diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml
index 1fa95a5e2d..4b6ef324b9 100644
--- a/.agents/notes/README.i18n.yaml
+++ b/.agents/notes/README.i18n.yaml
@@ -12,8 +12,8 @@
en: 055a5e49f9af3dc2
zh: 9f7c78409cccfded
/agent-notes/archiving-and-deletion:
- en: 6c393c7058c787d7
- zh: 70ce9ca3bd9c2133
+ en: 57ccc5fec461ebb0
+ zh: 69e8f9e79abc53aa
/agent-notes/when-to-write-one:
en: 2beb1b94d8fb633c
zh: 889b5542bb771205
diff --git a/.agents/notes/README.md b/.agents/notes/README.md
index 6872de0a13..8cee939fcf 100644
--- a/.agents/notes/README.md
+++ b/.agents/notes/README.md
@@ -39,7 +39,7 @@ Delete an implemented Agent Note that only describes small UI adjustments or pur
The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body.
-Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
+Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest, except for the exact seal transitions documented in [archive instructions](archived/AGENTS.md). The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale.
## When to write one
diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md
index ced81f77fa..6339f56f7c 100644
--- a/.agents/notes/README.zh.md
+++ b/.agents/notes/README.zh.md
@@ -41,7 +41,7 @@
归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。
-封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
+封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单),但[归档说明](archived/AGENTS.md)中记录的精确封存哈希转换除外。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。
diff --git a/.agents/notes/archived/AGENTS.md b/.agents/notes/archived/AGENTS.md
index 2ac4518a6a..d013ed068e 100644
--- a/.agents/notes/archived/AGENTS.md
+++ b/.agents/notes/archived/AGENTS.md
@@ -5,3 +5,5 @@ Archived Agent Note triplets under the kind directories are frozen historical sn
The archival change may only relocate a complete English/Chinese/sidecar triplet, insert the identical `Archived: YYYY-MM-DD` line below both `Status: implemented` lines, re-record the sidecar, and repair or delete inbound links. Do not inspect, verify, or repair links out of archived notes.
Run the [`dsh-archive-agent-notes`](../../skills/dsh-archive-agent-notes/SKILL.md) workflow and append new artifact hashes with `pnpm run verify-archived-agent-notes --write`. The normal verifier rejects changed or missing sealed artifacts, incomplete triplets, unknown kind folders, and invalid archive metadata.
+
+The authorized Figma-link removal from `feature/2026-08-10-durable-workflow-runs-in-chat` has three exact old-to-new seal exceptions in `scripts/archived-agent-notes.ts` for its English, Chinese, and pairing files. These exceptions permit no other content change, reversal, or deletion.
diff --git a/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml
new file mode 100644
index 0000000000..b5321c036f
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.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
+2026-09-24-web-default-schedule-composition.md: 3f27bb09ebc00d42c32563e17a1de82c8e525be1
+2026-09-24-web-default-schedule-composition.zh.md: 9e0e1fb77ce89b579e1bc8b0d4d0a1b9f4e9fea5
diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md
rename to .agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
index 91f533950a..3f27bb09eb 100644
--- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
@@ -1,6 +1,7 @@
# Agent Note: Schedule in the shipped Web composition
Status: implemented
+Archived: 2026-09-25
English | [中文](2026-09-24-web-default-schedule-composition.zh.md)
diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
similarity index 99%
rename from .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md
rename to .agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
index af561fe63e..9e0e1fb77c 100644
--- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.zh.md
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
@@ -1,6 +1,7 @@
# Agent Note: 默认 Web 组合中的 Schedule
Status: implemented
+Archived: 2026-09-25
[English](2026-09-24-web-default-schedule-composition.md) | 中文
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
index 284f43658a..871a8eefb8 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.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/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
-2026-08-10-durable-workflow-runs-in-chat.md: 01c59ef25996d6349ab2904f148bf4e72fdd5f09
-2026-08-10-durable-workflow-runs-in-chat.zh.md: 5f6b39b59beca5e2363261605ec58fbdcf71ea6f
+2026-08-10-durable-workflow-runs-in-chat.md: a49cb75e44bd65ef694679eeb295cfef564c7545
+2026-08-10-durable-workflow-runs-in-chat.zh.md: 753e02238cd8c00d0494cb673ba9896d61ff4cd8
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
index 01c59ef259..a49cb75e44 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.md
@@ -25,7 +25,7 @@ The renderer gives each level a distinct visual responsibility. The run uses a 3
Navigation is derived from two current authorities rather than persisted. A member row is interactive only while its durable member state is running and the current ordinary Session list contains the same id with `origin: 'subagent'`, `parentId` equal to the displayed parent, and `running: true`. Underlined member text is the only visible affordance; keyboard focus draws a two-pixel business-primary ring around the name area, and the fixed status label remains the lifecycle word rather than an action instruction. The renderer invokes only the injected ordinary `sessions.open(id)` callback. Addressed-only, remote, wrong-parent, and terminal members remain visible but static.
-The [seven-state Figma reference](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2) fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
+The seven-state Figma reference fixes the information hierarchy for running expanded/collapsed, completed history/expanded, failed plus cancelled, interrupted recovery, and dark narrow presentation. Repository `DisclosureRow`, `StateDot`, icons, semantic tokens, and keyed-node behavior remain the implementation authority; the reference introduces no runtime field or state owner.
## Verification
diff --git a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
index 5f6b39b59b..753e02238c 100644
--- a/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
+++ b/.agents/notes/archived/feature/2026-08-10-durable-workflow-runs-in-chat.zh.md
@@ -25,7 +25,7 @@ renderer 为每一层分配不同视觉职责。运行使用 32 像素 module-pl
导航从两个当前权威派生,不写入持久记录。只有持久成员状态仍为运行中,且当前普通 Session 列表包含同一 id、`origin: 'subagent'`、`parentId` 等于当前父 Session、`running: true` 时,成员行才可交互。带下划线的成员文字是唯一可见提示;键盘聚焦时,名称区显示 2 像素 business-primary 焦点环,固定状态列继续只表达生命周期,而不写动作说明。renderer 只调用注入的普通 `sessions.open(id)` 回调。仅地址化、远程、父级不符或终态成员继续可见,但保持静态。
-[七状态 Figma 参考](https://www.figma.com/design/tguwzZRmHCjbq58mfsqT0M?node-id=5-2)固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
+七状态 Figma 参考固定运行展开/收起、完成历史/展开、失败与取消、恢复后中断以及暗色窄列的信息层级。仓库的 `DisclosureRow`、`StateDot`、图标、语义 token 和 keyed-node 行为仍是实现权威;参考稿不引入运行时字段或状态 owner。
## 验证
diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json
index 5ed6850038..44178cc0ec 100644
--- a/.agents/notes/archived/manifest.json
+++ b/.agents/notes/archived/manifest.json
@@ -376,6 +376,9 @@
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.i18n.yaml": "sha256:af027c7dae53a181e4d73fd2309f69e9ae3c48df0d3b1dfc53cae274aa60d69e",
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.md": "sha256:44dd52839cdb67644c0dbcbc4beb6f36802628c4a8d17a0aad11db1f9be649ed",
"architecture/2026-09-11-wasm-preview-font-and-image-budgets.zh.md": "sha256:8285432ee1676be8b79a05953c4b6ac4a7ca5d8e701173b3a2b533608b584d0a",
+ "architecture/2026-09-24-web-default-schedule-composition.i18n.yaml": "sha256:8c3d5c614924d0e81f361a299427ac697aefc6189c41eb3f4151f5c42e371ca0",
+ "architecture/2026-09-24-web-default-schedule-composition.md": "sha256:3a139593ade13f95cbb67094c1eec66cb0a2c14bcc154512799803b2a7d22ab4",
+ "architecture/2026-09-24-web-default-schedule-composition.zh.md": "sha256:59908f7987058742d1226dc8a801d03c265c84d51172249ba51716b91f084f0c",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml": "sha256:8be5b0afd8820c593e2ec254d35fb8c384267814104f6234bb9af824c5f974ba",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md": "sha256:861e6130e893489271478def5f54f212a106a1580b744e6e40f56356f73c7e10",
"bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md": "sha256:3549257e06f074591de580ebfaa71284c7c384fb0ffb1963c30258d7a7c26db7",
@@ -1264,9 +1267,9 @@
"feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml": "sha256:74f519839f0cf82c7304bdeae41ae1cab8bb930bfb94f79ab44708acd3b72128",
"feature/2026-08-10-creator-guidance-introduce-cue.md": "sha256:3e25409dda498de150de18943ee332e1760963e377a40a365902b66f667fdc9f",
"feature/2026-08-10-creator-guidance-introduce-cue.zh.md": "sha256:203847010cab9e9d13c3969f17921d3a5aa0d69377eccfef555c7ff96572f162",
- "feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:0db3c21a3c8785e56d5d7b90df990b158069cd1cea664e62fc4786c6d7588450",
- "feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:f9f5290cd880908d17b1080ae5776f22e182f5253471f90cf8bead418b33b502",
- "feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:f4ffd2700bc9c2a0cc4b9d90f84e1d0a576939e2e6f0b130c5b142fd8cd518b0",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.i18n.yaml": "sha256:03eadb60c8ba4fde262cd61e3f911d8cd68ea4a58f57adeb8dd7bb9a97d08ca2",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.md": "sha256:6018de4a89ca99d6cbfd618aff1c8136b23d8e4854a30f7b12a602f0417cbdc2",
+ "feature/2026-08-10-durable-workflow-runs-in-chat.zh.md": "sha256:0080859221773122022d5880efa3d52f29c0872b52d63e170250d94f214ff37a",
"feature/2026-08-10-minimal-read-image-tool.i18n.yaml": "sha256:2311b6453b90a1f81d4fcbdaa723ab233cc0998f34393956364c8b92bc4d1213",
"feature/2026-08-10-minimal-read-image-tool.md": "sha256:debe6b570d11634f57a80eaa5891e4fd1e7a5aac19f10fb7dd1683c316515150",
"feature/2026-08-10-minimal-read-image-tool.zh.md": "sha256:572d080340682b354cb1f8a0f74af153d6542bd3dffbe3b72b32591e5d5d3baf",
diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml
index 800a60bd65..9de76f79b1 100644
--- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.i18n.yaml
@@ -9,11 +9,11 @@
en: 4945ce54f66df55c
zh: 9fbd833c1d8122a7
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/decision:
- en: 75b5f9e642b6c25c
- zh: c775a113d5ea0626
+ en: 2388519a1afaac92
+ zh: 6bf758f0560e29a5
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/incremental-session-log-field:
- en: edae015c24a2bc42
- zh: 0c7e23be17d9d424
+ en: 4e3138fbdd94259b
+ zh: 8fda801522045b88
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/plugin-package-field:
en: 0e51b8d8a1bd60fc
zh: dd18fd176c3f101f
@@ -33,5 +33,5 @@
en: b6eff63f49220dcc
zh: 8e4a6c785ed2256a
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/consequences:
- en: c9a0a5323480eb3d
- zh: 5df6e88de50038ce
+ en: 144fc01ad3b2b9bd
+ zh: 0acff935bd7835de
diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
index 7a3609a3f7..15dc88a3b6 100644
--- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
+++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.md
@@ -14,13 +14,13 @@ Both values belong only on the official DeepSeek adapter path. Adding them to `G
## Decision
-`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: both package metadata and Session-log upload are enabled by default; `session-log-deepseek.enabled: false` disables log upload under the [default-upload decision](2026-09-14-session-log-upload-default.md). Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes.
+`@deepseek-ai/dsh-deepseek-llm-api-extensions` registers `ctx.deepseekLlmApiExtensions`, an additive registry of top-level fields for `deepseek-official` request bodies. A contributor claims one declaration-merged field with `register()`. The adapter invokes `prepare()` after serializing the exact wire messages, passes the request cancellation signal, rejects preparation or base-field collision before HTTP, merges the detached fields, and calls the captured `accept()` transaction after HTTP 2xx. A merged body that fails to serialize is sent without extension fields or acceptance under the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md). The registry stops awaiting preparation after cancellation even if a contributor ignores the signal. Acceptance failures remain request failures under `REQUEST_EXTENSION`; transport and non-2xx failures never accept a contribution. A composition without the registry retains the reusable base adapter. Shipped compositions mount the registry and both contributors: both package metadata and Session-log upload are enabled by default; `session-log-deepseek.enabled: false` disables log upload under the [default-upload decision](2026-09-14-session-log-upload-default.md). Keyless `deepseek-official` replay invokes preparation with a synthetic empty base body and the same acceptance transaction before its first recorded chunk, preserving post-2xx extension side effects rather than field bytes.
The provider-neutral `llm` package and `llm-pi-ai` contain no extension type, service lookup, field merge, or acceptance call.
## Incremental session-log field
-`@deepseek-ai/dsh-session-log-deepseek` owns the default-on `dsh_session_log` field. When enabled, each request carrying a live Session id sends the contiguous canonical event suffix after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity. The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history.
+`@deepseek-ai/dsh-session-log-deepseek` owns the default-on `dsh_session_log` field. When enabled, each request carrying a live Session id sends the longest contiguous canonical event run after the greatest durable `session-log-deepseek/delivery-accepted` watermark for that same Session identity that fits the `maxBytes` limit of the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md). The field includes the immutable Session header and complete event envelopes. A 2xx appends a new watermark for the transmitted `throughSeq`; that event enters the following request's suffix. Forked logs retain parent watermark ids, so a child starts from sequence zero under its own identity. Concurrent acceptances may arrive out of order, and the maximum watermark remains authoritative. A process-local fold scans each Session event once and incrementally consumes later appends; a new Session object or HMR generation rebuilds the fold from durable history.
The failure direction is at least once. A transport or provider rejection records no watermark. A crash after remote acceptance but before the watermark persists causes replay after resume, never a skipped sequence. Existing session checkpoints persist the event; the upload plugin owns no second store.
@@ -85,7 +85,7 @@ About 98% of the measured v1 real-session events were `assistant/chunk`. Omittin
## Consequences
-Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. Unless Session-log upload is disabled, each eligible request also carries the complete newly unaccepted Session suffix. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata.
+Official DeepSeek requests carry active package versions to their resolved `baseURL`, including configured gateways. Unless Session-log upload is disabled, each eligible request also carries newly unaccepted Session events, up to `maxBytes` per request. The fields are model-hidden and add no prompt tokens or KV-cache changes, but can substantially increase HTTP body size. Manifest resolution, field collision, acceptance logging, or provider schema rejection fails the model request rather than silently dropping metadata. A merged body that fails to serialize is the exception: the request proceeds without extension fields and logs a warning.
The `delivery-accepted` event becomes part of the canonical log and is itself delivered on a later request. Crash recovery can duplicate a suffix but does not infer acceptance from assistant output or create a second local cursor store. Direct calls without a live Session omit the session field; host package inventory remains available.
diff --git a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md
index 69426df3cc..4358e0f86e 100644
--- a/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md
@@ -14,13 +14,13 @@ Status: implemented
## 决策
-`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
+`@deepseek-ai/dsh-deepseek-llm-api-extensions` 注册 `ctx.deepseekLlmApiExtensions`,即 `deepseek-official` 请求正文顶层字段的增量注册表。贡献方通过 `register()` 认领一个经声明合并的字段。适配器在序列化确切协议消息后调用 `prepare()`、传入请求取消信号,在 HTTP 前拒绝准备失败或基础字段冲突,合并分离字段,并在 HTTP 2xx 后调用捕获的 `accept()` 事务。合并后的正文无法序列化时,按[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)不带扩展字段发送,也不执行接受。即使贡献方忽略信号,注册表也会在取消后停止等待准备。接受失败仍以 `REQUEST_EXTENSION` 使请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。
## 增量会话日志字段
-`@deepseek-ai/dsh-session-log-deepseek` 拥有默认开启的 `dsh_session_log` 字段。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后的连续权威事件后缀。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。
+`@deepseek-ai/dsh-session-log-deepseek` 拥有默认开启的 `dsh_session_log` 字段。启用后,每个携带存活会话 id 的请求都会发送该确切会话身份最大持久 `session-log-deepseek/delivery-accepted` 水位之后、能放进[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)中 `maxBytes` 上限的最长连续权威事件段。该字段包含不可变会话 header 与完整事件信封。2xx 会为已发送的 `throughSeq` 追加新水位;该事件会进入下一次请求的后缀。Fork 日志会保留父级水位 id,因此子会话会在自己的身份下从序列零开始。并发接受可能乱序到达,最大水位仍保持权威。进程内 fold 会让每条会话事件只被扫描一次,并增量消费后续追加;新的会话对象或 HMR generation 会从持久历史重建该 fold。
失败方向为至少一次。传输失败或提供方拒绝不会记录水位。远端接受后、水位持久化前发生崩溃,会在恢复后触发重放,绝不会跳过序列。现有会话检查点会持久化该事件;上传插件不拥有第二份存储。
@@ -85,7 +85,7 @@ Status: implemented
## 后果
-DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带完整的未接受会话新后缀。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。
+DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带新的未接受会话事件,每次最多 `maxBytes`。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。合并后的正文无法序列化是例外:请求不带扩展字段继续发送,并记录一条告警。
`delivery-accepted` 事件会成为权威日志的一部分,并在后续请求中自行交付。崩溃恢复可能重复后缀,但不会根据 assistant 输出推断接受,也不会创建第二份本地游标存储。缺少存活会话的直接调用会省略会话字段;宿主包清单仍然可用。
diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
index d8cfb7a469..561af11c1a 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.i18n.yaml
@@ -15,14 +15,14 @@
en: 59c3648960630ff8
zh: 1891f8659e660e45
/agent-note-workspace-file-service/decision/the-workspacefiles-file-operations:
- en: cb489857d97a1b11
- zh: b1a7880df5607c05
+ en: efdecd9cf80c03e7
+ zh: 5ea40e48ab0559a2
/agent-note-workspace-file-service/decision/paths-on-the-wire:
en: 7b5a96adc08177bc
zh: ae4692e5e62e7de0
/agent-note-workspace-file-service/decision/file-checks-and-workspace-containment:
- en: 4399dcc29336f886
- zh: 69cf2fc174dc83a2
+ en: 1b588f346590a0fd
+ zh: 1cdac02b967b157f
/agent-note-workspace-file-service/decision/failures:
en: 0e419dd7bdcf38ba
zh: 6cb588a77593913c
@@ -45,8 +45,8 @@
en: 246bb7aed62f6c9b
zh: debf6d4129ee477b
/agent-note-workspace-file-service/testing:
- en: 3ab0dabf8ef85258
- zh: c3d0a634bb5e2928
+ en: 776c380b6150065e
+ zh: df3f0f1050966ff7
/agent-note-workspace-file-service/deferred:
en: b033218338ca0122
zh: aa48fa67f9eb36c3
diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
index 65bebf1817..7deb0dac14 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
+++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.md
@@ -41,7 +41,7 @@ Every Host method takes `WorkspaceFileScope` first. The `workspaceFileScope` Gat
- **`read`** returns one window of lines, `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`; `lines` counts the page's lines, so a page holding one empty line (`text: ''`, `lines: 1`) and a page past the end (`lines: 0`) read differently. `range.offset` is the 1-based first line and defaults to 1; `range.limit` is the largest number of lines and defaults to `maxLines`, which it may not exceed. Lines end at `\n` and a final `\n` terminates the last line rather than opening an empty one; `text` joins the page's lines with `\n` and carries no terminator; `eof` is true when the page includes the last line, and an offset past the end returns an empty page with `eof` true. The pager walks `streamText`, counts the lines before the window without keeping them, admits each in-window segment against `maxBytes` before buffering it, and returns at the first character past the window, so a file of any size costs one page of memory. The `version` and `bytes` on a page are the stat's, taken before the stream.
- **`readBytes`** returns native bytes with file metadata, offset, and EOF. The required options object selects a complete read under `maxFileBytes` when `range` is absent, or a bounded window under `maxBytes` when present. `range: {}` uses offset zero and the configured window cap; windows can read files above the complete-file cap. The [binary transfer decision](2026-09-17-workspace-file-binary-transfer.md) owns the Remote response encoding.
- **`baseFile`** selects target resolution independently of `range`: the target `path` must be relative and resolves from the base file's directory. The Host applies regular-file checks to both files and preserves backend read authority. [Document Preview](2026-09-08-document-preview-operations.md) owns content loading and address semantics.
-- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`, while opening such a child still fails the link gate below. Dotfiles are listed; nothing is filtered.
+- **`list`** returns `WorkspaceDirectoryListing { path, entries, truncated }`: the listed directory as a workspace path relative to the root (empty for the root), its direct children in the backend's stable name order as `{ name, type, size? }`, and whether `maxEntries` cut the list. `type` is `file`, `directory`, or `other`; a symlink child reports the type of what it points to and a dangling one is `other`. A child the listing reports as a directory opens through its resolved target, including a Windows junction, and the listing then names that target; [directory link listing](../bug-fix/2026-09-18-windows-directory-junction-listing.md) owns the rule. Dotfiles are listed; nothing is filtered.
- **`changes`** yields `WorkspaceFileWatchFrame`: `{ kind: 'ready' }` after the observation queue is registered and the workspace root resolves, followed by `{ kind: 'change', change }`. The `WorkspaceFileChange` payload is `{ absolutePath, version }` for a present file or `{ absolutePath, absent: true }` for one observed gone. Its source is `fs/observed` inside the workspace root, never an OS watcher. Observations after the first pull are queued, including during root resolution; cancellation or plugin disposal ends the generation.
### Paths on the wire
@@ -54,12 +54,12 @@ Two path vocabularies leave the service, and each method uses exactly one. `read
`read`, `readBytes`, and `stat` share regular-file checks and then rely on the filesystem backend's read authority. `list` shares path inspection but also checks workspace containment, while `changes` filters observations to the workspace root. The service applies the following checks:
-1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods and `not-directory` for `list`. An empty path is a `gateway/bad-request`.
+1. **The path itself.** `lstat` inspects the path before anything follows it: a missing path is `not-found`, and a symlink — wherever it points, including back inside the workspace — is `not-regular-file` (kind `symlink`) for the file methods. `list` instead follows a final link and judges the resolved target: the target must stat as a directory, and a link to a file or a link whose target is gone is `not-directory` (kind `symlink`). An empty path is a `gateway/bad-request`.
2. **Workspace containment for `list`.** The directory resolves to a target and `ctx.fs.contains(root, target)` decides, where `root` is the `WorkspaceFileScope.workspaceRoot` resolved from the selected Session header. A `..` traversal or an absolute directory outside the root is `outside-workspace`. `changes` applies the same backend containment predicate to observed targets.
3. **The caps.** A page or window above `maxBytes`, or a `read` asking for more than `maxLines`, is refused, never shortened, because a silently cut page reads as the whole page; a listing above `maxEntries` is cut and says so. Complete and related-file reads are refused above `maxFileBytes`.
4. **Text.** For `read` only: content that is not UTF-8 up to the end of the page, a NUL byte in the backend's 8 KiB opening sample, or a NUL byte anywhere in the page is `not-text`; bytes past the page are not inspected.
-After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position.
+After path inspection the file methods `stat` the resolved target once more, because the file may have gone or changed kind before the read: a vanished file is `not-found` and a replaced one `not-regular-file` with the new kind. For `list`, an outside entry whose type already disqualifies it reports its kind before its position; a final link resolves and passes containment before its target kind is read.
### Failures
@@ -140,7 +140,7 @@ The [resource model](2026-09-05-client-resource-model.md) owns `ctx.resources`,
## Testing
-Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, symlink children, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
+Host specs in `packages/api/workspace-files/tests` exercise header-only scope resolution for live and cold subagent Sessions, the deployment fallback, missing identities, and lookup disposal; the paged read (whole file, nested path, empty file, multi-byte UTF-8, the line window's edges, defaults and refused limits, carriage returns kept); the byte window (defaults, a middle window with more following, tail windows exact and short, past-end and empty files, NUL and invalid UTF-8 round-tripping through `Uint8Array`, version parity with `stat`, the cap as `too-large`, bad ranges, a window of a file far above the cap, and `eof` inferred without a size); `stat`; outside-workspace reads and backend refusals; `list` with containment, truncation, directory links, links whose target is gone, and `not-directory`; and the `changes` stream driven by `fs/observed` and filtered by root. Client specs cover the provider's frames, the change feed, unsupported addresses, and registration and disposal. `fs/fs` and `fs-local` specs pin `readByteRange`; `dsh-util-workspace-path` specs pin the file-address grammar. The connection fixture serves `stat`, paged `read`, `list`, and an opt-in `changes` frame for the web e2e suite.
## Deferred
diff --git a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
index b4ca420042..fe1dd00000 100644
--- a/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-05-workspace-files-service.zh.md
@@ -41,7 +41,7 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
- **`read`** 返回一个行窗口 `WorkspaceFileText = WorkspaceFileStat & { offset, text, lines, eof }`;`lines` 计页内行数,使只含一个空行的页(`text: ''`、`lines: 1`)与越过文件末尾的页(`lines: 0`)可区分。`range.offset` 是 1 起算的首行,缺省 1;`range.limit` 是最多行数,缺省 `maxLines` 且不得超过。行以 `\n` 结束,末尾的 `\n` 终止最后一行而不是开启一空行;`text` 以 `\n` 连接本页各行且不带终止符;页含最后一行时 `eof` 为 true,越过末尾的 offset 返回 `eof` 为 true 的空页。切页器沿 `streamText` 前进,数过窗口前的行而不保留,把每个窗内片段先按 `maxBytes` 核准再缓冲,并在越过窗口的第一个字符处返回,因此任意大小的文件只花一页内存。页上的 `version` 与 `bytes` 来自流之前的那次 stat。
- **`readBytes`** 返回原生字节、文件元数据、偏移量和 EOF。必填选项对象未传 `range` 时,在 `maxFileBytes` 上限内完整读取;传入时,在 `maxBytes` 上限内读取有界窗口。`range: {}` 使用零偏移和配置的窗口上限;窗口可读取超过完整文件上限的文件。[二进制传输决议](2026-09-17-workspace-file-binary-transfer.zh.md)负责 Remote 响应编码。
- **`baseFile`** 独立于 `range` 选择目标解析方式:目标 `path` 必须为相对路径,从基准文件所在目录解析。Host 对两个文件执行普通文件检查,并保留后端读取权限。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责内容加载与地址语义。
-- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`,而打开这样的子项仍会在下文的链接关被拒。dotfile 照常列出,不做任何过滤。
+- **`list`** 返回 `WorkspaceDirectoryListing { path, entries, truncated }`:被列目录相对根的工作区路径(根为空串)、其直接子项按后端的稳定名序以 `{ name, type, size? }` 给出,以及 `maxEntries` 是否截断了列表。`type` 为 `file`、`directory` 或 `other`;符号链接子项报告其指向目标的类型,悬空者为 `other`。列表报告为目录的子项经其解析目标打开,Windows 目录联接亦然,此时列表以该目标命名;[目录链接列举](../bug-fix/2026-09-18-windows-directory-junction-listing.zh.md)拥有该规则。dotfile 照常列出,不做任何过滤。
- **`changes`** 产出 `WorkspaceFileWatchFrame`:在观察队列注册且工作区根解析完成后先发 `{ kind: 'ready' }`,随后为 `{ kind: 'change', change }`。载荷 `WorkspaceFileChange` 对存在的文件为 `{ absolutePath, version }`,对消失的文件为 `{ absolutePath, absent: true }`。来源是工作区根内的 `fs/observed`,不监视操作系统。首次拉取后的观察都会排队,包括根解析期间的观察;取消或插件释放会结束该代流。
### 线路上的路径
@@ -54,12 +54,12 @@ Web 客户端需要从一个未必在 Host 机器上的浏览器查看会话工
`read`、`readBytes` 与 `stat` 共享普通文件检查,之后依赖文件系统后端的读取权限。`list` 共享路径检查,但还会检查工作区包含关系;`changes` 则把观察过滤到工作区根内。服务执行以下检查:
-1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`),对 `list` 为 `not-directory`。空路径是 `gateway/bad-request`。
+1. **路径本身。** `lstat` 在跟随任何东西之前检查路径:缺失路径为 `not-found`;符号链接——不论指向哪里,包括指回工作区内——对文件方法为 `not-regular-file`(kind 为 `symlink`)。`list` 改为跟随末端链接并判定解析后的目标:目标经 stat 必须为目录,指向文件的链接或目标已消失的链接为 `not-directory`(kind 为 `symlink`)。空路径是 `gateway/bad-request`。
2. **`list` 的工作区包含。** 目录解析为目标,由 `ctx.fs.contains(root, target)` 判定,其中 `root` 是从所选 Session header 解析出的 `WorkspaceFileScope.workspaceRoot`。`..` 爬出或根外绝对目录为 `outside-workspace`。`changes` 对观察到的目标使用相同的后端包含判定。
3. **上限。** 超过 `maxBytes` 的页或窗口,或 `read` 索要超过 `maxLines` 的行数,一律拒绝、绝不截短,因为悄悄截短的页读起来就像整页;超过 `maxEntries` 的列表被截断并如实报告。全文及关联文件读取超过 `maxFileBytes` 时被拒绝。
4. **文本。** 仅限 `read`:到页末为止不是 UTF-8 的内容、后端 8 KiB 开头样本里的 NUL 字节,或页内任何位置的 NUL 字节,都是 `not-text`;页之后的字节不检查。
-路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置。
+路径检查之后,文件方法再对解析出的目标 `stat` 一次,因为文件可能在读取前已消失或换了种类:消失者为 `not-found`,被替换者为带新种类的 `not-regular-file`。对 `list` 而言,根外条目若类型本身已不合格,会先报告其种类而不是位置;末端链接先解析并通过包含检查,再读取其目标种类。
### 失败
@@ -140,7 +140,7 @@ Client 导出向 `ctx.resources` 注册一个 `ResourceProvider<'file'>`,存
## Testing
-`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、符号链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
+`packages/api/workspace-files/tests` 中的 Host spec 覆盖 live 与 cold subagent Session 的 header-only scope 解析、部署 fallback、缺失身份与 lookup 释放;分页读取(整文件、嵌套路径、空文件、多字节 UTF-8、行窗口边界、缺省与拒绝的 limit、保留回车);字节窗口(缺省、中段与尾窗、越界与空文件、`Uint8Array` 往返、版本、上限、坏范围以及无大小时的 `eof`);`stat`;工作区外读取及后端拒绝;`list` 的包含、截断、目录链接、目标已消失的链接与 `not-directory`;以及由 `fs/observed` 驱动并按根过滤的 `changes`。Client spec 覆盖提供者帧、变更流、不支持地址及注册与释放。`fs/fs` 与 `fs-local` spec 钉住 `readByteRange`;`dsh-util-workspace-path` spec 钉住文件地址语法。connection fixture 为 web e2e 套件提供 `stat`、分页 `read`、`list` 与一帧可选启用的 `changes`。
## Deferred
diff --git a/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.i18n.yaml
index 60583babce..76754c3084 100644
--- a/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.i18n.yaml
@@ -15,5 +15,5 @@
en: 4cc4b61d76f84ff3
zh: bdd29ea66037f744
/agent-note-default-deepseek-session-log-upload/consequences:
- en: 1302d84a3d2b168b
- zh: aec67b793539f898
+ en: 31d0c8deb33dbe3f
+ zh: 8af3905630c629a0
diff --git a/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.md b/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.md
index c1e7c9e050..232ab867b1 100644
--- a/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.md
+++ b/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.md
@@ -23,6 +23,6 @@ This supersedes only the opt-in default in the [request-extension decision](2026
## Consequences
-Eligible requests send the complete unaccepted canonical log suffix, including message text, tool arguments and results, workspace paths, and feedback, to the resolved DeepSeek endpoint or configured gateway. No prompt tokens or model-visible content are added. Request bodies can grow substantially, and provider rejection still fails the request. OTel remains independent; disabling OTel does not disable this contribution.
+Eligible requests send the unaccepted canonical log suffix, up to `maxBytes` per request under the [bounded-upload decision](2026-09-24-bounded-session-log-upload.md), including message text, tool arguments and results, workspace paths, and feedback, to the resolved DeepSeek endpoint or configured gateway. No prompt tokens or model-visible content are added. Request bodies grow by up to that limit, and provider rejection still fails the request. OTel remains independent; disabling OTel does not disable this contribution.
Configuration tests cover default-on and explicit overrides with and without test environment markers. Both DeepSeek protocol Loader cases observe the default request field and recorded acceptance watermark, and explicit-off cases observe their absence. Existing headless, ACP, Web, and SDK recordings validate their declared upload policies without rewriting committed Session generations.
diff --git a/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.zh.md b/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.zh.md
index d7b6e64098..0fd8210e46 100644
--- a/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-14-session-log-upload-default.zh.md
@@ -23,6 +23,6 @@ Status: implemented
## 后果
-符合条件的请求会向解析后的 DeepSeek 端点或已配置网关发送完整的未接受规范日志后缀,包括消息文本、工具参数与结果、工作区路径和反馈。不增加提示词 token 或模型可见内容。请求正文可能显著增大,提供方拒绝仍会使请求失败。OTel 保持独立,关闭 OTel 不会关闭此贡献。
+符合条件的请求会向解析后的 DeepSeek 端点或已配置网关发送未接受的规范日志后缀,按[有上限上传决策](2026-09-24-bounded-session-log-upload.zh.md)每次最多 `maxBytes`,包括消息文本、工具参数与结果、工作区路径和反馈。不增加提示词 token 或模型可见内容。请求正文最多增加该上限,提供方拒绝仍会使请求失败。OTel 保持独立,关闭 OTel 不会关闭此贡献。
配置测试覆盖有无测试环境标记时的默认开启与显式覆盖。两种 DeepSeek 协议的 Loader 用例观察默认请求字段和已记录的接受水位,显式关闭用例观察两者均不存在。现有 headless、ACP、Web 和 SDK 录制验证各自声明的上传策略,无需重写已提交的会话代际。
diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml
index 159fee0bf9..8bc4eaeaa5 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml
@@ -15,5 +15,5 @@
en: 21d7ec9a9ff3cbf2
zh: 93d03b8b3012aa7e
/agent-note-host-owned-scheduled-messages/consequences:
- en: 0358aab730e4bf56
- zh: 61e89e0374891f8a
+ en: a48f13f89a1944b1
+ zh: f8fa677122ce2e12
diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md
index 79a4f72071..3d90e8ad7e 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md
@@ -58,7 +58,7 @@ Session inbox persistence and the task put occur in separate persistence systems
**Keep the mock's hourly choice out of the stored vocabulary.** The approved mock lists 每小时整点 beside the daily, weekly and one-shot choices. Hourly and fixed-rate intervals are the same elapsed-time rule, so the card maps that menu slot onto the existing interval kind instead of adding a persisted kind. A dedicated hourly kind would need a new durable variant, its decoder, its target resolution, and its tool schema, all to express what an interval already expresses.
-Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and the Web bundle mounts Schedule in its host row list. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
+Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and enabling the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` mounts Schedule; the Web composition carries its row disabled. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
Required coverage includes startup from stored tasks, missing-status normalization, cold Session restoration, latest-only catch-up, inactive-task retention without repeat dispatch, real message receipts, deletion across restart, removal of a deleted task's saved deliveries, queue/persistence failures, and disposal. Wall-clock obligations include gaps and skipped dates, earlier-only overlaps, fractional seconds, UTC-range edges, committed-target and alias preservation, and post-restoration clock rollback. Weekly adds weekday normalization and validation, next-target selection across a gap and an overlap, and stored set preservation. Historical decoders must reject Daily, Weekly, and Cron while Host storage accepts them. Client coverage includes every recurring kind, exact rule time, zone, and weekday set, All/Enabled/Inactive status filters, retained inactive details and original-Session navigation, authoritative refresh after confirmed deletion and notification, empty lists, reconnect, and late responses. Presentation coverage includes the page's New action starting a Session, the transcript card's narrowing of persisted result JSON including an incomplete or replayed result, the header clock's direct open for exactly one task and its count label, the idle-only row mark with the hover-card two-task limit and omission line. History coverage includes legacy byte preservation, append and restart, immutable sent instructions, strict latest-receipt consistency, stable cursor pagination, binding checks, failed persistence, and late page replies during refresh or task changes. Cross-store crash duplication remains an explicit limitation rather than a completion claim.
diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md
index 393a773fa0..f7b9a8f889 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md
@@ -58,7 +58,7 @@ Session 收件箱持久化和任务 put 发生在两个持久化系统中。收
**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。
-任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,Web bundle 在其宿主行列表中挂载 Schedule。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
+任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,而挂载 Schedule 需要启用可选实验 bundle `@deepseek-ai/dsh-experimental-schedule-bundle`;Web 组合以禁用状态携带其条目。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
必要覆盖范围包括从已存储任务启动、缺失状态规范化、冷 Session 恢复、仅补发最新发生时点、保留未运行任务且不重复投递、真实消息回执、重启后的删除结果、删除会移除已保存发送记录、排队或持久化失败,以及销毁。钟表规则的验证要求包括缺口和跳过的日期、仅选择较早时点的重叠、小数秒、UTC 范围边缘、已提交目标和别名保留,以及恢复后的时钟回拨。Weekly 另需覆盖星期集合的规范化与校验、跨缺口与重叠的下一目标选择,以及已存集合的保留。历史解码器必须拒绝 Daily、Weekly 与 Cron,而宿主存储接受它们。客户端覆盖范围包括每种重复规则、精确规则时间、时区和星期集合、全部/已开启/未运行状态筛选、保留未运行任务详情和原 Session 导航、确认删除和通知后的权威刷新、空列表、重新连接和晚到响应。呈现覆盖范围包括页面“新建”操作打开新会话、transcript 卡片从不完整或回放的结果 JSON 中收窄任务、页头时钟在恰好一个任务时直接打开并给出数量标签、仅空闲行标记及其悬浮卡片两个任务上限与遗漏行。历史覆盖范围包括旧文件字节保留、追加与重启、不可变的发送指令、最近回执一致性严格校验、稳定游标分页、绑定校验、持久化失败,以及刷新或切换任务期间晚到的分页响应。跨存储崩溃导致的重复投递仍是明确限制,不能被表述为完成保证。
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
index e8573b2e72..521567dd7e 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
@@ -9,11 +9,11 @@
en: 2ba6c43b20a3d347
zh: 5647179ddaa38ad9
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
- en: 1c5a43b3ce2d3056
- zh: b59f549a12cf67d1
+ en: fdd54ede661f7a83
+ zh: a41ed1a872bb8ba3
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
en: a65d84004e48b3ef
zh: 17221f08e7a8a89b
/agent-note-ship-experimental-capabilities-as-optional-bundles/consequences:
- en: d52ed0a248131a37
- zh: 779b8d415281c5da
+ en: e7fb2a93a89ba9b6
+ zh: 37ed9f0a3ca44583
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
index a2a83645c9..b7139f6d9a 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
@@ -10,7 +10,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
## Decision
-`OPTIONAL_BUNDLES` lists Agent Teams, voice input, and Auto review. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch.
+`OPTIONAL_BUNDLES` lists Agent Teams, voice input, Auto review, and the Schedule bundle. Every optional bundle declares `icon` and exports `./locale/*.json` with `meta.title` and `meta.description`, so the Official group renders a localized title, description, and image; `verify-default-product-isolation` rejects an optional bundle without them. The Inspector's `cordis.patch.yml` names the package instead of a built-artifact path, so the same file is the bundle patch and the `--patch` overlay for a built Web launch.
An optional bundle is a runtime dependency of the installation, so its dependency graph is downloaded by every `dsh` install. The list therefore admits only packages whose graph is already in the installation's closure; Auto review adds nothing. The Inspector stays available through explicit installation and does not appear in the default plugin list. The browser-use and computer-use providers stay explicit compositions: Playwright MCP, Chrome DevTools MCP, and the native Cua Driver runtime binaries add about 85 MB and 21 packages to every install whether or not the bundle is enabled, and a shipped Cua Driver MCP switch would offer a capability whose executable the installation does not carry. Those provider packages keep locale display metadata for their component rows. Two more packages stay out for other reasons: `ptc-runtime-python` replaces the PTC runtime, and `workflow-ptc` rejects a non-TypeScript runtime at load while the Web presets carry `workflow-ptc` rows a bundle patch cannot reach; `browser-use-stagehand-native` requires a native model name and API key at schema validation with no configuration form on the page.
@@ -22,4 +22,4 @@ An optional bundle is a runtime dependency of the installation, so its dependenc
## Consequences
-The Official group grows from two to three entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
+The Official group carries four optional-bundle entries, each tagged experimental by its experimental name. The runtime dependency closure of the installation is unchanged. A browser-use or computer-use provider still needs its Service Definition and the provider in a profile patch or composition.
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
index 62e248e5ae..af4048bc5e 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
@@ -10,7 +10,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
## 决策
-`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入与 Auto review。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。
+`OPTIONAL_BUNDLES` 列出 Agent Teams、语音输入、Auto review 与 Schedule bundle。每个可选 bundle 都声明 `icon` 并导出带 `meta.title` 与 `meta.description` 的 `./locale/*.json`,官方分组因此能渲染本地化标题、描述和图片;`verify-default-product-isolation` 会拒绝缺少这些声明的可选 bundle。Inspector 的 `cordis.patch.yml` 直接写包名而非构建产物路径,所以同一文件既是 bundle patch,也是已构建 Web 启动的 `--patch` overlay。
可选 bundle 是安装的运行时依赖,其依赖图会随每次 `dsh` 安装一起下载。因此名单只接纳依赖图已在安装闭包内的包;Auto review 不增加任何依赖。Inspector 保留显式安装方式,不在默认插件列表中显示。浏览器操作与电脑操作提供方仍保持显式组合:Playwright MCP、Chrome DevTools MCP 与原生 Cua Driver 运行时二进制会让每次安装多出约 85 MB、21 个包,无论 bundle 是否启用;而随附的 Cua Driver MCP 开关会提供一个安装本身并不携带其可执行文件的能力。这些提供方包保留 locale 显示元数据,供组件行使用。另有两个包因其他原因不纳入:`ptc-runtime-python` 会替换 PTC 运行时,而 `workflow-ptc` 在加载时拒绝非 TypeScript 运行时,且 Web preset 内含 bundle patch 无法触及的 `workflow-ptc` 行;`browser-use-stagehand-native` 在 schema 校验时就要求原生模型名称与 API 密钥,而插件页没有对应的配置表单。
@@ -22,4 +22,4 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
## 后果
-官方分组从两项增至三项,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
+官方分组包含四个可选 bundle 条目,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
diff --git a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.i18n.yaml
new file mode 100644
index 0000000000..64f1ca3e65
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-24-bounded-session-log-upload.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md
+/agent-note-bounded-deepseek-session-log-upload:
+ en: d35839d24f5704a7
+ zh: 8bc7e460fd3bd936
+/agent-note-bounded-deepseek-session-log-upload/problem:
+ en: b6b54d7ee8810f7e
+ zh: b0fdfa6c2c3cc224
+/agent-note-bounded-deepseek-session-log-upload/decision:
+ en: d2fe09351a82f7d6
+ zh: ad0741f63e14a8ad
+/agent-note-bounded-deepseek-session-log-upload/alternatives-considered:
+ en: 88985b4e1f5c7a4f
+ zh: 32617f7ff75ee825
+/agent-note-bounded-deepseek-session-log-upload/consequences:
+ en: e19fb6136ffd2250
+ zh: 01dacd7a44c3f837
diff --git a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md
new file mode 100644
index 0000000000..b980765eed
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.md
@@ -0,0 +1,30 @@
+# Agent Note: Bounded DeepSeek Session-log upload
+
+Status: implemented
+
+English | [中文](2026-09-24-bounded-session-log-upload.zh.md)
+## Problem
+
+Without a watermark of the current Session format generation, a request carries the whole canonical log: the first request after upload becomes enabled, the first request after a format migration, and the first request of a fork all do. A long log can exceed the V8 string limit of 2^29 − 24 UTF-16 code units on 64-bit hosts. The adapter's merged `JSON.stringify` then throws `RangeError`, the adapter reports `TRANSPORT`, the default retry policy repeats the same serialization, and no request reaches the 2xx that would advance the watermark, so every model request in that Session fails. Below the limit, one request can still carry hundreds of MiB: on Node v24.17.0 and macOS arm64, a 269 MiB first upload of a 96,341-event Session waited 34 s for its 2xx.
+
+## Decision
+
+`session-log-deepseek.Config.maxBytes`, 8 MiB by default, bounds the complete serialized `dsh_session_log` field in UTF-8 bytes. Each request carries the longest contiguous run after the watermark whose field, including the header and numeric envelope fields, fits the limit. Acceptance advances the watermark to that run's `throughSeq`, and later requests continue from there. When the first pending event alone exceeds the limit, the request omits the field, the plugin logs a warning, and the watermark stays before that event until `maxBytes` admits it. An event whose JSON text exceeds the runtime's string limit counts as exceeding every limit, so it blocks upload without failing the request, and the plugin serializes it once per request.
+
+The DeepSeek adapter treats a merged request body that fails to serialize as an extension failure rather than a transport failure. It sends the base body without any extension field, skips the joint acceptance transaction, and reports the omitted field names through the provider plugin's logger, so contributors resend unaccepted state on a later request. A base body that cannot serialize still fails the request.
+
+This supersedes complete-suffix delivery and fail-closed serialization in the [request-extension decision](2026-08-21-deepseek-llm-api-request-extensions.md). That note still owns field ownership, cancellation, acceptance, and the remaining fail-closed failures.
+
+## Alternatives considered
+
+**Serialize the whole pending run first and bound it only on overflow.** Measuring bytes requires serializing every candidate event either way. In a synthetic benchmark, a 14 KiB step took 14.3 µs whole versus 13.8 µs per event, while a 135 MiB backlog took 182 ms to serialize before any fallback versus 10 ms to fill 8 MiB. The whole-run attempt also still overflows the string limit for the logs that motivated this change.
+
+**Send an oversized first event alone.** Exceeding the configured limit reintroduces the provider body-size rejections that still fail the model request. Omission keeps every request bounded, and the warning names the blocked event.
+
+**Omit only the field that fails to serialize.** Acceptance is one joint transaction across fields, so per-field omission would split the registry's acceptance contract. Dropping every extension field on this path only leaves the small `dsh_plugin_packages` inventory out of one request.
+
+## Consequences
+
+A backlog drains at up to 8 MiB per accepted request by default. At the end-to-end rate measured for the 269 MiB upload, about 8 MiB/s, an 8 MiB field adds about 1 s to each request while a backlog drains, and a 0.86 GB backlog drains in about 100 requests. In the same 96,341-event Session, the median per-step append is 11 KiB and the largest event is 414 KiB, so ordinary steps stay far below the limit and only an unusual event or a small configured limit reaches the oversized-event path. While a backlog drains, the endpoint receives a `throughSeq` behind the Session's current tip. Preparation failures, acceptance failures, and provider rejections still fail the model request.
+
+Plugin tests cover the exact byte boundary, UTF-8 counting, backlog draining across accepted requests, the oversized-event warning with its unchanged watermark, an event past the string limit, and the 8 MiB default through plugin configuration. Adapter tests cover the base-body fallback through the provider plugin's logger and through an adapter without a reporter.
diff --git a/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.zh.md b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.zh.md
new file mode 100644
index 0000000000..13dd90ed0a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-bounded-session-log-upload.zh.md
@@ -0,0 +1,30 @@
+# Agent Note: 有上限的 DeepSeek 会话日志上传
+
+Status: implemented
+
+[English](2026-09-24-bounded-session-log-upload.md) | 中文
+## 问题
+
+没有当前会话格式代的水位时,请求会携带整份规范日志:开启上传后的第一次请求、格式迁移后的第一次请求,以及 fork 的第一次请求都是如此。长会话日志可能超过 64 位宿主上 V8 的字符串上限,即 2^29 − 24 个 UTF-16 码元。此时适配器合并正文的 `JSON.stringify` 会抛出 `RangeError`,适配器报告 `TRANSPORT`,默认重试策略重复同样的序列化,任何请求都无法拿到推进水位所需的 2xx,于是该会话的每次模型请求都会失败。即使低于上限,单次请求仍可能携带数百 MiB:在 Node v24.17.0 与 macOS arm64 上,一个 96,341 条事件的会话首次上传 269 MiB,等待 34 秒才收到 2xx。
+
+## 决策
+
+`session-log-deepseek.Config.maxBytes` 默认 8 MiB,按 UTF-8 字节限制完整的序列化 `dsh_session_log` 字段。每次请求携带水位之后字段能放进上限的最长连续事件段,字段大小包括 header 与数值型 envelope 字段。接受操作把水位推进到该段的 `throughSeq`,后续请求从那里继续。第一条待发事件本身就超过上限时,请求省略该字段,插件记录一条告警,水位停在该事件之前,直到 `maxBytes` 能容纳它。JSON 文本超过运行时字符串上限的事件视为超过任何上限,因此它会阻塞上传但不会使请求失败,插件每次请求只序列化它一次。
+
+DeepSeek 适配器把合并后请求正文的序列化失败视为扩展失败,而非传输失败。它发送不含任何扩展字段的基础正文,跳过联合接受事务,并通过提供方插件的 logger 报告被省略的字段名,因此贡献方会在之后的请求中重发未接受的状态。基础正文本身无法序列化时,请求仍然失败。
+
+本决策取代[请求扩展决策](2026-08-21-deepseek-llm-api-request-extensions.zh.md)中的完整后缀交付与 fail-closed 序列化。该记录仍负责字段所有权、取消、接受以及其余 fail-closed 失败。
+
+## 考虑过的替代方案
+
+**先序列化整个待发事件段,只在超限时再按上限截取。** 无论哪种方式,量字节都要序列化每个候选事件。合成基准中,14 KiB 的一步整体序列化耗时 14.3 µs,逐条累加耗时 13.8 µs;135 MiB 的积压在回退前整体序列化就要 182 ms,逐条装满 8 MiB 只需 10 ms。对促成本改动的日志,整体尝试仍会超过字符串上限。
+
+**单独发送超限的第一条事件。** 超过配置上限会重新引入提供方请求体大小拒绝,而这类拒绝仍会使模型请求失败。省略字段能让每次请求都有上限,告警也会指出被阻塞的事件。
+
+**只省略序列化失败的那个字段。** 接受是跨字段的一个联合事务,按字段省略会拆分注册表的接受契约。在这条路径上丢弃全部扩展字段,只会让体积很小的 `dsh_plugin_packages` 清单缺席一次请求。
+
+## 后果
+
+默认情况下,积压按每次已接受请求最多 8 MiB 补传。按那次 269 MiB 上传实测的端到端速度(约 8 MiB/s),补传期间 8 MiB 的字段让每次请求多约 1 秒,0.86 GB 的积压约 100 次请求补完。在同一个 96,341 条事件的会话中,每步追加量的中位数是 11 KiB,最大单条事件是 414 KiB,因此普通步骤远低于上限,只有异常事件或很小的配置上限才会走到超限事件路径。积压补传期间,端点收到的 `throughSeq` 会落后于会话当前末尾。准备失败、接受失败与提供方拒绝仍会使模型请求失败。
+
+插件测试覆盖精确字节边界、UTF-8 计数、跨已接受请求的积压补传、超限事件告警及其不变的水位、超过字符串上限的事件,以及经插件配置生效的 8 MiB 默认值。适配器测试覆盖经提供方插件 logger 报告的基础正文回退,以及没有 reporter 的适配器上的同一回退。
diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml
new file mode 100644
index 0000000000..ca6497cb81
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-24-schedule-opt-in-optional-bundle.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
+/agent-note-schedule-as-an-opt-in-optional-bundle:
+ en: 8c2381fd778a9888
+ zh: 8877255cb980eea1
+/agent-note-schedule-as-an-opt-in-optional-bundle/problem:
+ en: bb23c2f7f93a462b
+ zh: 9fb600fc8e203323
+/agent-note-schedule-as-an-opt-in-optional-bundle/decision:
+ en: 483d1dd51b9cc073
+ zh: b069e2e2385c8132
+/agent-note-schedule-as-an-opt-in-optional-bundle/alternatives-considered:
+ en: 8e5f4d8fa8c2e78d
+ zh: 3353c7042c6a1d6b
+/agent-note-schedule-as-an-opt-in-optional-bundle/consequences:
+ en: 36cd0eb71abeedbe
+ zh: 7c134095cc19fcfd
diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
new file mode 100644
index 0000000000..865f97848d
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.md
@@ -0,0 +1,35 @@
+# Agent Note: Schedule as an opt-in optional bundle
+
+Status: implemented
+
+English | [中文](2026-09-24-schedule-opt-in-optional-bundle.zh.md)
+
+## Problem
+
+Schedule adds four tool schemas to every live root Agent request and one durable clock message per eligible step, so the shipped Web composition must not mount it by default. A person using the shipped Web profile still needs a product-surface switch to turn it on; a profile patch layer or a `--patch` overlay is a configuration file that person cannot reach.
+
+## Decision
+
+`packages/bundle/web-app/cordis.patch.yml` carries none of `time-context`, `schedule`, and `ui-schedule`, so the shipped Web composition exposes no Schedule surface: no `schedule_*` tools, no Session reminder catalog, no Automation tasks page, and no per-step clock reading.
+
+`@deepseek-ai/dsh-experimental-schedule-bundle` (`packages/experimental/schedule-bundle/`) inserts the three rows in its `cordis.patch.yml` and depends on their packages, as every other optional bundle inserts the rows it ships. `OPTIONAL_BUNDLES` in `packages/boot/app-boot/src/profile.ts` names the package, and `apps/cli` declares it as a runtime dependency, so every installation ships it switched off. The [experimental-as-optional-bundles decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns the `OPTIONAL_BUNDLES` conventions and the localized `icon` and `meta.title` / `meta.description` metadata the Web Plugins page renders in its Official group.
+
+Enabling the bundle inserts `time-context` (a per-step clock reading with the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message), `schedule` (durable reminders plus `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on live root Agents), and `ui-schedule` (the Session reminder catalog and the Automation tasks page). The bundle carries `time-context` with `schedule` because a reminder request states a wall-clock target: the sampled instant is what lets the model turn a request such as "tomorrow at nine" into an offset-bearing `at` value. The [Schedule subsystem](../../../../docs/subsystems/schedule.md) owns the durable records, delivery, and management operations; the [Web bundle](../../../../packages/bundle/web-app/README.md) owns the composition the bundle adds the rows to. Disabling the bundle restores the shipped composition, and the Host stops scheduling while the `schedule` row is absent; stored task records remain in the Schedule domain.
+
+## Alternatives considered
+
+**Ship Schedule on by default.** Every Web session then pays four tool schemas in each request header and one durable user message per eligible step, and a conversation that never creates a reminder pays both costs. The arrangement also left the capability without an off switch on the product surface.
+
+**Keep a `--patch` overlay file.** An overlay is a launch-time argument rather than a product-surface switch, so a person using the shipped Web profile cannot reach it. Re-declaring the rows through its `insert` list does not override them: `applyEntryPatches` appends that list without de-duplicating ids, and the Loader collapses the composed list to one entry per id with the last declaration winning, so the overlay replaces the Web bundle's rows instead of setting fields on them.
+
+**Keep the rows in the Web composition with `disabled: true` and switch them on with id-targeted patches.** The bundle would then set fields on existing rows, but the plugin manager lists only the rows a bundle inserts, so the bundle's page reported no components and needed a second listing path for overridden rows, while every other optional bundle inserts its rows. A profile that also declares one of the ids resolves the same way as for any other bundle: the Loader keeps the last declaration.
+
+**Lock the three rows to the bundle switch.** A bundle manifest field that makes the plugin manager refuse to switch the rows one by one adds a plugin-manager concept only this bundle would use. The bundle's page offers a switch per row, as for every other bundle, and the bundle README records that the three rows work only together.
+
+## Consequences
+
+- A default `dsh web` session carries no Schedule tool schemas, no Session reminder catalog, no Automation tasks page, and no per-step clock reading. An installation that wants them enables the optional bundle from the Plugins page or lists the package in a profile's `dsh.profile.bundles`.
+- An enabled installation adds four tool schemas to every live root Agent and one durable clock message per eligible step; the reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message.
+- The Plugins page lists the three rows under the bundle with the titles their packages' `locale/*.json` declare, their state, and, while the bundle is on, a switch per row; switching one of them off leaves Schedule without that part.
+- A profile patch or `--patch` overlay that targets one of the three ids matches no row while the bundle is not selected, and the loader warns `patch: entry not found` for it; selecting the bundle replaces switching the rows on by id.
+- The switch is configuration-only: `src/index.ts` is an empty module, the patch carries the runtime content, and the package owns no mutable runtime state, so it publishes no invariant companion.
diff --git a/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md
new file mode 100644
index 0000000000..a90516606a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-24-schedule-opt-in-optional-bundle.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: Schedule 作为按需开启的可选 bundle
+
+Status: implemented
+
+[English](2026-09-24-schedule-opt-in-optional-bundle.md) | 中文
+
+## Problem
+
+Schedule 会给每个活跃根 Agent 的请求增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息,因此随发行版交付的 Web 组合不能默认挂载它。使用随发行版 Web profile 的人仍需要产品界面上的开关来打开它;profile patch 层或 `--patch` overlay 都是这个人无法触及的配置文件。
+
+## Decision
+
+`packages/bundle/web-app/cordis.patch.yml` 不含 `time-context`、`schedule` 与 `ui-schedule` 中的任何一行,因此随发行版交付的 Web 组合不提供任何定时能力:没有 `schedule_*` 工具、没有 Session 提醒目录、没有自动化任务页面,也没有逐步时钟读数。
+
+`@deepseek-ai/dsh-experimental-schedule-bundle`(`packages/experimental/schedule-bundle/`)在其 `cordis.patch.yml` 中插入这三行并依赖它们的包,与其他可选 bundle 插入各自随附的行相同。`packages/boot/app-boot/src/profile.ts` 的 `OPTIONAL_BUNDLES` 列出该包,`apps/cli` 将其声明为运行时依赖,因此每次安装都随包携带且默认关闭。[实验性能力作为可选 bundle 的决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)负责 `OPTIONAL_BUNDLES` 的约定,以及 Web 插件管理页在“官方”分组中渲染的本地化 `icon` 与 `meta.title` / `meta.description` 元数据。
+
+启用该 bundle 会插入 `time-context`(每个步骤的时钟读数,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长)、`schedule`(持久提醒,以及每个活跃根 Agent 上的 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`)与 `ui-schedule`(Session 提醒目录与自动化任务页面)。该 bundle 把 `time-context` 与 `schedule` 一起携带,因为提醒请求给出的是挂钟时间目标:采样瞬时正是让模型把“明天九点”这类请求转换成带偏移量的 `at` 值的依据。持久记录、投递与管理操作由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责;该 bundle 向其加入这些行的组合由 [Web bundle](../../../../packages/bundle/web-app/README.zh.md) 负责。禁用该 bundle 会恢复随发行版交付的组合,`schedule` 行不存在期间 Host 不再调度;已存储的任务记录保留在 Schedule domain 中。
+
+## Alternatives considered
+
+**默认发布 Schedule。** 那样每个 Web 会话都要在每个请求头中带上四个工具 schema,并在每个符合条件的步骤追加一条持久 user 消息,从不创建提醒的对话也要承担这两项成本。这种安排还让该能力在产品界面上没有关闭开关。
+
+**保留一个 `--patch` overlay 文件。** overlay 是启动时的参数,而不是产品界面上的开关,因此使用随发行版 `web` profile 的人无法触及它。通过它的 `insert` 列表重新声明这些条目并不能覆盖它们:`applyEntryPatches` 追加该列表时不对 id 去重,而 Loader 会把组合出的列表按 id 收敛为一个条目、最后一个声明生效,因此该 overlay 会替换 Web Bundle 的条目,而不是给它们设值。
+
+**把这三行以 `disabled: true` 留在 Web 组合中,用按 id 定位的 patch 打开。** 这样 bundle 只是给已有的行设值,但插件管理器只列出 bundle 插入的行,因此该 bundle 的页面显示不含任何组件,需要为被覆盖的行另建一条列出路径;而其他可选 bundle 都插入各自的行。某个 profile 若也声明了其中某个 id,处理方式与任何其他 bundle 相同:Loader 保留最后一份声明。
+
+**把这三行锁定在 bundle 开关上。** 让插件管理器拒绝逐行开关的 bundle 清单字段,会给插件管理器增加一个只有这个 bundle 使用的概念。该 bundle 的页面像其他所有 bundle 一样为每行提供开关,由 bundle README 说明这三行只能一起工作。
+
+## Consequences
+
+- 默认的 `dsh web` 会话不带 Schedule 工具 schema、Session 提醒目录、自动化任务页面与逐步时钟读数。需要它们的安装可从插件管理页启用该可选 bundle,或把该包列进 profile 的 `dsh.profile.bundles`。
+- 启用后的安装会给每个活跃根 Agent 增加四个工具 schema,并在每个符合条件的步骤追加一条持久时钟消息;该读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。
+- 插件管理页在该 bundle 下列出这三行,显示各自包的 `locale/*.json` 声明的标题、它们的状态,并在 bundle 打开时为每行提供开关;关掉其中一行会让 Schedule 缺少这一部分。
+- 未选中该 bundle 时,按 id 定位这三行之一的 profile 补丁或 `--patch` overlay 匹配不到任何条目,加载器会为它报告 `patch: entry not found` 警告;选中该 bundle 即可取代按 id 打开这些行。
+- 该开关只涉及配置:`src/index.ts` 是空模块,运行时内容由 patch 承载,包本身不拥有可变的运行时状态,因此不发布不变量伴随模块。
diff --git a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml
deleted file mode 100644
index fec9a85467..0000000000
--- a/.agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.i18n.yaml
+++ /dev/null
@@ -1,22 +0,0 @@
-# Bilingual-pair consistency record for 2026-09-24-web-default-schedule-composition.md (docs/i18n/README.md): per heading
-# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
-# After editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-web-default-schedule-composition.md
-/agent-note-schedule-in-the-shipped-web-composition:
- en: 39229d7ab6ed433b
- zh: 54287471d371a11c
-/agent-note-schedule-in-the-shipped-web-composition/problem:
- en: 2871fd5ed881c007
- zh: d03c78d402d186da
-/agent-note-schedule-in-the-shipped-web-composition/decision:
- en: bb19ad12b2ab0ffc
- zh: 971f33e95474a36c
-/agent-note-schedule-in-the-shipped-web-composition/recorded-sessions:
- en: 5539a42052f4ddb7
- zh: 98cc190ae63b4549
-/agent-note-schedule-in-the-shipped-web-composition/alternatives-considered:
- en: ca032a407c2ee8cb
- zh: 748a4fcf2485a3da
-/agent-note-schedule-in-the-shipped-web-composition/consequences:
- en: 4f23bda8fda37987
- zh: 9f7c4a3538bd2979
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml
new file mode 100644
index 0000000000..15f69188be
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-25-session-log-otel-byte-limits.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
+/agent-note-session-log-otlp-strings-and-bounded-requests:
+ en: 041f7e2b55202ea3
+ zh: e01834ae6c9fc7bc
+/agent-note-session-log-otlp-strings-and-bounded-requests/problem:
+ en: 20f659a15fb55352
+ zh: 49d39c95da1b8aa5
+/agent-note-session-log-otlp-strings-and-bounded-requests/decision:
+ en: 0f0866232326658b
+ zh: aa608d17ba712d8a
+/agent-note-session-log-otlp-strings-and-bounded-requests/alternatives-considered:
+ en: 0dc933b18a1a3573
+ zh: 77655740322c4e45
+/agent-note-session-log-otlp-strings-and-bounded-requests/consequences:
+ en: 3859569c9cdea30c
+ zh: 517449b8c49807f6
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
new file mode 100644
index 0000000000..57fbe72dc5
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.md
@@ -0,0 +1,29 @@
+# Agent Note: Session-log OTLP strings and bounded requests
+
+Status: implemented
+
+English | [中文](2026-09-25-session-log-otel-byte-limits.zh.md)
+
+## Problem
+
+Session logs contain nested JSON and large tool results. The product collector accepts named records with string content and limits request size; count-only SDK batching cannot bound bytes after UTF-8 encoding and JSON escaping.
+
+## Decision
+
+The shared `otel` Cordis plugin owns ordinary-event and Session-log transport implementations. Business adapters inject it and create independent caller-owned channels; their existing UI/RPC entry points retain authorization, redaction, identity, configuration, and bounded shutdown. Mounting the shared service creates no unused queues. Each canonical event becomes `eventName: "session-log"`, with `sessionId` and JSON-stringified event values in `content`; serialization does not preserve JSONL key ordering. Redaction runs before serialization and the copied envelope excludes the payload.
+
+Session logs use a separate provider and byte/count queue. Each record is measured once with the SDK serializer including a full envelope; the sum conservatively bounds a combined request. Greedy packing avoids full-candidate serialization and recursive re-encoding. Single oversized records receive one diagnostic and do not block later events. One HTTP request owns the transport slot until settlement, even after its watchdog fires; shutdown expiry stops the remaining queue. Scope name/version and explicit transport options remain owned by the backend.
+
+The [telemetry revival](../feature/2026-07-23-session-telemetry-otel-revival.md) remains authoritative for redaction, capture, and best-effort handoff. This decision replaces its SDK-only queue/batching rule: SDK batch deadlines cannot own an operation split into multiple HTTP requests. The shared Session-log channel schedules complete requests; the SDK still owns transport/retry. Feedback authorization and the separate DeepSeek model-request contribution remain unchanged.
+
+## Alternatives considered
+
+**One string for the entire Session.** Rejected because one large Session would exceed the request ceiling even when each event fits. One event per record preserves event identity and permits batching.
+
+**Truncate oversized records or add client-only fragments.** Rejected because truncation loses content and fragments require a collector reassembly protocol. Explicit rejection retains the one-event-per-record meaning.
+
+**Count compressed bytes or characters.** Rejected because collector limits can apply after decompression, and characters omit UTF-8 and escaping overhead. Measuring full uncompressed requests gives a conservative bound.
+
+## Consequences
+
+Product and Session records never share a request or queue, and neither package imports the other. Conservative per-record envelope accounting can produce smaller batches than exact combined accounting. Queue overflow, oversize rejection, exit, and transport failure can lose data; no durable outbox or acknowledgement is added. Tests cover byte limits, linear measurement, slow-request serialization, metadata, redaction-copy isolation, and feedback-authorized replay.
diff --git a/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md
new file mode 100644
index 0000000000..f6e8306978
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-25-session-log-otel-byte-limits.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: Session 日志的 OTLP 字符串与有界请求
+
+Status: implemented
+
+[English](2026-09-25-session-log-otel-byte-limits.md) | 中文
+
+## 问题
+
+Session 日志包含嵌套 JSON 和大型工具结果。产品采集端接收带名称和字符串内容的记录,并限制请求大小;仅按数量分批的 SDK 无法约束 UTF-8 编码与 JSON 转义后的字节数。
+
+## 决策
+
+共享 `otel` Cordis 插件负责普通事件和 Session 日志的传输实现。业务适配器注入它并创建独立、由调用方持有的通道;现有 UI/RPC 入口保留授权、脱敏、身份、配置和有期限的关闭。只挂载共享服务不会创建无用队列。每条规范事件转换为 `eventName: "session-log"`,带有 `sessionId` 和在 `content` 中 JSON 字符串化的事件值;序列化不保留 JSONL 键顺序。脱敏先于序列化,复制的 envelope 不包含 payload。
+
+Session 日志使用独立 provider 和字节/条数队列。每条记录使用 SDK 序列化器计量一次,包含完整 envelope;其总和保守约束合并请求大小。顺序组包避免候选批次的完整序列化和递归重复编码。单条超限记录只产生一次诊断,不阻塞后续事件。每个 HTTP 请求在结束前始终占有传输槽位,即使其监测期限已经触发;关闭期限到期后停止剩余队列。scope 名称/版本和显式传输选项仍由后端拥有。
+
+[遥测恢复决策](../feature/2026-07-23-session-telemetry-otel-revival.zh.md) 继续约束脱敏、捕获和尽力交接。本决策替代其仅使用 SDK 队列/聚合的规则:SDK 批次期限无法拥有拆成多个 HTTP 请求的操作。共享 Session 日志通道调度完整请求,SDK 仍负责传输和重试。反馈授权和独立的 DeepSeek 模型请求附加路径不变。
+
+## 考虑过的替代方案
+
+**整个 Session 合为一个字符串。** 拒绝,因为即使每条事件都能放入请求,大型 Session 仍会超过请求上限。每条事件对应一条记录,既保留事件身份又允许分批。
+
+**截断超限记录或仅在客户端分片。** 拒绝,因为截断会丢失内容,而分片需要采集端重组协议。显式拒绝保留一条事件对应一条记录的含义。
+
+**计算压缩字节数或字符数。** 拒绝,因为采集端限制可能作用于解压后内容,而字符数不包含 UTF-8 和转义开销。计量完整未压缩请求提供保守上限。
+
+## 影响
+
+产品事件和 Session 记录不共享请求或队列,两个包也不相互导入。保守的逐记录 envelope 计量可能比精确合并计量产生更小批次。队列溢出、超限拒绝、退出和传输失败可能丢失数据;不新增持久 outbox 或确认机制。测试覆盖字节上限、线性计量、慢请求串行调度、元数据、脱敏复制隔离和反馈授权回放。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml
new file mode 100644
index 0000000000..9a2f10699e
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-18-windows-directory-junction-listing.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# 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-18-windows-directory-junction-listing.md
+/agent-note-list-directory-links-in-the-workspace-file-tree:
+ en: 70fbeeae3fa4a6e6
+ zh: 6ed08f09c9d1d02e
+/agent-note-list-directory-links-in-the-workspace-file-tree/problem:
+ en: eec5c2e8edd0e0ff
+ zh: 03c0f3490bb891c0
+/agent-note-list-directory-links-in-the-workspace-file-tree/decision:
+ en: ad36ceb185d15d69
+ zh: 71847eaa4641a9d7
+/agent-note-list-directory-links-in-the-workspace-file-tree/alternatives-considered:
+ en: 8a9daf7bd2d78b65
+ zh: 69f46ee1108c2747
+/agent-note-list-directory-links-in-the-workspace-file-tree/consequences:
+ en: 3cbc15d04a639292
+ zh: 4a9c94a1a1e8aeca
diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md
new file mode 100644
index 0000000000..8e0edc0b99
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.md
@@ -0,0 +1,27 @@
+# Agent Note: List directory links in the workspace file tree
+
+Status: implemented
+
+English | [中文](2026-09-18-windows-directory-junction-listing.zh.md)
+
+## Problem
+
+Windows profile directories such as `Documents\My Music` are directory junctions: reparse points that `lstat` reports as symlinks rather than directories. The `list` endpoint gated on `lstat` before resolving, so it refused every final link with `workspace-file/not-directory`, while its own listing reported those same children as `directory` because `listDir` resolves each child. The file tree therefore listed the junctions and refused to open them, and the Sidebar reported `My Music`, `My Pictures`, and `My Videos` as not a directory.
+
+## Decision
+
+`list` follows a final link and judges what it resolves to. The resolved target must pass workspace containment and must stat as a directory, so a directory link inside the workspace lists through its target, and the returned workspace path names that target. A link resolving outside the workspace is `workspace-file/outside-workspace`; a link to a non-directory, or one whose target is gone, is `workspace-file/not-directory` with kind `symlink`.
+
+`read`, `readBytes`, `readAll`, `readRelated`, and `stat` keep their `lstat` gate that refuses a final symlink, so no read path follows a link. The [Workspace Files service](../architecture/2026-09-05-workspace-files-service.md) keeps ownership of the service; its earlier rule that `list` rejects a final symlink is superseded here.
+
+## Alternatives considered
+
+**Drop the `lstat` gate for every method.** Rejected: reading through a link is a separate exposure decision, and the file methods' no-follow rule is a trust rule the report asked to keep.
+
+**Special-case Windows junctions.** Rejected: `fs-local` already reports a junction as a `symlink` path entry, and the resolve-then-contain rule is identical for a junction and a POSIX directory symlink.
+
+**Keep the gate and report a linked directory from the parent listing only.** Rejected: the listing already reports the resolved type, so the endpoint contradicted itself and a reader could not open what the parent presented as a directory.
+
+## Consequences
+
+A directory link inside the workspace now lists like its target, and the Windows profile junctions expand in the Sidebar. A link resolving outside the workspace reports `outside-workspace` instead of `not-directory`, which names the actual reason. Read access is unchanged. Tests cover a directory link inside the workspace, a link resolving outside it, and a link whose target is deleted; the spec creates a junction with `symlink(target, path, 'junction')` on Windows and a directory symlink elsewhere, so one spec runs on both.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md
new file mode 100644
index 0000000000..3236c063d6
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-18-windows-directory-junction-listing.zh.md
@@ -0,0 +1,27 @@
+# Agent Note: 列举工作区文件树中的目录链接
+
+Status: implemented
+
+[English](2026-09-18-windows-directory-junction-listing.md) | 中文
+
+## Problem
+
+Windows 的个人目录如 `Documents\My Music` 是目录联接:`lstat` 把这类重解析点报告为符号链接而不是目录。`list` 端点在解析之前先用 `lstat` 把关,于是把所有末端链接都以 `workspace-file/not-directory` 拒绝;而它自己的列举又把同样的子项报告为 `directory`,因为 `listDir` 会解析每个子项。文件树因此既列出这些联接、又拒绝打开它们,侧栏把 `My Music`、`My Pictures`、`My Videos` 报告为非目录。
+
+## Decision
+
+`list` 跟随末端链接并判定其解析结果。解析后的目标必须通过工作区包含检查,且经 stat 必须为目录,因此工作区内的目录链接经其目标列举,返回的工作区路径也以该目标命名。解析到工作区外的链接为 `workspace-file/outside-workspace`;指向非目录的链接、或目标已消失的链接,为 kind 为 `symlink` 的 `workspace-file/not-directory`。
+
+`read`、`readBytes`、`readAll`、`readRelated` 与 `stat` 保留拒绝末端符号链接的 `lstat` 把关,因此没有任何读取路径跟随链接。[工作区文件服务](../architecture/2026-09-05-workspace-files-service.zh.md)继续拥有该服务;它此前「`list` 拒绝末端符号链接」的规则由本记录取代。
+
+## Alternatives considered
+
+**对所有方法去掉 `lstat` 把关。** 被否:经链接读取是另一项暴露决策,文件方法的「不跟随」规则是报告方要求保留的信任规则。
+
+**单独处理 Windows 目录联接。** 被否:`fs-local` 已把目录联接报告为 `symlink` 路径项,且「先解析再判包含」的规则对目录联接与 POSIX 目录符号链接完全相同。
+
+**保留把关,只靠父层列举报告被链接的目录。** 被否:列举本来就报告解析后的类型,端点因此自相矛盾,读取方无法打开父层呈现为目录的条目。
+
+## Consequences
+
+工作区内的目录链接现在与其目标一样可列举,Windows 个人目录中的联接可在侧栏展开。解析到工作区外的链接报告 `outside-workspace` 而不是 `not-directory`,如实说明原因。读取权限不变。测试覆盖工作区内的目录链接、解析到工作区外的链接,以及目标被删除的链接;spec 在 Windows 上用 `symlink(target, path, 'junction')` 创建目录联接,在其他平台创建目录符号链接,因此同一 spec 两端都跑。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml
new file mode 100644
index 0000000000..727bb883f4
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-19-failed-step-tool-results.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# 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-19-failed-step-tool-results.md
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step:
+ en: 8fd2a50a041b1b18
+ zh: 01aed07a6425a322
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/problem:
+ en: 1fa7525f83af9035
+ zh: 401ce968e3dd5434
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/decision:
+ en: 3d58367a05183b4a
+ zh: f9ccc3a7011a6240
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/alternatives-considered:
+ en: 37da4d9c8533b736
+ zh: e52a59763ae2b62e
+/agent-note-record-unanswered-tool-results-before-closing-a-failed-step/consequences:
+ en: 1ea72421a98551d8
+ zh: c01e02ddac8a3454
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md
new file mode 100644
index 0000000000..80c813598e
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.md
@@ -0,0 +1,33 @@
+# Agent Note: Record unanswered tool results before closing a failed step
+
+Status: implemented
+
+English | [中文](2026-09-19-failed-step-tool-results.zh.md)
+
+## Problem
+
+A committed assistant message can request tools whose scheduler fails before all results are recorded. Closing the step and turn with those calls unanswered makes later model requests invalid for providers that require paired tool calls and results. An error turn is already closed, so crash-tail repair cannot recover it on resume.
+
+## Decision
+
+The AgentLoop driver records recovery results for unanswered calls before it closes a failed step, then propagates the original failure. Live step recovery and `interruptedTurnClosers` share incremental pending-call tracking and result construction; the live driver still owns its step and turn events. A step-local observer retains pending identities from committed events without copying session history. The scheduler stops replenishment and drains started dispatches before rejecting, so result recovery cannot race a live dispatch.
+
+Recovery preserves committed results and follows the assistant's call order. A recorded `tool/call` without a result receives `TOOL_OUTCOME_UNKNOWN`; its text warns against blindly retrying an operation with possible side effects. An assistant request with no call record receives `TOOL_NOT_STARTED`. A call record precedes preparation and does not establish that the tool body ran, so unknown outcomes remain conservative.
+
+This decision supersedes only the missing-result policy in [parallel tool-call execution](../feature/2026-07-10-parallel-tool-call-execution.md). Its classification, concurrency limit, ordering, and quiescence rules remain applicable.
+
+## Alternatives considered
+
+**Leave calls unanswered to avoid inventing tool outcomes.** An unanswered call invalidates later provider requests. An explicit unknown-outcome error records the limit of the available evidence without claiming success, cancellation, or absence of side effects.
+
+**Repair only when resuming or forking.** The ordinary failure path already records `step/end` and `turn/end`; open-tail repair intentionally preserves closed history. Recovery belongs before those records, while the driver owns the failed step.
+
+**Repair inside the tool scheduler alone.** Failure can occur after the assistant message is recorded but before scheduling starts. The step driver covers that interval and reuses the existing session repair semantics rather than adding a second implementation of call/result matching.
+
+**Retry automatically.** A dispatch can have side effects even when its result is missing. The error result supplies retry guidance, while the turn retains the original failure and waits for later work.
+
+## Consequences
+
+Later requests, resume, and fork retain a usable call/result history after a live step failure. Recovery appends results; it does not remove assistant messages, replace completed results, execute tools, or convert the failed turn into success. The log uses existing event types and recovery codes, with no format change.
+
+Already-closed inconsistent historical turns remain unchanged. The live recovery path requires the session to accept the missing result records; it cannot promise recovery from a failed log append.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md
new file mode 100644
index 0000000000..7d69b46a9a
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-19-failed-step-tool-results.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 在关闭失败步骤前补齐工具结果
+
+Status: implemented
+
+[English](2026-09-19-failed-step-tool-results.md) | 中文
+
+## 问题
+
+已提交的 assistant 消息可能请求多个工具,而调度器在全部结果记录完成之前失败。如果关闭步骤和轮次时仍有调用尚无结果,后续模型请求就不满足要求工具调用与结果配对的提供方。错误轮次已经关闭,因此恢复会话时的崩溃尾部修复无法恢复它。
+
+## 决策
+
+AgentLoop 驱动器在关闭失败步骤之前,为尚无结果的调用记录恢复结果,随后传播原始失败。实时步骤恢复和 `interruptedTurnClosers` 共享增量的待处理调用跟踪与结果构造;实时驱动器仍拥有自己的步骤和轮次事件。步骤本地观察器从已提交事件中保留待处理调用标识,不复制会话历史。调度器在拒绝之前停止补充池并排空已启动的派发,因此结果恢复不会与运行中的派发竞争。
+
+恢复保留已提交的结果,并遵循 assistant 的调用顺序。已有 `tool/call` 记录但尚无结果的调用获得 `TOOL_OUTCOME_UNKNOWN`;其文本警告不要盲目重试可能存在副作用的操作。没有调用记录的 assistant 请求获得 `TOOL_NOT_STARTED`。调用记录先于准备阶段,不能证明工具主体已经运行,因此未知结果保持保守。
+
+本决策仅取代[并行工具调用执行](../feature/2026-07-10-parallel-tool-call-execution.zh.md)中的缺失结果策略。其中的分类、并发上限、顺序和完全停稳规则继续适用。
+
+## 备选方案
+
+**让调用保持无结果,避免虚构工具执行结果。** 尚无结果的调用会使后续提供方请求无效。显式的未知结果错误记录可用证据的局限,而不声称成功、取消或没有副作用。
+
+**仅在恢复或 fork 时修复。** 普通失败路径已经记录 `step/end` 和 `turn/end`;开放尾部修复有意保留已关闭历史。恢复应在这些记录之前执行,此时驱动器仍拥有失败步骤。
+
+**仅在工具调度器内部修复。** 失败可能发生在 assistant 消息记录之后、调度开始之前。步骤驱动器覆盖这段区间,并复用已有的会话修复语义,避免另行实现调用/结果匹配。
+
+**自动重试。** 即使结果缺失,派发也可能产生副作用。错误结果提供重试指引,而轮次保留原始失败并等待后续工作。
+
+## 影响
+
+实时步骤失败后,后续请求、恢复和 fork 会保留可用的调用/结果历史。恢复追加结果;它不移除 assistant 消息、不替换已完成结果、不执行工具,也不将失败轮次改为成功。日志使用已有事件类型和恢复代码,无需格式变更。
+
+此前已关闭的不一致历史轮次保持不变。实时恢复路径要求会话能够接纳缺失结果记录;它不能保证从日志追加失败中恢复。
diff --git a/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.i18n.yaml
new file mode 100644
index 0000000000..ad4ccdf4ab
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-24-exited-holder-lock-takeover.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# 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-24-exited-holder-lock-takeover.md
+/agent-note-taking-over-a-writer-lock-whose-holder-exited:
+ en: ecf6fb35c9eed3ca
+ zh: 59c29b64e23608e4
+/agent-note-taking-over-a-writer-lock-whose-holder-exited/problem:
+ en: cf143bc56530fe17
+ zh: a4e3ee8fa3b8c465
+/agent-note-taking-over-a-writer-lock-whose-holder-exited/decision:
+ en: af22bae72ff97ef8
+ zh: 428c09871bba95d1
+/agent-note-taking-over-a-writer-lock-whose-holder-exited/alternatives-considered:
+ en: ee800ed269cfc4a2
+ zh: df983b552ef25273
+/agent-note-taking-over-a-writer-lock-whose-holder-exited/testing:
+ en: 7eb7c5818049415a
+ zh: f84c39c55bb3bc5a
+/agent-note-taking-over-a-writer-lock-whose-holder-exited/consequences:
+ en: 03f4cda9290188e5
+ zh: 07f800776c061218
diff --git a/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.md b/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.md
new file mode 100644
index 0000000000..dd42c5cae3
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.md
@@ -0,0 +1,51 @@
+# Agent Note: Taking over a writer lock whose holder exited
+
+Status: implemented
+
+English | [中文](2026-09-24-exited-holder-lock-takeover.zh.md)
+
+## Problem
+
+`withFileLock` in [dsh-atomic-write](../../../../packages/util/atomic-write/README.md) creates `.lock` with exclusive create and removes it in a `finally`. A process that ends without running that `finally` leaves the lock behind, and every later writer of the file times out until someone deletes it by hand. The [original decision](../../archived/architecture/2026-07-30-settings-write-path-integrity.md) accepted this because file age cannot distinguish a crashed holder from a paused one.
+
+The profile package lock (`/package.json.lock`) made the cost visible. `dsh plugin` installs no signal handler, so Ctrl-C, SIGTERM, or closing its terminal ends the process while pnpm runs, and the lock stays. `dsh` and `dsh web` exit right after application disposal resolves, while a cancelled installation is still restoring files and has not released the lock. A crash, SIGKILL, or out-of-memory kill has the same effect. After any of these, every plugin operation on that profile fails after `lockWaitMs` (two minutes by default) without saying that the holder is gone.
+
+## Decision
+
+- The lock keeps the `\n` record earlier releases wrote, so locks left before this change are taken over and processes of both releases interoperate.
+- A contender that finds a lock reads its record. When a signal probe of the PID fails with `ESRCH`, the holder is proven gone and the lock is taken over. `EPERM` means the process exists under another user and the lock is kept. A record naming the contender's own process is kept too: that process is running, and the browser Worker's process shim reports its own PID as absent.
+- Contenders that read the same record serialize on a claim file, `.lock.takeover-`, created with `wx`. The claimant re-reads the lock and probes its PID again, removes it only if it still holds that record and the PID is still absent, removes the claim, and retries acquisition at once. The record's holder cannot release it once its PID is absent, another takeover needs the same claim, and a new holder that reused the PID fails the second probe, so a claimant never removes a lock that another contender acquired after the exited holder's.
+- A record that is empty, incomplete, not a decimal PID, or names PID 0 or a PID beyond the int32 range the probe accepts is waited for. None of these proves that the holder stopped.
+- Takeover proves only that the recorded process exited, not that the processes it started stopped writing. The [Plugin Manager](../../../../packages/boot/plugin-manager/README.md) records each pnpm run in `.plugin-manager/run.json` while the run lasts; an operation that finds that record waits up to five seconds for the recorded run to stop and otherwise refuses to run, naming the process and the record.
+- The probe runs on the contender's host. Writers on several hosts sharing one `DSH_HOME` over a network filesystem are unsupported; the `flock` that guards Session files does not reliably exclude them either.
+
+## Alternatives considered
+
+**Remove a lock older than a fixed age.** Age cannot separate a crashed holder from one running a ten-minute pnpm installation, and the plugin manager legitimately holds the lock that long.
+
+**Kernel-released locks.** The kernel releases them when the holder dies, which removes the problem instead of detecting it. The [Session write lease](../../../../packages/session/session-persistence-jsonl/src/lease.ts) already holds one on both platforms: `flock` through `@deepseek-ai/node-addon-system` on POSIX and a named semaphore through `koffi` on Windows. Moving `withFileLock` onto it would give the zero-dependency `dsh-atomic-write` both dependencies, and processes of earlier releases still exclude each other only through `.lock`, so a transition would have to hold both locks. This stays the stronger fix if PID reuse or shared-filesystem deployments turn out to matter.
+
+**Rename the stale lock aside and restore it when the renamed record differs.** Restoring can race a third contender that acquires the empty path in between, which leaves two holders. The claim file prevents that race without restoring anything.
+
+**Record the hostname and a nonce.** A hostname would keep a writer on another host from probing a PID that is not its own, but no supported deployment shares these files across hosts, and macOS changes its hostname when networks change, which would leave locks from before the change in place. A nonce would make each record unique, but the second probe under the claim already rejects a holder that reused the PID.
+
+**Terminate the recorded pnpm run instead of refusing.** Its PID can have been reused by an unrelated process, which a signal would then kill. Refusing leaves that case to the operator, whom the diagnostic names the process and the record.
+
+**Signal handlers in `dsh plugin` only.** This covers one of the paths that leave a lock and leaves crashes, forced exits, and the other lock files behind.
+
+## Testing
+
+| Evidence | Behaviour |
+|---|---|
+| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | Takeover of an exited holder's record, through an injected probe everywhere and a really exited process on POSIX; eight contenders over one exited holder never overlap; live, own-process, other-user, empty, incomplete, non-PID, process-group, and out-of-range records are waited for; a holder that reused the PID after the claim, an unreadable lock, a claim held by another contender, and a record replaced after the claim are left in place; a lock that cannot be removed is waited for; an `EPERM` claim refusal is retried, another claim failure surfaces, and a claim that cannot be removed does not fail the operation. |
+| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | CLI, service, and repair runs are recorded while they run and the record is removed afterwards; an active recorded run refuses the operation before pnpm starts; a stopped one's record is removed; a record that names no run refuses the operation. |
+| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | A real recorded run that is still writing finishes before the next operation's pnpm starts. |
+
+## Consequences
+
+- Locks left by an exited process, including those written before this change, no longer need an operator; the next writer proceeds without waiting for its deadline.
+- A PID that a live process reused after a reboot keeps the lock in place until an operator removes it, as before.
+- Processes that share the lock file but not a PID namespace, such as containers over one volume or hosts over a network filesystem, can see a live holder as exited. Such deployments are not supported.
+- A contender that crashes while it holds a claim leaves that claim, and the named lock is then not taken over automatically.
+- A pnpm run left by an exited operation blocks later operations until it stops or an operator removes its record.
+- A holder that is still alive keeps its lock, so this does not bound how long a live operation holds it.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.zh.md b/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.zh.md
new file mode 100644
index 0000000000..59162daa65
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-24-exited-holder-lock-takeover.zh.md
@@ -0,0 +1,51 @@
+# Agent Note: 接管持有者已退出的写锁
+
+Status: implemented
+
+[English](2026-09-24-exited-holder-lock-takeover.md) | 中文
+
+## 问题
+
+[dsh-atomic-write](../../../../packages/util/atomic-write/README.zh.md) 的 `withFileLock` 以独占创建方式创建 `.lock`,并在 `finally` 中移除它。进程若未执行该 `finally` 就结束,锁会留在原地,该文件后续的每个写入方都会超时,直到有人手动删除它。[原始决策](../../archived/architecture/2026-07-30-settings-write-path-integrity.md)接受了这一点,因为文件存续时间无法区分已崩溃的持有者与暂停的持有者。
+
+profile 包锁(`/package.json.lock`)让这个代价显现出来。`dsh plugin` 不安装信号处理器,因此在 pnpm 运行期间按 Ctrl-C、发送 SIGTERM 或关闭其终端都会结束进程,锁随之留下。`dsh` 与 `dsh web` 在应用销毁完成后立即退出,而此时被取消的安装仍在恢复文件,尚未释放锁。崩溃、SIGKILL 或内存不足被杀也有同样效果。发生上述任一情况后,该 profile 上的每个插件操作都会在 `lockWaitMs`(默认两分钟)后失败,且不说明持有者已不存在。
+
+## 决策
+
+- 锁沿用早期版本写入的 `\n` 记录,因此本变更之前遗留的锁也会被接管,新旧版本的进程可以互操作。
+- 竞争者发现锁时读取其记录。若对该 PID 的信号探测以 `ESRCH` 失败,则证明持有者已不存在,锁会被接管。`EPERM` 表示该进程以其他用户身份存在,锁保留。指向竞争者自身进程的记录同样保留:该进程正在运行,而浏览器 Worker 的进程模拟层会把自身 PID 报告为不存在。
+- 读到同一条记录的竞争者通过一个以 `wx` 创建的认领文件 `.lock.takeover-<记录 SHA-256 的前 16 位十六进制>` 串行化。认领者重新读取锁并再次探测其 PID,仅当锁仍是该记录且该 PID 仍不存在时才移除它,随后移除认领文件并立即重试获取。PID 不存在时记录的持有者无法再释放它,另一次接管需要同一个认领文件,复用了该 PID 的新持有者无法通过第二次探测,因此认领者绝不会移除另一个竞争者在已退出持有者之后获得的锁。
+- 记录为空、不完整、不是十进制 PID,或指向 PID 0 或超出探测所接受的 int32 范围的 PID 时,竞争者继续等待。这些情况都无法证明持有者已经停止。
+- 接管只能证明记录中的进程已退出,不能证明它启动的进程已停止写入。[Plugin Manager](../../../../packages/boot/plugin-manager/README.zh.md) 在每次 pnpm 运行期间把它记录在 `.plugin-manager/run.json` 中;发现该记录的操作最多等待五秒让记录中的运行停止,否则拒绝运行,并指明该进程与记录文件。
+- 探测在竞争者所在主机上进行。不支持多台主机通过网络文件系统共享同一个 `DSH_HOME` 写入;保护 Session 文件的 `flock` 同样无法可靠地排除它们。
+
+## 考虑过的替代方案
+
+**移除超过固定时长的锁。** 时长无法区分已崩溃的持有者与正在运行十分钟 pnpm 安装的持有者,而插件管理器会合理地持锁这么久。
+
+**由内核释放的锁。** 持有者死亡时内核会释放它们,从根源上消除问题而不是检测它。[Session 写租约](../../../../packages/session/session-persistence-jsonl/src/lease.ts)已在两个平台上持有这种锁:POSIX 上通过 `@deepseek-ai/node-addon-system` 使用 `flock`,Windows 上通过 `koffi` 使用命名信号量。把 `withFileLock` 迁移到它上面,会让零依赖的 `dsh-atomic-write` 引入这两个依赖;而早期版本的进程之间只通过 `.lock` 互斥,因此过渡期必须同时持有两种锁。若 PID 复用或共享文件系统部署变得重要,这仍是更强的修复。
+
+**把陈旧锁重命名到别处,若重命名后的记录不同再恢复。** 恢复可能与在此间隙获取空路径的第三个竞争者竞态,导致出现两个持有者。认领文件无需任何恢复即可防止这一竞态。
+
+**记录主机名和随机数。** 主机名能让其他主机上的写入方不去探测不属于自己的 PID,但没有受支持的部署会跨主机共享这些文件,而且 macOS 切换网络时会改变主机名,导致变更前留下的锁无法接管。随机数能让每条记录唯一,但认领后的第二次探测已经能排除复用了该 PID 的持有者。
+
+**终止记录中的 pnpm 运行而不是拒绝。** 其 PID 可能已被无关进程复用,发送信号会杀死该进程。拒绝则把这种情况交给操作者,诊断会指明该进程与记录文件。
+
+**只在 `dsh plugin` 中安装信号处理器。** 这只覆盖留下锁的其中一条路径,崩溃、强制退出和其他锁文件仍会遗留。
+
+## 验证
+
+| 证据 | 行为 |
+|---|---|
+| [atomic-write.spec.ts](../../../../packages/util/atomic-write/tests/atomic-write.spec.ts) | 接管已退出持有者的记录,各平台通过注入的探测验证,POSIX 上另用真实已退出的进程验证;八个竞争者同时接管一个已退出持有者时从不重叠;存活、自身进程、其他用户、空、不完整、非 PID、进程组和超出范围的记录均继续等待;认领后复用了该 PID 的持有者、无法读取的锁、被另一竞争者持有的认领、认领后被替换的记录都保留原样;无法移除的锁会继续等待;以 `EPERM` 拒绝的认领会重试,其他认领失败会抛出,认领无法移除时操作不失败。 |
+| [operations.spec.ts](../../../../packages/boot/plugin-manager/tests/operations.spec.ts) | CLI、service 与修复运行在运行期间被记录,结束后删除记录;仍活动的记录中运行会在 pnpm 启动前拒绝操作;已停止运行的记录被删除;不指向任何运行的记录会拒绝操作。 |
+| [operations-process.spec.ts](../../../../packages/boot/plugin-manager/tests/operations-process.spec.ts) | 仍在写入的真实记录中运行会在下一个操作的 pnpm 启动前结束。 |
+
+## 后果
+
+- 已退出进程留下的锁(包括本变更之前留下的)不再需要操作者处理;下一个写入方无需等到期限即可继续。
+- 重启后被存活进程复用的 PID 会让锁保持原样,直到操作者移除,与之前相同。
+- 共享锁文件但不共享 PID 命名空间的进程,例如共享同一卷的容器或通过网络文件系统共享的多台主机,可能把存活持有者视为已退出。不支持此类部署。
+- 竞争者在持有认领文件时崩溃会留下该认领文件,对应的锁随后不会被自动接管。
+- 已退出操作留下的 pnpm 运行会阻止后续操作,直到它停止或操作者删除其记录。
+- 仍存活的持有者会保留其锁,因此本变更不限制存活操作的持锁时长。
diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
index ea0738686e..e29631633c 100644
--- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml
@@ -30,8 +30,8 @@
en: 7bc0fab85c7c91d0
zh: 3eafcde6b1e3249c
/agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/the-bash-consumer:
- en: af561a23104227cd
- zh: ac6fd59518d1bb55
+ en: f7f359c89a0e8fd6
+ zh: 07d0d551a35716e0
/agent-note-the-subprocess-sandbox-confinement-seam-native-runners-escalation-and-per-session-modes/decision/design-detail/escalation-one-approved-wider-retry-after-a-denial:
en: 035640e276225e97
zh: 09fd95921529369b
diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md
index f2c703ba67..1f8ca60c83 100644
--- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md
+++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md
@@ -78,7 +78,7 @@ Provider-owned I/O requires asynchronous confinement. `confine(argv, policy, sig
`dsh-bash-sandbox` extends `LocalBashExecutor`, awaits `ctx.sandbox` for the exact `['bash', '-c', command]` argv with the execution signal, rechecks cancellation, and directly spawns the provider result. This leaves shell semantics and `BASH_ENV` on the inner Bash after the shipped native runner establishes confinement. A provider error propagates unchanged. An asynchronous rejection counts as a runner failure only when the caller-owned workdir is independently usable and Node reports `ENOENT` or `EACCES` with either an `error.path` equal to provider argv[0] or, when `error.path` is absent, an exact `syscall: 'spawn '`; a present path also requires `syscall: 'spawn'` or the exact `spawn `. Other codes, invalid workdirs, resource failures, unrelated syscalls, and unstructured rejections retain stage-neutral provider-failure semantics. Foreground execution converts attributable runner failures to `SANDBOX_UNAVAILABLE` with the original detail; an attributable asynchronous background rejection stamps `runnerFailed: true`, `denied: false`. A `SubprocessRuntime` that synchronously throws the same runner-identifying shape makes background start reject with `SANDBOX_UNAVAILABLE` before handle publication, while other synchronous errors propagate unchanged. After a direct outcome exists, foreground and background use one runner-failure classifier that requires the rule's exit-code check and a remaining fatal line after informational exclusions. A match takes priority over denial: foreground execution throws `SANDBOX_UNAVAILABLE` with that fatal line as detail; a settled `ShellProcess` stamps `sandbox.runnerFailed`, and the bash producer renders it through generic `job_output`.
-The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`), encourages attempting commands that may be denied, and forbids retrying around a denial; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
+The model sees the current effective file policy in the owner-derived `sandbox:policy` context, while the static tool description explains the denial marker (`[sandbox: file access denied under mode]`) and forbids retrying around a denial, and the `sandbox:policy` context tells the model to try an available tool normally; a rejected escalation's error text says the action stays denied; when the escalation fields are advertised, a denied result additionally carries the escalation hint itself, so the sanctioned same-turn retry is prompted at the decision point rather than depending on the model recalling the description (§ Escalation). [The current-policy decision](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) owns the context's rationale and boundaries.
#### Escalation: one approved wider retry after a denial
diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
index ff4d4ed592..707a576e97 100644
--- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md
@@ -78,7 +78,7 @@ Landlock launcher 源码和包家族位于 `native/system`,与 harness 消费
`dsh-bash-sandbox` 扩展 `LocalBashExecutor`,携带执行信号等待 `ctx.sandbox` 为精确的 `['bash', '-c', command]` argv 完成限制准备,重新检查取消后直接 spawn 提供方返回的 argv。这样,随附的原生 runner 建立约束后,shell 语义与 `BASH_ENV` 仍由内层 Bash 处理。提供方错误原样传播。异步 rejection 只有在调用方拥有的 workdir 经独立验证可用,Node 报告 `ENOENT` 或 `EACCES`,并且错误符合以下一种形态时,才判定为 runner 失败:`error.path` 等于提供方返回的 `argv[0]`,同时 `syscall` 为 `'spawn'` 或精确的 `'spawn '`;或者 `error.path` 不存在,同时 `syscall` 为精确的 `'spawn '`。其他错误码、无效 workdir、资源失败、无关 syscall 与无结构 rejection 保留不声明阶段的 provider-failure 语义。前台执行会将可归因的 runner failure 转为 `SANDBOX_UNAVAILABLE` 并附上原始详情;可归因的异步后台 rejection 则盖章 `runnerFailed: true`、`denied: false`。如果 `SubprocessRuntime` 同步抛出同样能指明 runner 的形态,后台启动会在发布句柄前以 `SANDBOX_UNAVAILABLE` 拒绝;其他同步错误原样传播。direct outcome 已存在后,前台与后台共用一个 runner failure 分类器:先排除信息性行,再要求规则的退出码检查与余下的一行致命诊断同时匹配。匹配结果优先于拒绝:前台执行抛出 `SANDBOX_UNAVAILABLE`,并以该致命行作为详情;结算后的 `ShellProcess` 会盖章 `sandbox.runnerFailed`,bash 生产者再通过通用 `job_output` 渲染它。
-模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`),鼓励尝试可能被拒绝的命令,并禁止绕过拒绝重试。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
+模型会在归属方派生的 `sandbox:policy` 上下文中看到当前有效的文件策略;静态工具描述则解释拒绝标记(`[sandbox: file access denied under mode]`)并禁止绕过拒绝重试,`sandbox:policy` 上下文要求模型正常尝试可用工具;被拒绝的升级请求的错误文本说明该操作仍被拒绝。当升级字段被公布时,被拒绝的结果还会携带升级提示本身,使被认可的同轮次重试在决策点获得提示,而非依赖模型回忆描述(§ 升级机制)。[当前策略决策](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)负责该上下文的理由与边界。
#### 升级机制:拒绝后一次经批准的更宽重试
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
index 4b82a34a31..5867da4d43 100644
--- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml
@@ -12,8 +12,8 @@
en: 532bb7268d9b93ce
zh: ad47162233222ab0
/agent-note-parallel-tool-call-execution-by-per-call-safety/scheduling-and-ordering:
- en: f0906d03fef98e7b
- zh: f9e43c4c832c9a05
+ en: a485c530ea4366de
+ zh: 6f9915d726de9ac7
/agent-note-parallel-tool-call-execution-by-per-call-safety/safety-contract:
en: 16c4ee08b65ead23
zh: df80d6e49982615b
@@ -24,8 +24,8 @@
en: eb5a6aa84980f0ab
zh: eed2680a160e8ed2
/agent-note-parallel-tool-call-execution-by-per-call-safety/alternatives-considered:
- en: 44f8fb5851ba4e39
- zh: 4d75c937ac5277f7
+ en: 17becc038bb7df7c
+ zh: 673d9a9bdfa39edf
/agent-note-parallel-tool-call-execution-by-per-call-safety/consequences:
- en: 5e5b2a1c7d850f83
- zh: b63311f28eb69366
+ en: a5508057bc03f4f6
+ zh: 96b46c59402474c3
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
index d5d26e03a2..004d725bfd 100644
--- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md
@@ -46,7 +46,7 @@ Only dispatch and the tool body overlap. `tools/pre-execute` and `tools/post-exe
Each started call appends `tool/call` immediately before its pre-execute gate. Completed dispatches occupy model-order slots, and a commit cursor appends `tool/result` and collects `additionalContexts` only when the next slot is ready. Live surfaces may show several pending calls, but results and post-tool context remain model-ordered.
-An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls.
+An abort stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and records synthetic aborted call/result pairs for undispatched calls. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. The driver records missing results before closing the failed step under the [failed-step recovery decision](../bug-fix/2026-09-19-failed-step-tool-results.md).
PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler.
@@ -84,7 +84,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
**Expose staged methods or a scheduling waterfall.** Public `prepare` / `dispatch` / `finalize` methods or a `tools/execution-mode` event add extension surface before another consumer needs it. The loop uses an internal scheduler view, while `executionMode(exec)` leaves an insertion point for a policy hook.
-**Convert scheduler failures into tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures therefore remain terminal instead of being reclassified as `ABORTED` results.
+**Convert scheduler failures into ordinary tool results.** AgentLoop cannot determine whether a rejected dispatch invoked the tool body; ToolRuntime owns body-invocation state and typed tool outcomes. Internal scheduler failures remain terminal instead of being reclassified as `ABORTED` results. Explicit recovery results preserve this uncertainty without leaving unanswered calls.
**Start calls while the model streams.** This may reduce latency further but changes assistant-message authority, replay, and call/result pairing. The scheduler starts only after the assistant message is complete.
@@ -96,7 +96,7 @@ Snapshot coverage pins the visible multi-call transcript: pending calls may over
The design is fail-closed and simple for tool authors, but it cannot exploit concurrency whose safety depends on comparing siblings. A tool that opts in too broadly can expose latent shared-state races.
-Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler therefore records only started calls, drains them on abort, and never starts replacements after cancellation.
+Parallel calls may begin in cases where serial execution would have aborted before reaching them. The scheduler drains them on abort and never starts replacements after cancellation; undispatched calls receive synthetic aborted call/result pairs.
Ordered commits may hold a fast result behind a slow earlier sibling. This preserves replay and model-history order while live surfaces still show pending progress.
@@ -104,4 +104,4 @@ Concurrent external calls can compete for quota or process capacity. Providers o
Tool registration is a scheduling boundary. Registry mutations affect not-yet-started calls because the scheduler reclassifies after each barrier and before every pool replenishment. Already-started calls retain the scheduling decision under which they entered the pool.
-A terminal scheduler failure may leave recorded calls without results before the failed step closes. Waiting for live dispatches preserves quiescence without misreporting those internal failures as tool outcomes.
+A terminal scheduler failure reaches step recovery only after live dispatches settle. Recovery reports uncommitted outcomes as unknown, preserving quiescence and the original failure while keeping later model history usable.
diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
index 707428985c..e78f56e07c 100644
--- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md
@@ -46,7 +46,7 @@ Status: implemented
每个已启动的调用都会在进入 pre-execute 门禁之前立即追加 `tool/call`。已完成的派发占据模型顺序的槽位;提交游标只有在下一个槽位就绪时,才会追加 `tool/result` 并收集 `additionalContexts`。实时界面可以显示多个待处理调用,但结果和工具执行后的上下文仍按模型顺序排列。
-如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。
+中止会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,并为未派发的调用记录合成的中止调用/结果对。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。驱动器按照[失败步骤恢复决策](../bug-fix/2026-09-19-failed-step-tool-results.zh.md),在关闭失败步骤之前记录缺失结果。
PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。
@@ -84,7 +84,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
**公开分阶段方法或调度 waterfall(瀑布式事件)。** 公开的 `prepare` / `dispatch` / `finalize` 方法或 `tools/execution-mode` 事件,会在另一个消费方需要它之前扩大扩展接口。循环使用内部调度器视图,而 `executionMode(exec)` 为策略钩子保留了插入点。
-**将调度器故障转换为工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。因此,内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。
+**将调度器故障转换为普通工具结果。** AgentLoop 无法判断被拒绝的派发是否已调用工具主体;ToolRuntime 负责工具主体调用状态和类型化工具结果。内部调度器故障保持为终态,而不会被重新分类为 `ABORTED` 结果。显式恢复结果保留这种不确定性,同时避免留下尚无结果的调用。
**在模型流式输出时启动调用。** 这可能进一步降低延迟,但会改变 assistant 消息的权威性、回放以及调用/结果配对。调度器只在 assistant 消息完成后才启动。
@@ -96,7 +96,7 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
该设计遵循安全侧原则,对工具作者而言也很简单,但无法利用必须通过比较并列调用才能确认安全的并发性。工具过于宽泛地选择并行,可能暴露潜在的共享状态竞态。
-在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。因此,调度器只记录已启动的调用,在中止时将其排空,且取消后绝不启动替换调用。
+在某些情形下,并行调用会先行启动,而串行执行原本会在轮到这些调用之前中止。调度器在中止时将其排空,且取消后绝不启动替换调用;未派发的调用获得合成的中止调用/结果对。
有序提交可能会让快速结果等待较慢的早期并列调用。这保留了回放和模型历史顺序,同时实时界面仍可显示待处理进度。
@@ -104,4 +104,4 @@ PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_co
工具注册是调度边界。调度器会在每个屏障之后以及每次补充池之前重新分类,因此注册表变更会影响尚未启动的调用。已启动的调用保留它们进入池时所依据的调度决策。
-终态调度器故障可能会在故障步骤关闭前留下已记录但没有结果的调用。等待仍在运行的派发可确保完全停稳,而不会将这些内部故障误报为工具结果。
+终态调度器故障只有在运行中的派发结算后才会进入步骤恢复。恢复将未提交的执行结果报告为未知,保留完全停稳与原始失败,同时让后续模型历史保持可用。
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
index 8f2fe2b211..a06d830891 100644
--- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml
@@ -9,8 +9,8 @@
en: d3383b495424d118
zh: 4e6ffc8ebfa7f741
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/decision:
- en: 85b950a67315023c
- zh: cb4c08338c30b226
+ en: 739b871ae583dcbf
+ zh: d17053e3b0a35942
/agent-note-session-telemetry-seam-with-mandatory-redaction-and-the-otel-backend/alternatives-considered:
en: 01838eefca2bc117
zh: fc71f5449d095b27
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
index dc52ee87ef..b3b50d4be5 100644
--- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md
@@ -16,6 +16,8 @@ Every deployment that wants harness sessions in an observability stack must hand
- **The `session-telemetry/record` waterfall** — the delta over the branch version and the seam's redaction extension point. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten.
- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `DISABLED` is the plugin default and constructs no transport. `FEEDBACK_ONLY` requires `exporter.url`; the [explicit-feedback policy](../architecture/2026-09-05-nonofficial-feedback-otel.md) requires a new submission for every bounded capture, for all providers. [Buffer-free feedback replay](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md) avoids a second in-memory copy of the session prefix.
+The [Session-log request limits](../architecture/2026-09-25-session-log-otel-byte-limits.md) supersede the SDK-only batching rule below for the OTel Session sender; capture, redaction, and handoff rationale remain applicable.
+
The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs. Delivery is best-effort: a crash can lose queued records. Backend retry or losing the same live object's module-scope cursor can duplicate lifecycle-local rows, so receivers deduplicate on `(session.id, session.format_version, event.seq)`.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
index f647ea2896..349cd47e59 100644
--- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md
@@ -16,6 +16,8 @@ Status: implemented
- **`session-telemetry/record` waterfall(瀑布式事件)** —— 相对分支版本的增量,也是该 seam 的脱敏扩展点。每条记录抵达任何后端前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。
- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考后端:OTel JS SDK 日志流水线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`DISABLED` 是插件默认值,且不构造任何传输。`FEEDBACK_ONLY` 要求 `exporter.url`;[显式反馈策略](../architecture/2026-09-05-nonofficial-feedback-otel.zh.md)要求每次有界捕获都由新提交触发,适用于所有提供方。[无缓冲反馈回放](../../archived/simplification/2026-08-06-buffer-free-feedback-telemetry.md)避免在内存中创建会话前缀的第二份副本。
+[Session 日志请求上限](../architecture/2026-09-25-session-log-otel-byte-limits.zh.md) 在 OTel Session 发送器中替代下述仅由 SDK 分批的规则;采集、脱敏和交接的理由仍然适用。
+
边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,并经 passthrough 配置。投递是尽力而为:崩溃可能丢失已排队记录。后端重试或丢失同一 live 对象的模块作用域游标可能重复生命周期本地 row,因此接收端基于 `(session.id, session.format_version, event.seq)` 去重。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml
index eef9430a5a..f52f1c14fc 100644
--- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml
@@ -9,8 +9,8 @@
en: 476b4a1b9ada2d8a
zh: 15b8051d22bb1dd0
/agent-note-in-process-subagent-policy-inheritance-the-child-starts-under-the-parent-s-sandbox-override/decision:
- en: dc11e7e52cdd4704
- zh: e000adedb13f2a98
+ en: 1f99951bc562cce4
+ zh: d68ee315b7aea792
/agent-note-in-process-subagent-policy-inheritance-the-child-starts-under-the-parent-s-sandbox-override/decision/what-a-blocked-child-experiences:
en: 3b6ff196a06c2420
zh: c04cb8bd81eb1c4b
diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md
index 8ca63bd511..a7973bcc07 100644
--- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md
+++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md
@@ -10,7 +10,7 @@ The Auto or Full access preset identity plus sandbox and approval overrides are
## Decision
-The delegation boundary calls the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`) before its first await; the one-shot driver and the [continuable start](../../../../packages/subagent/subagent/README.md) both use them. The capture copies the current `permission/preset` identity when `permissionPresets.current(parent.session)` is `auto` or `danger-full-access`, snapshots `sandboxPolicy.overrideOf(parent.session)`, and pins the child approval policy to `'never'`. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. The permission-preset and sandbox-policy services are optional: only the Auto/Full-access shared-bundle identity and the explicit sandbox session override are copied, never deployment defaults or one-shot grants. The approval policy is not inherited — the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md) supersedes this note's original approval-override inheritance.
+The delegation boundary calls the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`) before its first await; the one-shot driver and the [continuable start](../../../../packages/subagent/subagent/README.md) both use them. The capture copies the current `permission/preset` identity when `permissionPresets.current(parent.session)` is `auto` or `danger-full-access`, snapshots `sandboxPolicy.overrideOf(parent.session)`, and pins the child approval policy to `'never'`. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. The permission-preset and sandbox-policy services are optional: only the Auto or Full access identity and the explicit sandbox session override are copied, never deployment defaults or one-shot grants. The approval policy is not inherited — the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md) supersedes this note's original approval-override inheritance.
An inherited Auto or Full access identity becomes a `permission/preset` event, while captured sandbox and pinned approval values become source-tagged `sandbox/mode` and `approval/policy` events during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` after the constructor seed, while `Session.inheritedEventCount` keeps the exact fork-prefix length, so the inherited facts follow fork history without changing its lineage cut. Lifecycle-local telemetry starts at `firstLiveSeq`, so it excludes the constructor seed and includes these unpublished-setup events. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism.
diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md
index 630c69274e..0b4a2d4099 100644
--- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md
@@ -10,7 +10,7 @@ Auto 或 Full access 预设身份以及沙箱与审批覆盖项都是按会话
## 决策
-委派边界会在第一次 await 之前调用共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`);一次性驱动器与[可继续启动](../../../../packages/subagent/subagent/README.zh.md)都会使用它们。当 `permissionPresets.current(parent.session)` 为 `auto` 或 `danger-full-access` 时,捕获会复制当前 `permission/preset` 身份,同时对 `sandboxPolicy.overrideOf(parent.session)` 获取快照,并把子 agent 的审批策略钉定为 `'never'`。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。权限预设与沙箱策略服务都是可选的:只复制 Auto/Full access 共用旋钮组合的身份与显式沙箱会话覆盖项,绝不复制部署默认值或一次性授权。审批策略不继承——[审批钉定决策](2026-08-10-subagent-approval-pinned-never.zh.md)取代了本 note 原先的审批覆盖项继承。
+委派边界会在第一次 await 之前调用共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`);一次性驱动器与[可继续启动](../../../../packages/subagent/subagent/README.zh.md)都会使用它们。当 `permissionPresets.current(parent.session)` 为 `auto` 或 `danger-full-access` 时,捕获会复制当前 `permission/preset` 身份,同时对 `sandboxPolicy.overrideOf(parent.session)` 获取快照,并把子 agent 的审批策略钉定为 `'never'`。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。权限预设与沙箱策略服务都是可选的:只复制 Auto 或 Full access 身份与显式沙箱会话覆盖项,绝不复制部署默认值或一次性授权。审批策略不继承——[审批钉定决策](2026-08-10-subagent-approval-pinned-never.zh.md)取代了本 note 原先的审批覆盖项继承。
继承的 Auto 或 Full access 身份会成为一条 `permission/preset` 事件,捕获的沙箱值与钉定的审批值则会在子 agent 工厂的未发布设置阶段成为带来源标记的 `sandbox/mode` 与 `approval/policy` 事件。会话构造函数已把 `Session.firstLiveSeq` 固定在 constructor seed 之后,而 `Session.inheritedEventCount` 保留精确的 fork 前缀长度,因此继承事实会排在 fork 历史之后,却不改变其谱系 cut。生命周期本地遥测从 `firstLiveSeq` 开始,因此排除 constructor seed,并包含这些未发布设置事件。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
index 770368862d..e811b85577 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
@@ -12,8 +12,8 @@
en: 8892eea7f62d45ed
zh: 18f64966ff53e38b
/agent-note-web-subagent-catalog-and-human-continuation/design-context:
- en: 3c44fc5af90bc807
- zh: 478538d028a52858
+ en: da3858c86cf685ff
+ zh: b13ee4f45e3dff7d
/agent-note-web-subagent-catalog-and-human-continuation/product-contract:
en: 2060d607d1cd1ef3
zh: e7100887358625c4
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
index 3798a5a2e5..ba72e31d88 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
@@ -28,7 +28,7 @@ This decision covers Web discovery, transcript viewing, and parent-authorized hu
## Design context
-The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f), [hierarchical expansion](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f), and [child conversation](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f) frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
+The Figma subagent list, hierarchical expansion, and child conversation frames are non-normative interaction and visual references. This note owns lifecycle, wire, and failure semantics.
| Design intent | Shipped contract |
| --- | --- |
diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
index 4531528dd2..a3841b05fb 100644
--- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
@@ -28,7 +28,7 @@ Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话
## 设计上下文
-Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-14602&p=f)、[层级展开](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=383-15917&p=f)与 [child 对话](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=388-18584&p=f)画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
+Figma 中的 subagent 列表、层级展开与 child 对话画框是非规范性的交互与视觉参考。本记录负责生命周期、协议与失败语义。
| 设计意图 | 已交付约定 |
| --- | --- |
diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml
index 4e846fcbce..ab336fff79 100644
--- a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml
@@ -9,8 +9,8 @@
en: 492ba2355360d81a
zh: 825c0e7ab1cf4beb
/agent-note-experimental-auto-review-before-each-tool-call/decision:
- en: 8bf58a9d5c800af6
- zh: 3bb386279301fbbf
+ en: fd4103b65b24f6e2
+ zh: 5c1a364355b7e9ab
/agent-note-experimental-auto-review-before-each-tool-call/decision/effects-and-authority:
en: 55a79d8572fb4332
zh: f63d03d0f3bb5811
@@ -18,8 +18,8 @@
en: d93bd9b8af6b0bfc
zh: 9514c2e5554a721e
/agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation:
- en: 7952d0f88e7b7753
- zh: ea85c96f9d0b78a1
+ en: 237018634b5f9dcf
+ zh: cbc49d9c1b794065
/agent-note-experimental-auto-review-before-each-tool-call/decision/process-catalog-and-children:
en: c305e3506fb4febc
zh: aecc99cda8be3de2
diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.md
index 13b3769b97..54a2609e33 100644
--- a/.agents/notes/implemented/feature/2026-08-28-auto-review.md
+++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.md
@@ -10,9 +10,9 @@ Full access lets useful project work proceed without repeated approvals, but it
## Decision
-[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.md) is an explicitly installed experimental Web layer, published under the [experimental package publication decision](../process/2026-09-12-publish-all-experimental-packages.md). Default Web retains Read Only, Workspace Write, and Full access. The layer contributes current-session `auto`, whose only durable identity is `permission/preset:auto`; it shares Full access's unchanged `danger-full-access + never` knobs and tool definitions. Headless, General settings, and new-session defaults exclude the integration.
+[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.md) is an explicitly installed experimental Web layer, published under the [experimental package publication decision](../process/2026-09-12-publish-all-experimental-packages.md). Default Web retains Read Only, Workspace Write, and Full access. The layer contributes current-session `auto`, whose only durable identity is `permission/preset:auto`; it uses Full access's `danger-full-access` sandbox and tool definitions with the approval policy chosen by [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md). Headless, General settings, and new-session defaults exclude the integration.
-Every native call and started PTC `tools.*` inner call receives one review before its body. The outer `run_code` transport and direct Node effects in a PTC program remain outside this guarantee. There are no tool-name exemptions, cached grants, retries, configurable policy, second authorization check, or manual fallback. A repeated call receives a fresh review.
+Every native call and started PTC `tools.*` inner call receives one review before its body. The outer `run_code` transport and direct Node effects in a PTC program remain outside this guarantee. There are no tool-name exemptions, cached grants, retries, configurable policy, or second authorization check; [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md) owns what follows a denial. A repeated call receives a fresh review.
### Effects and authority
@@ -46,11 +46,11 @@ The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool r
### Result and cancellation
-The reviewer may emit reasoning blocks followed by exactly one JSON text block and terminal `stop`. The closed object admits only `low + allow`, `medium + allow/deny`, and `high + deny`; only deny may carry a string `reason`. Extra fields, duplicate members, invalid combinations, other blocks or termination, and provider failures share the ordinary Auto denial outcome. Risk and reviewer traces are not durable state.
+The reviewer may emit reasoning blocks followed by exactly one JSON text block and terminal `stop`. The closed object admits only `low + allow`, `medium + allow/deny`, and `high + deny`; only deny may carry a string `reason`. Extra fields, duplicate members, invalid combinations, other blocks or termination, and provider failures are reviewer failures, which [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md) reports with their specific error. Risk and reviewer traces are not durable state.
-Native results and PTC settle events carry the same structured `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional raw reason. The main agent receives only `Auto review rejected tool ""; its body was not executed` through ordinary failure rendering. PTC retains the existing program exception/catch behavior; catching a denial does not elevate it to an outer failure. The generic Web tool card supplies the denial identity for the collapsed row and one not-executed output line for the expanded row, with no input body. Only that display trims and collapses line separators or supplies the localized empty-reason fallback; persistence and both SDKs preserve the complete raw reason, without a new length or redaction rule.
+A final denial, which [the user-approval fallback decision](2026-09-24-auto-review-user-approval-fallback.md) limits to the `never` approval policy, gives native results and PTC settle events the same structured `AutoReviewDeniedError` / `AUTO_REVIEW_DENIED` and optional raw reason. For a final denial the main agent receives only `Auto review rejected tool ""; its body was not executed` through ordinary failure rendering. PTC retains the existing program exception/catch behavior; catching a denial does not elevate it to an outer failure. The generic Web tool card supplies the denial identity for the collapsed row and one not-executed output line for the expanded row, with no input body. Only that display trims and collapses line separators or supplies the localized empty-reason fallback; persistence and both SDKs preserve the complete raw reason, without a new length or redaction rule.
-Admission and active-review enrollment occur synchronously before the first await. The integration owns one lifecycle controller and one set of active operations. Unload closes new selection/review admission, changes live Auto Sessions to Full access through the existing preset writer without changing knobs or closing terminals, then aborts and drains reviews before removing listener and contribution. After provider settlement, a lifecycle abort always produces canonical pre-dispatch cancellation, including late allow, deny, or failure. Caller cancellation retains ToolRuntime's priority: late allow cancels before dispatch; late deny or failure retains that outcome. No cancelled review starts a tool body.
+Admission and active-review enrollment occur synchronously before the first await. The integration owns one lifecycle controller and one set of active operations. Unload closes new selection/review admission, changes live Auto Sessions to Full access through the existing preset writer, which writes the `never` approval policy without changing the sandbox value or closing terminals, then aborts and drains reviews before removing listener and contribution. After provider settlement, a lifecycle abort always produces canonical pre-dispatch cancellation, including late allow, deny, or failure. Caller cancellation retains ToolRuntime's priority: late allow cancels before dispatch; a late failure or final denial retains that outcome, and a late denial under `ask` produces a cancelled approval and canonical pre-dispatch cancellation. No cancelled review starts a tool body.
A persisted Auto Session cannot publish when the complete integration is absent or failed. Its log is not rewritten, and no background retry runs. Reinstallation allows a user to reopen it; live Sessions migrated to Full access remain there until explicitly switched.
diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md
index bf7d70b85b..6555a7b5cd 100644
--- a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md
@@ -10,9 +10,9 @@ Full access 让有用的项目工作无需反复审批即可继续,但也允
## 决策
-[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.zh.md)是显式安装的实验性 Web 层,按[实验包发布决策](../process/2026-09-12-publish-all-experimental-packages.zh.md)参与发布。默认 Web 保持 Read Only、Workspace Write 与 Full access。此层贡献仅限当前会话的 `auto`,唯一持久身份为 `permission/preset:auto`;它共用 Full access 未改变的 `danger-full-access + never` 旋钮与工具定义。Headless、通用设置与新会话默认值都排除此 integration。
+[`dsh-experimental-auto-review`](../../../../packages/experimental/auto-review/README.zh.md)是显式安装的实验性 Web 层,按[实验包发布决策](../process/2026-09-12-publish-all-experimental-packages.zh.md)参与发布。默认 Web 保持 Read Only、Workspace Write 与 Full access。此层贡献仅限当前会话的 `auto`,唯一持久身份为 `permission/preset:auto`;它使用 Full access 的 `danger-full-access` 沙箱与工具定义,审批策略由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)决定。Headless、通用设置与新会话默认值都排除此 integration。
-每个原生调用与已开始的 PTC `tools.*` inner call 都在 body 前接受一次审查。外层 `run_code` transport 与 PTC 程序内直接 Node 效果不在保证范围内。不提供按工具名豁免、缓存 grant、重试、可配置策略、第二授权检查或人工 fallback。重复调用也重新审查。
+每个原生调用与已开始的 PTC `tools.*` inner call 都在 body 前接受一次审查。外层 `run_code` transport 与 PTC 程序内直接 Node 效果不在保证范围内。不提供按工具名豁免、缓存 grant、重试、可配置策略或第二授权检查;拒绝之后的行为由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)负责。重复调用也重新审查。
### 效果与权威
@@ -46,11 +46,11 @@ reviewer 从 Session 的完整动作历史构建这两个动作分节:授权
### 结果与取消
-Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和终态 `stop`。封闭对象只允许 `low + allow`、`medium + allow/deny` 与 `high + deny`;只有 deny 可携带字符串 `reason`。额外字段、重复成员、非法组合、其他 block 或终态以及 provider 失败均使用普通 Auto 拒绝结果。风险与 reviewer trace 不成为持久状态。
+Reviewer 可以输出 reasoning blocks,随后恰好一个 JSON text block 和终态 `stop`。封闭对象只允许 `low + allow`、`medium + allow/deny` 与 `high + deny`;只有 deny 可携带字符串 `reason`。额外字段、重复成员、非法组合、其他 block 或终态以及 provider 失败都是 reviewer 失败,由[用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)以具体错误报告。风险与 reviewer trace 不成为持久状态。
-原生结果与 PTC 结算事件携带同形结构化 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 及可选原始理由。主 agent 通过普通失败渲染只收到 `Auto review rejected tool ""; its body was not executed`。PTC 保留既有程序异常/catch 行为;捕获拒绝不会将其提升为外层失败。通用 Web 工具卡片为折叠行提供拒绝身份,为展开行提供一行未执行输出,不提供输入正文。只有该显示过程会 trim、折叠行分隔符,或提供本地化空理由 fallback;持久化与两套 SDK 保留完整原始理由,不增加长度或脱敏规则。
+最终拒绝([用户审批兜底决策](2026-09-24-auto-review-user-approval-fallback.zh.md)将其限定在 `never` 审批策略下)让原生结果与 PTC 结算事件携带同形结构化 `AutoReviewDeniedError`/`AUTO_REVIEW_DENIED` 及可选原始理由。对最终拒绝,主 agent 通过普通失败渲染只收到 `Auto review rejected tool ""; its body was not executed`。PTC 保留既有程序异常/catch 行为;捕获拒绝不会将其提升为外层失败。通用 Web 工具卡片为折叠行提供拒绝身份,为展开行提供一行未执行输出,不提供输入正文。只有该显示过程会 trim、折叠行分隔符,或提供本地化空理由 fallback;持久化与两套 SDK 保留完整原始理由,不增加长度或脱敏规则。
-准入与在途 review 登记在首次 await 前同步完成。Integration 拥有一个生命周期 controller 和一个在途操作集合。卸载先关闭新选择/review admission,经由既有 preset writer 把存活 Auto Session 切为 Full access,不改变旋钮、不关闭终端,然后中止并等待 review 结清,最后移除 listener 与 contribution。Provider 结算后,lifecycle abort 始终形成规范的 dispatch 前取消,包括晚到 allow、deny 或 failure。Caller 取消保留 ToolRuntime 优先级:晚到 allow 在 dispatch 前取消;晚到 deny 或 failure 保留原结果。被取消的 review 不启动工具 body。
+准入与在途 review 登记在首次 await 前同步完成。Integration 拥有一个生命周期 controller 和一个在途操作集合。卸载先关闭新选择/review admission,经由既有 preset writer 把存活 Auto Session 切为 Full access,该 writer 写入 `never` 审批策略,不改变沙箱值、不关闭终端,然后中止并等待 review 结清,最后移除 listener 与 contribution。Provider 结算后,lifecycle abort 始终形成规范的 dispatch 前取消,包括晚到 allow、deny 或 failure。Caller 取消保留 ToolRuntime 优先级:晚到 allow 在 dispatch 前取消;晚到 failure 或最终拒绝保留原结果,`ask` 下晚到的拒绝产生已取消的审批与规范的 dispatch 前取消。被取消的 review 不启动工具 body。
完整 integration 缺失或失败时,持久 Auto Session 不能发布。日志不改写,也不后台重试。重装后用户可以重新打开它;已经迁移为 Full access 的存活 Session 保持原状,直到显式切换。
diff --git a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.i18n.yaml b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.i18n.yaml
new file mode 100644
index 0000000000..21a2a83f53
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-24-auto-review-user-approval-fallback.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md
+/agent-note-auto-review-asks-the-user-after-a-denial:
+ en: 5591f8e34bda222a
+ zh: 7d433ca541e6988f
+/agent-note-auto-review-asks-the-user-after-a-denial/problem:
+ en: ed725dff16cd2339
+ zh: 555d4ba70af96790
+/agent-note-auto-review-asks-the-user-after-a-denial/decision:
+ en: 4782a7609717bac3
+ zh: f3ebb3581b807f3f
+/agent-note-auto-review-asks-the-user-after-a-denial/alternatives-considered:
+ en: 01f71dd10849f925
+ zh: 8b79a7e149a9a60c
+/agent-note-auto-review-asks-the-user-after-a-denial/consequences:
+ en: 822a4241ddf39448
+ zh: dd05997932eed142
diff --git a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md
new file mode 100644
index 0000000000..c9d4cda11b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.md
@@ -0,0 +1,33 @@
+# Agent Note: Auto review asks the user after a denial
+
+Status: implemented
+
+English | [中文](2026-09-24-auto-review-user-approval-fallback.zh.md)
+
+## Problem
+
+[Auto review](2026-08-28-auto-review.md) made every reviewer denial final and reported every reviewer failure as that same denial. A person watching the Session had no way to let a denied call run, even when the reviewer misjudged an action the person wanted, so the only recovery was to switch the whole Session to Full access. A malformed reviewer response, a provider error, or a missing Session fact reached the model and the user as `Auto review rejected tool ""`, so neither could tell a policy decision from a technical failure or act on the cause.
+
+## Decision
+
+A reviewer denial returns the tools pipeline's existing `ask` decision when the Session's approval policy is `ask`. `ToolRuntime` sends it through the approval service with the reason `Auto review denied tool ""`, followed by `: ` when the reviewer gave one. `allowed-once` executes the call; `rejected`, `cancelled`, and `unavailable` deny it through the approval service's ordinary messages, and the body does not run. The listener asks only after later `tools/pre-execute` listeners allow the call, so a downstream denial or cancellation wins without prompting.
+
+Selecting Auto writes `danger-full-access` with the `ask` approval policy. A recorded Auto selection also matches the `never` policy. A [delegated in-process child pins `never`](2026-08-10-subagent-approval-pinned-never.md), so a child Auto keeps the final `AutoReviewDeniedError` denial and its reason, and a Session recorded under the earlier `never` bundle still resolves to Auto instead of Full access. Selecting Auto again writes `ask`.
+
+A reviewer failure denies the call with `Auto review of tool "" failed; its body was not executed: ` and no structured error info. A provider failure's message carries the finish kind, failure code, and provider message, so the model and the generic tool card show the actual cause.
+
+## Alternatives considered
+
+**Ask the user after every reviewer failure too.** A failure says nothing about the action's risk; asking would present a technical error as a policy question and would hide a broken reviewer route behind repeated prompts.
+
+**Always return `ask` and let the `never` policy reject it.** The approval service reports that rejection as `the user rejected tool ""`, which is false for a child, and it drops the reviewer's reason.
+
+**Keep Auto on `never` and let the reviewer bypass the approval policy.** The approval service enforces `never` before any answerer so no listener can bypass it; the model-context policy text would also tell the main agent that no approval can happen.
+
+## Consequences
+
+- An interactive Auto Session can pause on an approval prompt, so Auto no longer guarantees unattended progress.
+- The raw reviewer reason is visible to the user in the approval prompt and the approval audit events; the main model sees only the approval outcome.
+- A reviewer failure's text, including provider messages, becomes model-visible tool error content.
+- Switching between Auto and Full access now also changes the approval policy, so the live-agent switch path queues the approval-policy change notice.
+- Unit tests cover the approval outcomes, the downstream-denial ordering, the child `never` path, and the specific failure messages; the shipped Web composition test covers a user rejection after a real reviewer denial.
diff --git a/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.zh.md b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.zh.md
new file mode 100644
index 0000000000..7d901b75fb
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-24-auto-review-user-approval-fallback.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: Auto review 拒绝后请求用户审批
+
+Status: implemented
+
+[English](2026-09-24-auto-review-user-approval-fallback.md) | 中文
+
+## Problem
+
+[Auto review](2026-08-28-auto-review.zh.md) 把每次 reviewer 拒绝都当作最终结果,并把每次 reviewer 失败也报告为同样的拒绝。旁观 Session 的人无法让被拒绝的调用继续执行,即使 reviewer 误判了此人想要的动作;唯一的补救是把整个 Session 切换到 Full access。Reviewer 响应不合法、provider 出错或 Session 事实缺失时,模型与用户看到的都是 `Auto review rejected tool ""`,因此两者都无法区分策略决定与技术失败,也无法针对原因采取行动。
+
+## Decision
+
+当 Session 的审批策略为 `ask` 时,reviewer 拒绝返回 tools 流水线既有的 `ask` 决定。`ToolRuntime` 通过审批服务发送该请求,理由为 `Auto review denied tool ""`;reviewer 给出理由时再追加 `: `。`allowed-once` 执行调用;`rejected`、`cancelled` 与 `unavailable` 通过审批服务的普通消息拒绝调用,body 不执行。只有后续 `tools/pre-execute` listener 放行调用后,listener 才请求审批,因此下游拒绝或取消优先且不弹出审批。
+
+选择 Auto 会写入 `danger-full-access` 与 `ask` 审批策略。已记录的 Auto 选择也匹配 `never` 策略。[进程内委派 child 固定 `never`](2026-08-10-subagent-approval-pinned-never.zh.md),因此 child 的 Auto 保留最终的 `AutoReviewDeniedError` 拒绝及其理由;按先前 `never` 组合记录的 Session 仍解析为 Auto,而不是 Full access。再次选择 Auto 会写入 `ask`。
+
+Reviewer 失败以 `Auto review of tool "" failed; its body was not executed: ` 拒绝调用,不带结构化错误信息。Provider 失败消息包含 finish 类型、失败代码与 provider 消息,因此模型与通用工具卡片显示实际原因。
+
+## Alternatives considered
+
+**Reviewer 失败后也请求用户审批。** 失败不说明动作风险;请求审批会把技术错误呈现为策略问题,并用反复弹出的审批掩盖损坏的 reviewer 路由。
+
+**始终返回 `ask`,由 `never` 策略拒绝。** 审批服务把该拒绝报告为 `the user rejected tool ""`,这对 child 不成立,而且会丢失 reviewer 理由。
+
+**Auto 保持 `never`,由 reviewer 绕过审批策略。** 审批服务在任何应答者之前执行 `never`,因此没有 listener 能绕过它;模型上下文中的策略文本也会告诉主 agent 不可能发生审批。
+
+## Consequences
+
+- 交互式 Auto Session 可能停在审批提示上,因此 Auto 不再保证无人值守地推进。
+- 用户可以在审批提示与审批审计事件中看到原始 reviewer 理由;主模型只看到审批结果。
+- Reviewer 失败文本(包括 provider 消息)成为模型可见的工具错误内容。
+- 在 Auto 与 Full access 之间切换现在也会改变审批策略,因此存活 agent 的切换路径会排入审批策略变更通知。
+- 单元测试覆盖各审批结果、下游拒绝的先后顺序、child 的 `never` 路径以及具体失败消息;shipped Web 组合测试覆盖真实 reviewer 拒绝之后的用户拒绝。
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
index 1b44f40971..982839c935 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml
@@ -9,8 +9,8 @@
en: cef0f05fd8dfd6a1
zh: 228107eb6c00a91a
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/decision:
- en: 453cf66046816477
- zh: af5bb77e849416a1
+ en: f0d9c292c3b8dd3b
+ zh: fd928084dfd333e2
/agent-note-freeze-low-future-value-agent-notes-outside-the-active-corpus/alternatives-considered:
en: 7637deef67f3ec14
zh: 7f43606e487fab67
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
index e9ecfa78f5..8e4ae6beab 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md
@@ -18,7 +18,7 @@ The root `.rgignore` excludes the archive from searches that traverse a parent d
After archival, the triplet is permanently frozen and is historical context rather than current authority. It is not updated for renamed packages, changed behavior, translation standards, formatting rules, broken outbound links, or later documentation contracts. Active prose may intentionally link into an archived note, redirect that link to current authority, or delete it. Repository gates therefore validate links into archived files but never treat archived files as link sources.
-[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest. Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
+[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) owns the frozen boundary. It accepts only the closed set of Agent Note kinds, requires a complete triplet with implemented status and matching valid archive dates, verifies the sidecar against both current Git blob hashes, and seals every artifact by path and SHA-256 content hash in an append-only manifest, with only the exact seal-transition exceptions recorded in [archive instructions](../../archived/AGENTS.md). Its `--write` mode first proves every existing seal unchanged and then appends only newly archived artifacts. Pull-request CI supplies the trusted base SHA and checks out complete history before running the verifier, so a reused runner's shallow checkout cannot omit the baseline manifest. The ordinary Agent Note format, translation-pairing, wrapping, Markdown-link, package-path, Mermaid, documentation-TypeScript, and type-equivalence gates exclude archive sources; their evolving standards cannot create pressure to edit history.
The [`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) workflow owns classification. It requires a semantic note-by-note audit, uses code and current documentation to identify present authority, treats word count only as triage, carries calibrated keep/archive/delete examples, and reports genuinely borderline outcomes for review.
diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
index 9a3ce2e6e2..a41cf83cf5 100644
--- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
+++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md
@@ -18,7 +18,7 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录
归档后,这三个文件永久冻结,只作为历史背景,不再是当前权威依据。不得因为包重命名、行为变化、翻译标准、格式规则、出站链接失效或后续文档约定而更新归档文件。活跃文档可以有意链接到归档 Agent Note,也可以把该链接重定向到当前权威依据,或直接删除。仓库门禁因此会校验指向归档文件的链接,但绝不把归档文件作为链接源来校验。
-[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
+[`verify-archived-agent-notes`](../../../../scripts/verify-archived-agent-notes.ts) 负责维护冻结边界。它只接受封闭集合中的 Agent Note 类别,要求三个配对文件完整、状态为 implemented,且归档日期有效并互相匹配;它还会用双方当前的 Git blob hash 校验伴随记录,并在仅追加的 manifest(元数据清单)中按路径和 SHA-256 内容 hash 封存每项产物,仅允许[归档说明](../../archived/AGENTS.md)中记录的精确封存哈希转换例外。其 `--write` 模式会先证明每条现有封存记录对应的内容都未改变,再仅追加新归档的产物。拉取请求 CI 会提供可信的基准 SHA,并在运行校验器前检出完整历史,因此复用运行器上的浅克隆检出无法漏掉基线 manifest。普通的 Agent Note 格式、翻译配对、换行、Markdown 链接、包路径、Mermaid、文档 TypeScript 和类型等价门禁都排除归档源文件,因此这些门禁持续演进的标准不会产生修改历史记录的压力。
[`dsh-archive-agent-notes`](../../../skills/dsh-archive-agent-notes/SKILL.md) 工作流负责分类判断。它要求逐份 Agent Note 做语义审计,使用代码和当前文档识别现行权威依据,仅把字数作为初步筛选手段,收录经过校准的保留、归档和删除示例,并报告真正处于边界的结果,以供评审。
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
index 5d29f39a16..a87c0b5e8b 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml
@@ -9,8 +9,8 @@
en: b3ec2245644b46e6
zh: 83de1cb620f9ddcf
/agent-note-make-lefthook-installation-worktree-local/decision:
- en: 577d90ff7e03ebaf
- zh: dc680155c0c3a2a1
+ en: b53a2425f88a6031
+ zh: 41f459b00803b3f9
/agent-note-make-lefthook-installation-worktree-local/alternatives-considered:
en: d7100b0d2c95a3f9
zh: 070c38c4d0d1331a
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
index 42adc1ed1d..66ae7a136e 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
@@ -14,7 +14,7 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal
Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer so `git config --show-scope` can report which scope supplied a value, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`.
-Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
+Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking. On Windows, acquiring or inspecting a lock retries EPERM within the existing 30-second acquisition deadline because deleting a lock can temporarily deny access while readers close their handles; other errors and expired deadlines still fail.
Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
index b4b9059678..641bc6d2c8 100644
--- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
+++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md
@@ -14,7 +14,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,安装程序要求 Git 2.26 或更高版本,使 `git config --show-scope` 可以报告由哪个作用域提供配置值;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。
-升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。
+升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。在 Windows 上,获取或检查锁时遇到 EPERM 会在原有的 30 秒获取期限内重试,因为删除锁时,读取者关闭句柄前可能暂时无法访问该文件;其他错误及超时仍会导致失败。
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。
diff --git a/.agents/skills/agent-experience/SKILL.md b/.agents/skills/agent-experience/SKILL.md
index bd169f1ae2..3ad20ad5cb 100644
--- a/.agents/skills/agent-experience/SKILL.md
+++ b/.agents/skills/agent-experience/SKILL.md
@@ -1,6 +1,6 @@
---
name: agent-experience
-description: Use when designing agent tools, skills, context loading, or multi-step workflows to make information discoverable and use context efficiently.
+description: Use when writing or changing a model-facing tool definition (name, description, parameter schema, or its system-prompt section), and when designing skills, context loading, or multi-step workflows, to make information discoverable and use context efficiently.
metadata:
date: "2026-09-11"
---
@@ -11,3 +11,11 @@ metadata:
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
+
+## Tool definitions
+
+- **Delete obvious constraints.** Omit rules the model learns from the call result, such as a missing file failing to read or a failed send not being delivered.
+- **Describe behavior, not implementation.** State what the tool does and returns; omit internal mechanisms, output markers the model sees in results, and enforcement details.
+- **Put parameter rules on the parameter.** Defaults, ranges, pairing, and when-to-set rules belong in that parameter's description or schema; a description that varies with configuration usually means the varying part belongs to a parameter.
+- **Say each fact once.** Do not repeat the tool definition in its system-prompt section, a parameter description in the tool description, or one tool's rules in another tool.
+- **Measure the change.** Compare first-turn prompt tokens before and after a tool-definition change.
diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml
index 8bac404b2d..6cd25b059d 100644
--- a/.github/workflows/docs-pages.yml
+++ b/.github/workflows/docs-pages.yml
@@ -35,7 +35,7 @@ env:
jobs:
build:
- runs-on: ubuntu-latest
+ runs-on: blacksmith-8vcpu-ubuntu-2404
permissions:
contents: read
pages: read
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 3f325012a0..e08afa0c1c 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -56,7 +56,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/core`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
-| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index e72a1f0ce6..e627d9e47d 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -74,6 +74,8 @@ flowchart LR
cfg --> plugin_dsh_base_storage_domain
plugin_dsh_base_session_projection_cache["session-projection-cache @deepseek-ai/dsh-session-projection-cache"]
cfg --> plugin_dsh_base_session_projection_cache
+ plugin_dsh_base_otel["otel @deepseek-ai/dsh-otel"]
+ cfg --> plugin_dsh_base_otel
plugin_dsh_base_session_telemetry_otel["session-telemetry-otel @deepseek-ai/dsh-session-telemetry-otel"]
cfg --> plugin_dsh_base_session_telemetry_otel
plugin_dsh_base_subprocess["subprocess @deepseek-ai/dsh-subprocess-local"]
@@ -231,6 +233,7 @@ flowchart LR
| `storage-json` | `@deepseek-ai/dsh-storage-json` |
| `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
+| `otel` | `@deepseek-ai/dsh-otel` |
| `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `sandbox` | `@deepseek-ai/dsh-sandbox-local` |
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 33eac62c0b..be98d17216 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:*",
"@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*",
+ "@deepseek-ai/dsh-experimental-schedule-bundle": "workspace:*",
"@deepseek-ai/dsh-fs-local": "workspace:*",
"@deepseek-ai/dsh-goal": "workspace:*",
"@deepseek-ai/dsh-goal-round-driver": "workspace:*",
diff --git a/apps/cli/src/dump-config-schema.ts b/apps/cli/src/dump-config-schema.ts
index 874cd56f8c..1d92f2dfca 100644
--- a/apps/cli/src/dump-config-schema.ts
+++ b/apps/cli/src/dump-config-schema.ts
@@ -35,7 +35,7 @@ export async function runDumpConfigSchema(
// Trusted module diagnostics must not precede the JSON document on stdout.
process.stdout.write = process.stderr.write.bind(process.stderr)
try {
- dump = await generateConfigSchema(NAME, loaded, layers.map(layer => layer.patches), INSTALL_ANCHOR)
+ dump = await generateConfigSchema(loaded, layers.map(layer => layer.patches), INSTALL_ANCHOR)
} finally {
process.stdout.write = stdoutWrite
}
diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts
index 9e40d01f70..b795f627ca 100644
--- a/apps/cli/src/profile-boot.ts
+++ b/apps/cli/src/profile-boot.ts
@@ -23,6 +23,7 @@ import {
installFailLoud,
loadOverlayPatches,
loadProfile,
+ reportSkippedBundles,
PluginPackages,
PROFILE_PATCH_FILENAME,
PROFILE_TEMPLATES,
@@ -166,6 +167,7 @@ export function initializeProfileFromDefault(
export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
+ reportSkippedBundles(NAME, profile)
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
return profile
}
diff --git a/apps/cli/tests/desktop-host.e2e.ts b/apps/cli/tests/desktop-host.e2e.ts
index 57afeb20c4..615270aae0 100644
--- a/apps/cli/tests/desktop-host.e2e.ts
+++ b/apps/cli/tests/desktop-host.e2e.ts
@@ -28,7 +28,11 @@ it.each([false, true])('settles startup after parent IPC disconnect (boot failur
}
writeFileSync(join(root, 'package.json'), '{"type":"module"}')
writeFileSync(join(modules, 'dsh-app-boot', 'package.json'), '{"type":"module","exports":"./index.js"}')
- writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), 'export const loadProfileDirectory = () => ({}); export const loadLayeredEnv = () => ({})')
+ writeFileSync(join(modules, 'dsh-app-boot', 'index.js'), `
+ export const loadProfileDirectory = () => ({ skippedBundles: [] });
+ export const reportSkippedBundles = () => {};
+ export const loadLayeredEnv = () => ({});
+ `)
writeFileSync(join(modules, 'dsh', 'package.json'), '{"type":"module","exports":{"./profile-boot":"./profile-boot.js"}}')
writeFileSync(join(modules, 'dsh', 'profile-boot.js'), `
import { writeFileSync } from 'node:fs';
diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts
index 359e559241..cc851efd23 100644
--- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts
+++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts
@@ -108,7 +108,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent
expect(existsSync(decoyMarker), 'the agent must not invoke system Office when the bundled CLI is supplied').toBe(false)
expect(commandText, 'the skill supplies an entry; searching for other Office binaries is unnecessary').not.toMatch(/(?:which|whereis|command -v|find).{0,100}(?:libreoffice|soffice)/iu)
const capabilities = JSON.parse(await readFile(join(root, 'capabilities.json'), 'utf8')) as { runtime: { version: string; cliPath: string } }
- expect(capabilities.runtime.version).toBe('0.1.0')
+ expect(capabilities.runtime.version).toBe('0.1.1')
expect(capabilities.runtime.cliPath).toMatch(/lib[/\\]cli\.js$/u)
const pdf = await readFile(join(root, 'result.pdf'))
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
diff --git a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts
index 063f578de9..a6846024da 100644
--- a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts
@@ -3,10 +3,14 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule'
import { expect, it } from 'vitest'
import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts'
+/** The optional Schedule bundle is the switch this built-Web scenario turns on. */
+const patches = [fileURLToPath(new URL('../../../../../../packages/experimental/schedule-bundle/cordis.patch.yml', import.meta.url))]
+
const id = 'retained-remote-task' as DailyScheduleRecord['id']
const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId']
const record: DailyScheduleRecord = {
@@ -82,7 +86,7 @@ it('edits a retained task through built Remote bindings and preserves its identi
expect(await rpc('schedule/update', { request: { sessionId: 'other-owner', id, expected: updated, change } }))
.toEqual({ ok: true, value: { id, updated: false, code: 'schedule_not_found' } })
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
- }, { prepare: root => seed(root, persisted) })
+ }, { patches, prepare: root => seed(root, persisted) })
await withDefaultWeb(test, async ({ root, url }) => {
const rpc = await connect(url, test.signal)
@@ -92,7 +96,7 @@ it('edits a retained task through built Remote bindings and preserves its identi
sessionId, id, expected: updated, change: { kind: 'daily', daily: { time: updated.time, time_zone: 'Etc/UTC' } },
} })).toEqual({ ok: true, value: { id, updated: false, record: updated } })
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
- }, { prepare: root => seed(root, persisted) })
+ }, { patches, prepare: root => seed(root, persisted) })
})
it('creates and idempotently adopts the explicit Session id bound to seeded task data without touching its task file', async (test) => {
@@ -114,5 +118,5 @@ it('creates and idempotently adopts the explicit Session id bound to seeded task
expect(await rpc('schedule/list', { request: { sessionId } })).toEqual(expectedList)
expect(await rpc('schedule/history', { request: { sessionId, id, limit: 20 } })).toEqual(expectedHistory)
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
- }, { prepare: root => seed(root, persisted) })
+ }, { patches, prepare: root => seed(root, persisted) })
})
diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts
index f98616f9a9..eef3fb2908 100644
--- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts
@@ -8,7 +8,7 @@ import { withDefaultWeb, webGet } from './default-web-process.ts'
const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team'
-it('boots default Web without experimental modules or an active built-in Browser', async (test) => {
+it('boots default Web without experimental modules, scheduling, time context, or an active built-in Browser', async (test) => {
await withDefaultWeb(test, async ({ url, request }) => {
const auth = await webGet(url, test.signal)
const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0]
@@ -24,21 +24,20 @@ it('boots default Web without experimental modules or an active built-in Browser
expect(roster.entries).toEqual(expect.arrayContaining([
expect.objectContaining({ name: '@deepseek-ai/dsh-host-webserver', state: FiberState.ACTIVE }),
expect.objectContaining({ name: '@deepseek-ai/dsh-client-modules', state: FiberState.ACTIVE }),
- // The Loader rewrites an inserted row's id (`include:ui-schedule`), so
- // the shipped rows are pinned by name and settled state.
- expect.objectContaining({ name: '@deepseek-ai/dsh-time-context', state: FiberState.ACTIVE }),
- expect.objectContaining({ name: '@deepseek-ai/dsh-schedule', state: FiberState.ACTIVE }),
- expect.objectContaining({ name: '@deepseek-ai/dsh-client-ui-schedule', state: FiberState.ACTIVE }),
]))
expect(roster.entries.some(entry => entry.name.endsWith('/runtime-roster-observer.js') && entry.state === FiberState.ACTIVE)).toBe(true)
expect(roster.plugins.length).toBeGreaterThan(roster.entries.length)
expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true)
expect(roster.client.entries.length).toBeGreaterThan(0)
- const browserName = '@deepseek-ai/dsh-client-ui-sidebar-browser'
- const browserEntry = roster.entries.find(entry => entry.name === browserName)
- expect(browserEntry).toBeDefined()
- expect(browserEntry!.state).toBeUndefined()
- expect(delivered.entries.some(entry => entry.id === browserName)).toBe(false)
+ const browser = roster.entries.find(entry => entry.name === '@deepseek-ai/dsh-client-ui-sidebar-browser')
+ expect(browser).toBeDefined()
+ expect(browser!.state).toBeUndefined()
+ expect(delivered.entries.some(entry => entry.id === '@deepseek-ai/dsh-client-ui-sidebar-browser')).toBe(false)
+ // The optional Schedule bundle inserts these rows; the shipped composition carries none of them.
+ for (const name of ['@deepseek-ai/dsh-time-context', '@deepseek-ai/dsh-schedule', '@deepseek-ai/dsh-client-ui-schedule']) {
+ expect(roster.entries.some(entry => entry.name === name), name).toBe(false)
+ expect(delivered.entries.some(entry => entry.id === name), name).toBe(false)
+ }
expect(experimentalRuntimeReferences(roster)).toEqual([])
const contaminated = await request('mount-experimental')
diff --git a/apps/cli/tests/resolved-profile-boot.spec.ts b/apps/cli/tests/resolved-profile-boot.spec.ts
index 0df34251c6..6e7bc52060 100644
--- a/apps/cli/tests/resolved-profile-boot.spec.ts
+++ b/apps/cli/tests/resolved-profile-boot.spec.ts
@@ -60,7 +60,7 @@ describe('runProfile with an application-owned profile', () => {
throw failure
})
if (stage === 'composition') vi.mocked(createRuntimeResolution).mockRejectedValueOnce(failure)
- const profile: Profile = {
+ const profile: Profile = { skippedBundles: [],
name: 'desktop', dir: home, patchPath: join(home, 'cordis.patch.yml'),
patches: [], layers: [],
}
@@ -121,7 +121,7 @@ describe('runProfile with an application-owned profile', () => {
writeFileSync(profilePatch, '- id: target\n config: { profile: true, priority: profile }\n')
writeFileSync(overlay, '- id: target\n config: { overlay: true, priority: overlay }\n')
writeFileSync(join(home, 'cordis.yml'), '- id: stale\n')
- const profile: Profile = {
+ const profile: Profile = { skippedBundles: [],
name: 'desktop', dir: home, patchPath: profilePatch,
patches: [{ id: 'target', config: { profile: true, priority: 'profile' } }],
layers: [{
diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts
index 7d3a25f9af..758f383a44 100644
--- a/apps/cli/tests/web-agent-presets.e2e.ts
+++ b/apps/cli/tests/web-agent-presets.e2e.ts
@@ -23,9 +23,6 @@ import { dump, load } from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { bundlePatchPaths, composeEntries } from '@deepseek-ai/dsh-app-boot'
/** Profile entry ids whose volatile fields these scenarios edit through Settings. */
-/** Host Schedule tools every live root Agent receives from the shipped Web bundle. */
-const SCHEDULE_TOOLS = ['schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update']
-
const SETTINGS_NAMESPACE = 'agent-preset-registry'
const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = 'subagent-model-selection-settings'
import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
@@ -136,7 +133,7 @@ async function bootWeb(
await mkdir(dirname(link), { recursive: true })
await symlink(packageDir, link, 'junction')
}
- let profile: Profile = {
+ let profile: Profile = { skippedBundles: [],
name: 'spec',
dir: profileDir,
layers: [],
@@ -266,8 +263,8 @@ 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', 'present', 'read', 'read_image', 'schedule_create',
- 'schedule_delete', 'schedule_list', 'schedule_update', 'send_message', 'skill',
+ 'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'read', 'read_image',
+ 'send_message', 'skill',
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
'workflow', 'write',
])
@@ -321,7 +318,7 @@ 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', ...SCHEDULE_TOOLS])
+ expect(assembly.tools.map(tool => tool.name)).toEqual(['bash'])
expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION)
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
// serviceFor reports preset-owned providers; unisolated consumers inherit the host fs.
@@ -345,7 +342,7 @@ describe('the shipped Web composition', () => {
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
try {
- expect(toolNames(ctx, minimal.agent)).toEqual(['bash', ...SCHEDULE_TOOLS])
+ expect(toolNames(ctx, minimal.agent)).toEqual(['bash'])
expect(toolNames(ctx, full.agent).length).toBeGreaterThan(10)
await minimal.dispose()
@@ -511,7 +508,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', ...SCHEDULE_TOOLS])
+ expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
} finally {
await handle.dispose()
}
@@ -866,7 +863,7 @@ describe('the default preset as a user setting', () => {
try {
// `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', ...SCHEDULE_TOOLS])
+ expect(toolNames(ctx, handle.agent)).toEqual(['bash'])
} finally {
await handle.dispose()
}
@@ -912,7 +909,7 @@ describe('a profile patch stored before Developer tools owned preset selection',
setup: agentCtx => legacy.agentPresets.mount(agentCtx).then(() => undefined),
})
try {
- expect(toolNames(legacy, handle.agent)).toEqual(['bash', ...SCHEDULE_TOOLS])
+ expect(toolNames(legacy, handle.agent)).toEqual(['bash'])
} finally {
await handle.dispose()
}
diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json
index 25fae4257e..a5b679bfff 100644
--- a/apps/desktop-host/package.json
+++ b/apps/desktop-host/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -10,7 +10,7 @@
"lib/index.js"
],
"dependencies": {
- "@deepseek-ai/libreoffice-kit": "^0.1.0",
+ "@deepseek-ai/libreoffice-kit": "^0.1.1",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:*",
diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts
index e9e4e13b29..4a47a92ce8 100644
--- a/apps/desktop-host/src/index.ts
+++ b/apps/desktop-host/src/index.ts
@@ -2,7 +2,7 @@
import { delimiter, join } from 'node:path'
import { inspect } from 'node:util'
-import { loadLayeredEnv, loadProfileDirectory } from '@deepseek-ai/dsh-app-boot'
+import { loadLayeredEnv, loadProfileDirectory, reportSkippedBundles } from '@deepseek-ai/dsh-app-boot'
import { runProfile } from '@deepseek-ai/dsh/profile-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -21,6 +21,7 @@ async function main(): Promise {
installOfficeEngineResolution(runtimeDir)
const installAnchor = join(runtimeDir, 'node_modules', '@deepseek-ai', 'dsh', 'package.json')
const profile = loadProfileDirectory('dsh', projectDir, installAnchor)
+ reportSkippedBundles('dsh', profile)
const application = runProfile({
environment: loadLayeredEnv('dsh'),
profile: 'desktop',
diff --git a/apps/desktop-host/tests/platform-session.spec.ts b/apps/desktop-host/tests/platform-session.spec.ts
index fbc26da6e1..4abc1b2055 100644
--- a/apps/desktop-host/tests/platform-session.spec.ts
+++ b/apps/desktop-host/tests/platform-session.spec.ts
@@ -9,6 +9,7 @@ const state: AccountView = {
}
class Account extends DeepSeekAccount {
+ getDeviceIdentity = async () => ({ osVersion: 'test-os' })
fail = false
ended = Promise.withResolvers()
reading = Promise.withResolvers()
diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml
index 039d4701b1..93f5641fb1 100644
--- a/apps/desktop/README.i18n.yaml
+++ b/apps/desktop/README.i18n.yaml
@@ -3,11 +3,11 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
/deepseek-harness-desktop:
- en: 45561fc0dd3a1b20
- zh: f4b03355d4bd0693
+ en: b8e4f357cf330721
+ zh: cdaaf35a061f41b1
/deepseek-harness-desktop/closing-the-window-and-quitting:
- en: fb852f3bd70d8602
- zh: 3d2b0a8d580d7f0a
+ en: c323e02e3f0305fe
+ zh: 3c730db632136443
/deepseek-harness-desktop/key-technical-decisions:
en: 069fdfd72d2ea26a
zh: 76ae549d43c99bb4
@@ -15,20 +15,20 @@
en: 31cbb7452be8e1ad
zh: 502ec556e2ec301e
/deepseek-harness-desktop/installation-ownership:
- en: 8307baf6c5b691d0
- zh: 86e46b9552017ff6
+ en: d186d3cfc9597688
+ zh: 5f7094355c5f562c
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: 0d11af8639977a6d
zh: 86b086b12d2210d3
/deepseek-harness-desktop/develop:
- en: 9b9b362a9865e763
- zh: c748901168e2db8c
+ en: 40d2ad4014bf445b
+ zh: 6a08de06df9ed042
/deepseek-harness-desktop/develop/startup-onboarding:
- en: 5e2d62207a603d81
- zh: 5fb863d4906f4a67
+ en: a51346b8d880ddba
+ zh: 7913214c1c66063c
/deepseek-harness-desktop/develop/welcome-window-appearance:
- en: 7220801ccc3b2e6b
- zh: 042dc97976aef979
+ en: ae319a496639b38d
+ zh: ef044103240ac9da
/deepseek-harness-desktop/package:
en: 4cd8a8da6977928d
zh: 9f624855a2edac37
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 0f579c249b..cf780cddc6 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -2,6 +2,8 @@
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited.
+
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
@@ -28,6 +30,8 @@ Windows keeps a tray icon for the whole run. Its tooltip is the product name, a
Every ordinary quit entry — ⌘Q, the application menu, the Dock menu, the Windows tray and caption Application menu, and the quits caused by closing the mandatory-update or welcome window — asks the Host what it would interrupt. The Host answers over the private IPC channel with two facts: active tasks under the same rule as the update restart check (running agents including subagents and approval waits, queued messages, running or stopping jobs), and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded during this run. Neither fact, the quit proceeds silently. Otherwise a native message box without an owner window — hidden windows stay hidden — shows **Quit DeepSeek Harness?** with one of three localized explanations: running tasks will be interrupted, scheduled tasks will not run while the application is closed, or both. Quit is the default button and Cancel answers Esc; macOS places Cancel left of Quit, Windows places Quit left of Cancel, and the Windows task dialog shows the application icon and stays light regardless of the application theme. A Host that has not reached ready or has failed cannot run tasks, so the quit proceeds without asking. An inspection failure or a Host that misses the two-second deadline is treated as running tasks. While the box is open, another quit request joins it instead of stacking a second one (macOS also raises it; Electron exposes no handle to the Windows task dialog), the explanation does not change when work starts or ends, Quit stops the application without re-inspecting, and Cancel changes nothing. Cancelling a quit that closing the welcome window started shows the welcome window again.
+The mandatory update confirmation includes the installation wait notice on Windows; macOS uses the shorter restart explanation.
+
The confirmation is skipped when the installer restart already confirmed task interruption, when the fatal-recovery dialog exits or restarts, when the development Restart App and Host command runs, and during operating-system shutdown, restart, or log-off: Windows sets this on the definitive session-end message; macOS sets it on the power-off notification, and because another application can still cancel that shutdown, the next focus or show of the main window clears it. Installer-owned quit cancels any pending ordinary quit decision; late inspection results and dialog answers cannot open another confirmation or repeat cleanup. A user-initiated update download that finishes while the window is hidden defers its Install and Restart confirmation until the window is shown again; the mandatory flow keeps its taskbar and Dock attention. The Windows installer and uninstaller tell a user whose application is still running to quit it from the system tray. Desktop does not enable scheduled tasks by default, so the scheduled-task explanations appear only once that feature is on; reminders fire only in loaded sessions, and unloaded sessions are neither counted nor resumed until they are opened.
The tray renderer enlarges the whale by 20% around the tile center while preserving the background and aspect ratio; application and installer icons retain their original proportions.
@@ -84,6 +88,8 @@ The `dsh-app://shell/` origin serves packaged update documents, scripts, and sty
The product UI retains Web actions, including "Open In..." through the shared authenticated HTTP routes. Desktop uses Web's automatic directory-picker selection and initializes new profiles with the shared Web template's bundles.
+Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version.
+
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
@@ -130,7 +136,7 @@ pnpm run start:desktop
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
-The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
+The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile as part of the repository Client typecheck. Their Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
### Startup onboarding
@@ -138,7 +144,7 @@ The API-key input starts empty and uses `autocomplete="new-password"` to ask Chr
Repeated launches and `dsh://open` keep the workspace hidden until the startup credential check or a welcome action permits entry. Entering from Welcome places keyboard focus on the document without selecting a sidebar control; Tab navigation remains available.
-Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the [API-key page](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626). Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
+Desktop checks configured model API-key references after the Host starts and before opening the workspace. With no configured key, the welcome window offers the API-key page. Save and continue writes through the existing credential service using the official DeepSeek provider's configured reference, then opens the workspace. Set up later opens the workspace without saving the draft or a completion setting; the next process launch checks credentials again. Back to sign in returns to the entry and clears the unsaved key and validation message. Buttons keep their labels and block competing actions while saving or opening the workspace. The Desktop preload marker suppresses the Web credential dialog, while retaining the Models settings page and the welcome notice.
The welcome window reads the shared `locale.preference` before it appears. An explicit English or Chinese choice wins; otherwise Desktop picks the first supported OS language and falls back to English. The main UI reads the same preference and OS language order through its isolated preload before mounting. Language changes in Settings update the shell’s current dictionary and menu; automatic selection writes no preference. The welcome window has no language selector.
@@ -146,7 +152,7 @@ The browser-login waiting page offers a copy-link action for the current pending
### Welcome window appearance
-The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 [entry layout](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334) and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
+The welcome window follows system appearance with the design’s Platform light/dark colors and shows the 600 × 700 entry layout and the API-key form, with native window controls, a draggable title area, a local brand SVG, system sans-serif fallbacks, and locally bundled Montserrat Light for non-button text. The window uses macOS menu vibrancy or Windows acrylic with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. The local React welcome entry bundles React and the shared `StateDot` loading indicator with its CSS; it uses the isolated preload without loading the main Web application. The entry, sign-in status and API-key pages share a fixed bottom action row; the back-to-sign-in link sits below it. Buttons share the platform motion timings, and Reduce Motion disables their transitions. The OS owns blur strength and outer corners. macOS Reduce Transparency suppresses translucency, and Increase Contrast forces that setting on. Save and continue writes to the development credential store; Set up later opens the real workspace without saving a key or completion flag. The generated project links the declared workspace dependency closure as well as pnpm’s hoisted packages, so unhoisted configured plugins remain resolvable. [The window note](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.md) owns the material and onboarding decisions.
## Package
diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md
index 62e34fad48..a64cbd2246 100644
--- a/apps/desktop/README.zh.md
+++ b/apps/desktop/README.zh.md
@@ -2,6 +2,8 @@
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。
+
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
@@ -28,6 +30,8 @@ Windows 在整个运行期间常驻托盘图标。悬停提示为产品名,单
所有普通退出入口——⌘Q、应用菜单、Dock 菜单、Windows 托盘和标题栏"应用程序"菜单,以及关闭强制更新窗口或欢迎窗口引起的退出——都先向 Host 查询退出会中断什么。Host 通过私有 IPC 通道回答两项事实:与更新重启检查同一口径的运行中任务(运行中的 agent,包括子代理和等待审批的回合、排队消息、运行中或停止中的后台任务),以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出,不弹框。否则弹出一个没有父窗口的原生消息框——隐藏的窗口保持隐藏——标题为**退出 DeepSeek Harness?**,正文为三种本地化说明之一:正在运行的任务将会中断、应用关闭期间定时任务不会运行,或两者兼有。"退出"是默认按钮,Esc 等同"取消";macOS 上"取消"在"退出"左侧,Windows 上"退出"在"取消"左侧,Windows 任务对话框显示应用图标且不跟随应用主题、始终为浅色。Host 尚未就绪或已失败时不可能有任务在跑,直接退出。查询失败或 Host 超过两秒截止时间未答复,按运行中任务处理。弹框打开期间,再次请求退出只会并入同一弹框而不叠加新弹框(macOS 上还会把它提到前面;Electron 不暴露 Windows 任务对话框的句柄);任务开始或结束不会改变文案;点"退出"不再重新查询即停止应用;点"取消"不发生任何变化。取消由关闭欢迎窗口引起的退出时,欢迎窗口会重新显示。
+强制更新的安装确认在 Windows 上额外显示等待提示,macOS 只显示简短的重启说明。
+
以下情况跳过确认:安装更新的重启已确认过任务中断、致命错误恢复对话框中的退出或重启、开发版"重启应用与 Host"命令,以及操作系统关机、重启或注销:Windows 在确定性的会话结束消息上设置该状态;macOS 在关机通知上设置,而其他应用仍可能取消这次关机,因此主窗口下一次获得焦点或显示时会清除它。安装器接管退出时会取消尚未结束的普通退出决策;晚到的查询结果和弹框答复不会再次打开确认框或重复清理。窗口隐藏期间完成的用户主动发起的更新下载,把"安装并重启"确认推迟到窗口再次显示时;强制更新流程沿用其任务栏和 Dock 提醒。Windows 安装程序和卸载程序在应用仍在运行时提示用户先在系统托盘中退出。Desktop 默认未开启定时任务,定时任务的说明只在该功能开启后出现;提醒只在已加载的会话中触发,未加载的会话既不计入,也要等到打开后才会继续。
托盘渲染器以底板中心为基准将鲸鱼放大 20%,保留背景和宽高比;应用和安装器图标保持原有比例。
@@ -86,6 +90,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
产品 UI 保留 Web 操作,包括通过共享认证 HTTP 路由执行的“打开方式…”。Desktop 使用 Web 的自动目录选择机制,并以共享 Web 模板的 bundle 列表初始化新 profile。
+升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。
+
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
@@ -132,7 +138,7 @@ pnpm run start:desktop
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
-[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
+[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)直接纳入仓库 Client 类型检查。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
### 启动引导
@@ -140,7 +146,7 @@ API Key 输入框初始为空,并通过 `autocomplete="new-password"` 请求 C
重复启动和 `dsh://open` 会保持工作区隐藏,直到启动凭据检查或欢迎页操作允许进入。从 Welcome 进入时,键盘焦点落在文档上,不选中侧边栏控件;Tab 导航仍可使用。
-Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 [API Key 页面](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2138-44626)。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
+Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否已配置。没有已配置的密钥时,欢迎窗口提供 API Key 页面。“保存并继续”通过现有凭证服务写入 DeepSeek 官方提供方配置的引用,然后打开工作区。“稍后配置”打开工作区,但不保存草稿或完成标记;下次进程启动时会重新检查凭证。“返回登录”回到入口并清空未保存的密钥和校验提示。保存或打开工作区期间,按钮保持原文案并禁用竞争操作。Desktop preload 标记使 Web 凭证弹窗不再显示,同时保留模型设置页和欢迎须知。
欢迎窗口在显示前读取共享的 `locale.preference`。用户明确选择的英文或中文优先;否则 Desktop 按系统语言顺序匹配支持的语言,并以英文兜底。主界面在挂载前通过隔离 preload 读取同一偏好和系统语言顺序。在设置中切换语言会更新桌面壳的当前词典和菜单;自动选择不会写入偏好。欢迎窗口不提供语言切换入口。
@@ -148,7 +154,7 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否
### 欢迎窗口外观
-欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的[入口布局](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Harness?node-id=2121-39334)和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
+欢迎窗口使用设计稿的 Platform light/dark 颜色跟随系统外观,展示 600 × 700 的入口布局和 API Key 表单,包含原生窗口控件、可拖动标题区域、本地品牌 SVG、系统无衬线字体回退,以及非按钮文字使用的本地 Montserrat Light 字体。窗口使用 macOS menu vibrancy 或 Windows acrylic,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。本地 React 欢迎入口将 React、公共 `StateDot` 加载指示器及其 CSS 一起打包;它通过隔离 preload 工作,不加载主 Web 应用。入口、登录状态和 API Key 页面共用固定的底部操作行;“返回登录”链接位于操作行下方。按钮共用平台的过渡时序,开启“减少动态效果”会禁用过渡。操作系统控制模糊强度和外部圆角。macOS 的“降低透明度”会抑制半透明效果,“增强对比度”会强制开启该设置。“保存并继续”写入开发环境的凭证存储;“稍后配置”打开真实工作区,不保存密钥或完成标记。生成的开发项目同时链接已声明的 workspace 依赖闭包和 pnpm 提升的包,因此未提升的配置插件仍能解析。[窗口记录](../../.agents/notes/implemented/architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责材质与引导决策。
## 打包
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 0695bb6ebd..81c8059d55 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -75,6 +75,7 @@
"vite": "^6.0.0",
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
- "@deepseek-ai/dsh-atomic-write": "workspace:*"
+ "@deepseek-ai/dsh-atomic-write": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
}
}
diff --git a/apps/desktop/renderer/mandatory-update.css b/apps/desktop/renderer/mandatory-update.css
index 89cf0cf0d5..591a82b6ab 100644
--- a/apps/desktop/renderer/mandatory-update.css
+++ b/apps/desktop/renderer/mandatory-update.css
@@ -9,4 +9,8 @@
#copy { min-height: 32px; padding: 3px 0; background: transparent; color: #4d6bfe; font-size: 12px; }
#copy-message { color: #d93025 !important; }
#manual-copy { display: block; margin-top: 8px; }
-#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; accent-color: #4d6bfe; }
+#progress { display: block; width: 100%; height: 4px; margin-top: 12px; border: 0; appearance: none; -webkit-appearance: none; background: #e6e7eb; accent-color: #0f1115; }
+
+#progress::-webkit-progress-bar { background: #e6e7eb; }
+#progress::-webkit-progress-value { background: #0f1115; }
+#progress::-moz-progress-bar { background: #0f1115; }
diff --git a/apps/desktop/src/account-backend.ts b/apps/desktop/src/account-backend.ts
index 889b4371bc..770d3e4e9d 100644
--- a/apps/desktop/src/account-backend.ts
+++ b/apps/desktop/src/account-backend.ts
@@ -67,9 +67,13 @@ export interface DesktopAccountBackend {
* @param listener - state recipient.
* @param failed - stream failure recipient.
* @param expired - live credential-expiry recipient.
+ * @param onAnalyticsEnabledChanged - optional Desktop collection-policy recipient; disconnected streams publish false.
* @returns stream disposer.
*/
- watch(listener: (state: AccountView) => void, failed: () => void, expired: () => void): () => void
+ watch(
+ listener: (state: AccountView) => void, failed: () => void, expired: () => void,
+ onAnalyticsEnabledChanged?: (enabled: boolean) => void,
+ ): () => void
}
/**
@@ -86,19 +90,21 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
state: () => call('getState'),
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
- watch(listener, failed, expired) {
+ watch(listener, failed, expired, onAnalyticsEnabledChanged) {
let closed = false
let socket: WebSocket | undefined
let retry: ReturnType | undefined
const connect = (): void => {
const streamId = randomUUID()
const expiryStreamId = randomUUID()
+ const analyticsPolicyStreamId = randomUUID()
void cookies().then((cookie) => {
if (closed) return
const url = new URL(REMOTE_STREAM_MUX_PATH, origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
socket = new WebSocket(url, { headers: { cookie, origin }, maxPayload: 65_536 })
socket.on('open', () => {
+ if (onAnalyticsEnabledChanged !== undefined) socket?.send(JSON.stringify({ type: 'open', streamId: analyticsPolicyStreamId, endpoint: 'productAnalytics/watchPolicy', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId: expiryStreamId, endpoint: 'account/watchExpiry', payload: { args: {} } }))
socket?.send(JSON.stringify({ type: 'open', streamId, endpoint: 'account/watch', payload: { args: {} } }))
})
@@ -106,6 +112,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
try {
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data)
const frame = parseRemoteStreamServerMessage(bytes.toString('utf8'))
+ if (frame.streamId === analyticsPolicyStreamId) {
+ if (frame.type === 'item' && typeof frame.value === 'boolean') onAnalyticsEnabledChanged?.(frame.value)
+ else onAnalyticsEnabledChanged?.(false)
+ return
+ }
if (frame.streamId === expiryStreamId && frame.type === 'item' && frame.value === 'session-expired') { expired(); return }
if (frame.streamId !== streamId) throw new Error('desktop account: unexpected stream')
if (frame.type === 'item') listener(accountView(frame.value))
@@ -113,11 +124,11 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
} catch { socket?.close() }
})
socket.on('error', () => { socket?.close() })
- socket.on('close', () => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
- }).catch(() => { if (!closed) { failed(); retry = setTimeout(connect, 1000) } })
+ socket.on('close', () => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
+ }).catch(() => { if (!closed) { onAnalyticsEnabledChanged?.(false); failed(); retry = setTimeout(connect, 1000) } })
}
connect()
- return () => { closed = true; clearTimeout(retry); socket?.close() }
+ return () => { closed = true; onAnalyticsEnabledChanged?.(false); clearTimeout(retry); socket?.close() }
},
}
}
diff --git a/apps/desktop/src/client/WelcomePage.tsx b/apps/desktop/src/client/WelcomePage.tsx
index 8fcde7768a..b367061732 100644
--- a/apps/desktop/src/client/WelcomePage.tsx
+++ b/apps/desktop/src/client/WelcomePage.tsx
@@ -19,6 +19,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
const [expiryNotice, setExpiryNotice] = useState(false)
const [page, setPage] = useState('entry')
const pageRef = useRef('entry')
+ const visiblePage = useRef('entry')
const [attempt, setAttempt] = useState(null)
const attemptRef = useRef(null)
const [starting, setStarting] = useState(false)
@@ -69,12 +70,14 @@ export function Welcome({ api }: { api: WelcomeApi }) {
}, [api, m.welcomeTitle])
useEffect(() => {
+ if (page === 'entry' && visiblePage.current !== 'entry') void api.analytics?.('auth_page_view', {})
+ visiblePage.current = page
if (page === 'key') input.current?.focus()
else if (page === 'entry' && focusEntry.current) {
focusEntry.current = false
keyButton.current?.focus()
}
- }, [page])
+ }, [page, api])
useEffect(() => {
if (copyState !== 'copied' && copyState !== 'failed') return
@@ -85,6 +88,7 @@ export function Welcome({ api }: { api: WelcomeApi }) {
async function saveKey(event: FormEvent) {
event.preventDefault()
if (busyRef.current) return
+ void api.analytics?.('api_key_save_click', {})
const value = draft.trim()
if (!/^[\x21-\x7e]+$/.test(value) || /^[A-Z][A-Z0-9_]*=[^=]/.test(value)
|| ((value.startsWith('"') || value.startsWith("'") || value.charCodeAt(0) === 96) && value.at(-1) === value[0])) {
@@ -198,15 +202,15 @@ export function Welcome({ api }: { api: WelcomeApi }) {
-
-
+
+
-
-
+
+
diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts
index b3f9e535c9..066418e6bb 100644
--- a/apps/desktop/src/locale.ts
+++ b/apps/desktop/src/locale.ts
@@ -77,17 +77,17 @@ export const en = {
updateCheckFailed: 'Could not check for updates. Please try again later.',
updateDownloadFailed: 'Could not download the update. Please try again.',
updateInstallFailed: 'Could not install the update. Please try again later.',
- updateCheckNetworkFailed: 'Could not check for updates. Please try again later. The connection was interrupted. Check your network and try again.',
- updateDownloadNetworkFailed: 'Could not download the update. Please try again. The connection was interrupted. Check your network and try again.',
- updateInstallNetworkFailed: 'Could not install the update. Please try again later. The connection was interrupted. Check your network and try again.',
+ updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.',
+ updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.',
+ updateInstallNetworkFailed: 'Could not install the update. Check your connection and try again.',
unknownError: 'Unknown error',
updateCheckTitle: 'Check for Updates',
- updateCurrent: 'No updates available. Current version: V{version}',
+ updateCurrentDetail: 'Current version: {version}',
+ updateCurrent: 'You’re up to date!',
updateChecking: 'Checking for updates…',
updateDownload: 'Download update',
- updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded',
- updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.',
- updateDownloadedTitleWindows: 'New version v{version} is ready',
+ updateDownloadedTitle: 'Version {version} is ready to install',
+ updateDownloadedDetail: 'The app will close during the update and reopen automatically when it is complete.',
updateDownloadedDetailWindows: 'The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.',
updateClose: 'Close',
updateAcknowledge: 'OK',
@@ -97,47 +97,47 @@ export const en = {
updateInstalling: 'Preparing to restart…',
updateRetry: 'Retry update',
updateActiveTasks: 'Tasks are still in progress',
- updateActiveTasksDetail: 'Restarting to update may interrupt these tasks. Continue updating?',
+ updateActiveTasksDetail: 'Updating will stop the tasks in progress and restart the app. Continue?',
updateStopTasks: 'Stop tasks and update',
- updateTasksChanged: 'New tasks started. Review the update confirmation again.',
+ updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.',
updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.',
- updateStopFailed: 'Tasks could not be stopped safely. The update was not installed. Please try again later.',
+ updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.',
updateTechnicalDetails: 'View technical details',
updateTitle: 'DeepSeek Harness Update',
- updateAvailable: 'An update is available',
- updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness will restart to complete the update.',
+ updateAvailable: 'New version available: {version}',
+ updateDetail: 'Once the download is complete, you can install the update and restart the app.',
installAndRestart: 'Install and Restart',
later: 'Later',
updateFailedTitle: 'Update Failed',
- mandatoryTitle: 'Update required',
- mandatoryDetail: 'This version is no longer supported. Update to continue. Existing tasks can keep running until you approve a restart.',
- mandatoryUnavailable: 'The update requirement could not be checked. Retry when the connection is available.',
+ mandatoryTitle: 'Update to continue',
+ mandatoryDetail: 'This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.',
+ mandatoryUnavailable: 'Could not check update requirements. Please try again later.',
policyLoginTitle: 'Sign in to the test environment',
- policyLoginRequired: 'This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.',
+ policyLoginRequired: 'Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.',
policyLogin: 'Sign in with Feishu',
- policyLoginFailed: 'Test environment sign-in did not complete. Please check your connection and try again.',
+ policyLoginFailed: 'Feishu sign-in did not complete. Please try again.',
policyLoginLoading: 'Loading sign-in page…',
- mandatoryNoRelease: 'No applicable update is available. Check again or contact support.',
+ mandatoryNoRelease: 'No compatible update was found. Check again or contact support.',
mandatoryRefresh: 'Check again',
- mandatoryPage: 'Open download page',
- mandatoryCopy: 'Copy download address',
- mandatoryPageFailed: 'The download page could not be opened. Copy the address below and open it in your browser.',
- mandatoryActionFailed: 'The update action failed. Retry; the update requirement remains active.',
+ mandatoryPage: 'Download from the official website',
+ mandatoryCopy: 'Copy download link',
+ mandatoryPageFailed: 'Could not open the official download page. Copy the link and open it in your browser.',
+ mandatoryActionFailed: 'The action failed. Please try again. You can use the app again after the update is complete.',
mandatoryReady: 'Update ready',
- mandatoryVersion: 'V{version}',
- mandatoryReadyDetail: 'Installing the update will restart the application.',
- mandatoryDeferred: 'Existing tasks can keep running. Update to continue using the application.',
- mandatoryContinue: 'Continue installing update',
+ mandatoryVersion: 'New version: {version}',
+ mandatoryReadyDetail: 'The app will close during the update and reopen automatically when it is complete.',
+ mandatoryDeferred: 'Tasks in progress will keep running. Complete the update before using the app again.',
+ mandatoryContinue: 'Continue update',
mandatoryInspecting: 'Checking tasks…',
- mandatoryStopping: 'Safely stopping tasks in the application.',
- mandatoryRestarting: 'The application will restart shortly. Please wait.',
+ mandatoryStopping: 'Stopping tasks…',
+ mandatoryRestarting: 'The app will restart shortly. Please wait.',
mandatoryDownloadFailed: 'The update files could not be downloaded or prepared. Please retry.',
- mandatoryInstallFailed: 'The update was not installed. Check tasks again and retry.',
- mandatoryOpenHelp: 'If the page did not open, you can',
- mandatoryReopen: 'Open download page again',
+ mandatoryInstallFailed: 'The update has not been installed. Check the tasks again and retry.',
+ mandatoryOpenHelp: 'Page didn’t open?',
+ mandatoryReopen: 'Open the official download page again',
mandatoryCopied: 'Link copied',
- mandatoryCopyFailed: 'Copy failed. Select and copy the address below manually.',
- mandatoryAddress: 'Download address',
+ mandatoryCopyFailed: 'Could not copy the link. Select and copy it below.',
+ mandatoryAddress: 'Download link',
mandatoryNotification: 'Return to the application to confirm installation and restart.',
} as const
@@ -221,17 +221,17 @@ export const zh = {
updateCheckFailed: '检查更新失败,请稍后重试。',
updateDownloadFailed: '下载更新失败,请重试。',
updateInstallFailed: '安装更新失败,请稍后重试。',
- updateCheckNetworkFailed: '检查更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
- updateDownloadNetworkFailed: '下载更新失败,请重试。网络连接异常,请检查网络后重试。',
- updateInstallNetworkFailed: '安装更新失败,请稍后重试。网络连接异常,请检查网络后重试。',
+ updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。',
+ updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。',
+ updateInstallNetworkFailed: '安装更新失败,请检查网络连接后重试。',
unknownError: '未知错误',
updateCheckTitle: '检查更新',
- updateCurrent: '当前暂无可用更新。当前版本:V{version}',
+ updateCurrentDetail: '当前版本:{version}',
+ updateCurrent: '已是最新版本',
updateChecking: '正在检查更新…',
updateDownload: '下载更新',
- updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成',
- updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。',
- updateDownloadedTitleWindows: '新版本 v{version} 已准备就绪',
+ updateDownloadedTitle: '新版本 {version} 已准备就绪',
+ updateDownloadedDetail: '更新期间应用将暂时关闭,完成后会自动打开。',
updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。',
updateClose: '关闭',
updateAcknowledge: '确定',
@@ -241,47 +241,47 @@ export const zh = {
updateInstalling: '正在准备重启…',
updateRetry: '重试更新',
updateActiveTasks: '仍有进行中的任务',
- updateActiveTasksDetail: '重启更新可能中断这些任务,是否要继续更新?',
+ updateActiveTasksDetail: '更新将停止进行中的任务并重启应用,是否继续?',
updateStopTasks: '停止任务并更新',
- updateTasksChanged: '有新任务开始,请重新确认更新。',
+ updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。',
updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。',
- updateStopFailed: '未能安全停止任务,更新未安装。请稍后重试。',
+ updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。',
updateTechnicalDetails: '查看技术详情',
updateTitle: 'DeepSeek Harness 更新',
- updateAvailable: '发现可用更新',
- updateDetail: 'DeepSeek Harness {version}\n\nDeepSeek Harness 将重启以完成更新。',
+ updateAvailable: '发现新版本 {version}',
+ updateDetail: '下载完成后,可安装并重启应用。',
installAndRestart: '安装并重启',
later: '稍后',
updateFailedTitle: '更新失败',
- mandatoryTitle: '需要更新',
- mandatoryDetail: '当前版本已停止支持,请更新后继续使用。在您确认重启之前,现有任务可以继续运行。',
- mandatoryUnavailable: '暂时无法检查更新要求,请在网络恢复后重试。',
+ mandatoryTitle: '请更新后继续使用',
+ mandatoryDetail: '当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。',
+ mandatoryUnavailable: '暂时无法检查更新要求,请稍后重试。',
policyLoginTitle: '登录测试环境',
- policyLoginRequired: '这是测试版应用,检查更新要求需要先通过飞书登录。登录不会下载或安装更新。',
+ policyLoginRequired: '此测试版需要先通过飞书登录,才能检查更新要求。登录不会下载或安装更新。',
policyLogin: '通过飞书登录',
- policyLoginFailed: '测试环境登录未完成,请检查网络后重试。',
+ policyLoginFailed: '飞书登录未完成,请重试。',
policyLoginLoading: '正在加载登录页面…',
- mandatoryNoRelease: '暂时没有可用的更新,请重新检查或联系支持人员。',
+ mandatoryNoRelease: '暂未找到适用的更新,请重新检查或联系支持人员。',
mandatoryRefresh: '重新检查',
mandatoryPage: '前往官网下载',
mandatoryCopy: '复制下载链接',
- mandatoryPageFailed: '无法打开浏览器,请复制下载链接后手动打开。',
- mandatoryActionFailed: '更新操作失败,请重试;应用仍需更新后才能继续使用。',
+ mandatoryPageFailed: '无法打开官网下载页面,请复制链接后在浏览器中打开。',
+ mandatoryActionFailed: '操作失败,请重试。完成更新后才能继续使用应用。',
mandatoryReady: '更新已准备就绪',
- mandatoryVersion: 'V{version}',
- mandatoryReadyDetail: '安装后将重新启动应用。',
- mandatoryDeferred: '现有任务可以继续运行。完成更新后才能继续操作应用。',
- mandatoryContinue: '继续安装更新',
+ mandatoryVersion: '新版本:{version}',
+ mandatoryReadyDetail: '更新期间应用将暂时关闭,完成后会自动打开。',
+ mandatoryDeferred: '进行中的任务会继续运行。请完成更新后再操作应用。',
+ mandatoryContinue: '继续更新',
mandatoryInspecting: '正在检查任务状态…',
- mandatoryStopping: '正在安全结束应用中的任务。',
+ mandatoryStopping: '正在停止任务…',
mandatoryRestarting: '应用即将重启,请稍候。',
mandatoryDownloadFailed: '更新文件下载或准备失败,请重试。',
mandatoryInstallFailed: '更新尚未安装,请重新检查任务后重试。',
- mandatoryOpenHelp: '若页面未打开,可',
- mandatoryReopen: '重新打开官网',
- mandatoryCopied: '已复制链接',
- mandatoryCopyFailed: '复制失败,请手动选择下方地址复制。',
- mandatoryAddress: '下载地址',
+ mandatoryOpenHelp: '页面未打开?',
+ mandatoryReopen: '重新前往官网下载',
+ mandatoryCopied: '链接已复制',
+ mandatoryCopyFailed: '复制失败,请手动选择并复制下方链接。',
+ mandatoryAddress: '下载链接',
mandatoryNotification: '返回应用确认安装并重启。',
} as const satisfies DesktopMessages
@@ -325,7 +325,7 @@ export function formatDesktopMessage(
/**
* Select localized copy for an ordinary downloaded-update confirmation.
* @param messages - Selected Desktop dictionary.
- * @param version - Prepared update version without a leading v.
+ * @param version - Prepared update version, including any prerelease suffix.
* @param platform - Operating system presenting the confirmation.
* @returns The versioned title and installation guidance.
*/
@@ -335,7 +335,7 @@ export function desktopUpdateReadyConfirmation(
platform: string,
): { message: string; detail: string } {
return {
- message: formatDesktopMessage(platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, { version }),
+ message: formatDesktopMessage(messages.updateDownloadedTitle, { version }),
detail: platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail,
}
}
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index a59a8a8b35..eb75980ad7 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1,3 +1,4 @@
+import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
@@ -44,7 +45,7 @@ import { DesktopUpdateSchedule, resolveDesktopUpdateScheduleConfig } from './upd
import { desktopUpdateErrorSummary, presentDesktopUpdate } from './update-presentation.ts'
import { desktopErrorState } from './startup-error.ts'
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopPolicyState } from './mandatory-update-policy.ts'
-import { desktopClientMetadata } from './client-metadata.ts'
+import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
@@ -376,6 +377,14 @@ async function main(): Promise {
const browserGuests = new DesktopBrowserGuests(() => hostUrl)
let injections: readonly unknown[] = []
let welcomeBackend: DesktopWelcomeBackend | undefined
+ let reportedLaunch = false
+ let analyticsEnabled = false
+ const track = async (eventName: K, attributes: ProductEventMap[K]): Promise => {
+ const event = { eventName, attributes, timestamp: Date.now() } as ProductEvent
+ try {
+ if (analyticsEnabled) await welcomeBackend?.report(event)
+ } catch (_error) { /* Analytics cannot interrupt native actions. */ }
+ }
let stopAccount: (() => void) | undefined
let openedAttempt: string | undefined
let returnedAttempt: string | undefined
@@ -408,7 +417,7 @@ async function main(): Promise {
const backend = new DesktopBackendController((onFailure) => {
const hostInspectPort = developmentHostInspectPort(development)
const host = new DesktopHostProcess(resources.node, resources.dsh, activeProject,
- hostInspectPort, process.env, onFailure,
+ hostInspectPort, { ...process.env, DSH_CLIENT_VERSION: desktopClientVersion() }, onFailure,
primaryRuntime,
resources, (next) => { platformView.setSession(next) })
return {
@@ -419,6 +428,8 @@ async function main(): Promise {
if (ready.injections === undefined) throw new Error('Desktop Host did not provide boot injections')
injections = ready.injections
welcomeBackend = await connectDesktopWelcome(ready.url, (input, init) => net.fetch(input, init), async () => (await session.defaultSession.cookies.get({ url: ready.url })).map(cookie => `${cookie.name}=${cookie.value}`).join('; '))
+ analyticsEnabled = await welcomeBackend.analyticsEnabled().catch(() => false)
+ if (!reportedLaunch) { reportedLaunch = true; void track('desktop_app_launch', {}) }
stopAccount?.()
const accountBackend = welcomeBackend.account
stopAccount = accountBackend.watch((state) => {
@@ -456,9 +467,11 @@ async function main(): Promise {
const state = await accountBackend.state()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) welcomeWindow.webContents.send(WELCOME_IPC.state, state)
}).catch(() => undefined)
- })
+ }, (enabled) => { analyticsEnabled = enabled })
},
stop: async () => {
+ analyticsEnabled = false
+ stopAccount?.()
try { await host.stop(requireCleanStop) }
catch (error) {
if (!requireCleanStop || !(error instanceof DesktopHostUncleanExitError)) throw error
@@ -567,6 +580,8 @@ async function main(): Promise {
const result = await updateDialog.show(parent, confirmation)
if (result.response !== 0 || isMandatory()) return false
}
+ // The update lock rejects new HTTP requests, including analytics intake.
+ await track('desktop_upgrade_install_restart_click', {})
if (backend.host !== host) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
try {
const stillActive = await host.updateTasks('lock')
@@ -591,11 +606,14 @@ async function main(): Promise {
}
return true
},
+ undefined, undefined, undefined,
+ (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
)
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
const downloadUpdate = async (version: string): Promise => {
+ void track('desktop_upgrade_click', {})
updateJournal?.action('download-requested')
const state = await updates.download(version)
if (state.phase !== 'ready' || quitting) return state
@@ -778,7 +796,8 @@ async function main(): Promise {
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
await ordinaryMessageBox({ type: 'info', title: locale.messages.updateCheckTitle,
- message: formatDesktopMessage(locale.messages.updateCurrent, { version: app.getVersion() }) })
+ message: locale.messages.updateCurrent,
+ detail: formatDesktopMessage(locale.messages.updateCurrentDetail, { version: app.getVersion() }) })
return
}
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
@@ -790,8 +809,9 @@ async function main(): Promise {
}
if (state.phase !== 'available' && !(state.phase === 'error' && state.failedOperation === 'download')) return
if (manual) {
- const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle, message: locale.messages.updateAvailable,
- detail: formatDesktopMessage(locale.messages.updateDetail, { version: state.version ?? '' }),
+ const result = await ordinaryMessageBox({ title: locale.messages.updateCheckTitle,
+ message: formatDesktopMessage(locale.messages.updateAvailable, { version: state.version ?? '' }),
+ detail: locale.messages.updateDetail,
buttons: [locale.messages.updateDownload], cancelId: 1 })
if (result.response !== 0) return
}
@@ -1083,12 +1103,15 @@ async function main(): Promise {
const showWelcome = (): Promise => {
if (quitting) return Promise.resolve()
if (welcomeWindow !== undefined && !welcomeWindow.isDestroyed()) {
+ if (!welcomeWindow.isVisible()) void track('auth_page_view', {})
welcomeWindow.show()
welcomeWindow.focus()
return Promise.resolve()
}
openingWelcome ??= (async () => {
welcomeWindow = await openWelcomeWindow(locale, {
+ analytics: track,
+ analyticsEnabled: () => Promise.resolve(analyticsEnabled),
takeNotice: () => {
const notice = pendingWelcomeNotice
pendingWelcomeNotice = undefined
diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts
index 724df4ff97..3713883600 100644
--- a/apps/desktop/src/mandatory-update-window.ts
+++ b/apps/desktop/src/mandatory-update-window.ts
@@ -243,7 +243,11 @@ export class DesktopMandatoryUpdateWindow {
}
private view(): MandatoryUpdateView {
- return { locale: this.options.locale, policy: this.options.policy(), update: this.options.update(),
+ const locale = process.platform === 'win32'
+ ? { ...this.options.locale, messages: { ...this.options.locale.messages,
+ mandatoryReadyDetail: this.options.locale.messages.updateDownloadedDetailWindows } }
+ : this.options.locale
+ return { locale, policy: this.options.policy(), update: this.options.update(),
deferred: this.deferred,
...(this.confirmation === undefined ? {}
: { confirmation: { version: this.confirmation.version, active: this.confirmation.active, revision: this.confirmation.revision } }),
diff --git a/apps/desktop/src/preload-welcome.ts b/apps/desktop/src/preload-welcome.ts
index e9ec1aa910..6748367e81 100644
--- a/apps/desktop/src/preload-welcome.ts
+++ b/apps/desktop/src/preload-welcome.ts
@@ -10,6 +10,10 @@ const locale = process.argv.find(argument => argument.startsWith(prefix))?.slice
if (locale === undefined) throw new Error('desktop welcome: missing window locale')
const api: WelcomeApi = {
...resolveDesktopLocale(locale),
+ analytics: async (eventName, attributes) => {
+ if (await api.analyticsEnabled()) await ipcRenderer.invoke(WELCOME_IPC.analytics, eventName, attributes)
+ },
+ analyticsEnabled: () => ipcRenderer.invoke(WELCOME_IPC.analyticsEnabled) as Promise,
takeNotice: () => ipcRenderer.invoke(WELCOME_IPC.takeNotice) as Promise,
startSignIn: () => ipcRenderer.invoke(WELCOME_IPC.start) as Promise,
cancelSignIn: (id: SignInAttemptId) => ipcRenderer.invoke(WELCOME_IPC.cancel, id) as Promise,
diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts
index c7bf91c384..8d6eb808e7 100644
--- a/apps/desktop/src/update-coordinator.ts
+++ b/apps/desktop/src/update-coordinator.ts
@@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator {
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
* @param enabled - Whether this process has a packaged update source.
* @param currentVersion - Actual installed application version.
+ * @param downloadResult - Once per completed download attempt, including platform preparation failures.
*/
constructor(
private readonly publish: (state: DesktopUpdateState) => DesktopUpdateState,
@@ -52,6 +53,7 @@ export class DesktopUpdateCoordinator {
private readonly updater: AppUpdater = autoUpdater,
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
private readonly currentVersion: () => string = () => app.getVersion(),
+ private readonly downloadResult?: (success: boolean, reason?: string) => void,
) {
if (updater === autoUpdater) {
// electron-updater omits this internal transport property from its public declarations.
@@ -108,9 +110,11 @@ export class DesktopUpdateCoordinator {
try {
await this.updater.downloadUpdate()
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
+ this.downloadResult?.(true)
return this.setState({ phase: 'ready', version })
} catch (error) {
this.downloaded = false
+ this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
return this.setState(this.failure(error, 'download'))
}
}).finally(() => { this.downloadOperation = undefined })
diff --git a/apps/desktop/src/welcome-api.ts b/apps/desktop/src/welcome-api.ts
index 015296776a..c693f385d6 100644
--- a/apps/desktop/src/welcome-api.ts
+++ b/apps/desktop/src/welcome-api.ts
@@ -1,11 +1,14 @@
/** Operations available to the isolated native welcome renderer. */
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
+import type { ProductEventMap } from '@deepseek-ai/dsh-client-product-analytics/types'
import type { DesktopLocale } from './locale.ts'
/** Private native welcome channels, installed only while its window exists. */
export const WELCOME_IPC = {
saveApiKey: 'dsh-welcome:save-api-key',
+ analytics: 'dsh-welcome:analytics',
+ analyticsEnabled: 'dsh-welcome:analytics-enabled',
skip: 'dsh-welcome:skip',
start: 'dsh-welcome:start',
cancel: 'dsh-welcome:cancel',
@@ -20,8 +23,14 @@ export type WelcomeSaveResult = { readonly ok: true } | { readonly ok: false }
/** One-time notification retained by the main process until Welcome receives it. */
export type WelcomeNotice = 'session-expired'
+type WelcomeEventName = 'auth_page_view' | 'auth_page_click' | 'api_key_save_click'
+
/** Host-owned operations used by the welcome window. */
export interface WelcomeOperations {
+ /** @param eventName - allowed welcome event. @param attributes - approved fields without credentials. */
+ analytics?(eventName: K, attributes: ProductEventMap[K]): Promise
+ /** @returns the Host's current effective collection policy. */
+ analyticsEnabled(): Promise
/** @returns the pending notification, clearing it before another renderer can receive it. */
takeNotice(): Promise
/** @returns account state after starting a login attempt. */
diff --git a/apps/desktop/src/welcome-backend.ts b/apps/desktop/src/welcome-backend.ts
index a0bb4e9440..54ab46d85e 100644
--- a/apps/desktop/src/welcome-backend.ts
+++ b/apps/desktop/src/welcome-backend.ts
@@ -1,3 +1,4 @@
+import type { ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
/** Native welcome operations using the shared Web authentication and RPC APIs. */
import { randomUUID } from 'node:crypto'
@@ -13,7 +14,11 @@ export interface WelcomeState {
/** Narrow operations available to the native welcome flow. */
export interface DesktopWelcomeBackend {
+ /** @returns the current Host policy; every read observes live configuration. */
+ analyticsEnabled(): Promise
readonly account: DesktopAccountBackend
+ /** @param event - desktop-owned fields. @returns after local Host intake. */
+ report(event: ProductEvent): Promise
/** @returns Configured-key presence and the shared language preference, without credential values. */
read(): Promise
/** @returns The saved UI language without account or provider requests. */
@@ -44,11 +49,14 @@ export async function connectDesktopWelcome(
const authenticated = await send(authenticatedUrl, { credentials: 'include' })
await authenticated.body?.cancel()
if (!authenticated.ok) throw new Error('desktop welcome: Web authentication failed')
- const invoke = async (request: { namespace: string; method: string; args: Record }): Promise => {
+ const invoke = async (
+ request: { namespace: string; method: string; args: Record },
+ signal?: AbortSignal,
+ ): Promise => {
const rpcId = randomUUID()
const method = `${request.namespace}/${request.method}`
const response = await send(new URL(`/api/${method}`, origin).href, {
- method: 'POST', credentials: 'include', redirect: 'error',
+ method: 'POST', credentials: 'include', redirect: 'error', ...(signal === undefined ? {} : { signal }),
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args: request.args } }),
})
@@ -115,6 +123,12 @@ export async function connectDesktopWelcome(
return {
account,
read,
+ async analyticsEnabled() {
+ const enabled = await invoke({ namespace: 'productAnalytics', method: 'enabled', args: {} }, AbortSignal.timeout(1000))
+ if (typeof enabled !== 'boolean') throw new Error('desktop analytics: invalid collection policy')
+ return enabled
+ },
+ async report(event) { await invoke({ namespace: 'productAnalytics', method: 'report', args: { event } }, AbortSignal.timeout(1000)) },
async readLocalePreference() {
const settings = await invoke({ namespace: 'settings', method: 'describe', args: {} })
if (!record(settings) || !Array.isArray(settings.namespaces)) throw new Error('desktop welcome: missing settings namespaces')
diff --git a/apps/desktop/src/welcome-window.ts b/apps/desktop/src/welcome-window.ts
index a32e1f1c56..61fda9e36a 100644
--- a/apps/desktop/src/welcome-window.ts
+++ b/apps/desktop/src/welcome-window.ts
@@ -57,14 +57,16 @@ let disposeActiveHandlers: (() => void) | undefined
* @returns the visible window; a failed load destroys it before rejecting.
*/
export async function openWelcomeWindow(locale: DesktopLocale, operations: WelcomeOperations): Promise {
- const window = new BrowserWindow(welcomeWindowOptions(process.platform, locale))
+ const options = welcomeWindowOptions(process.platform, locale)
+ const window = new BrowserWindow(options)
disposeActiveHandlers?.()
let active = true
const disposeHandlers = (): void => {
if (!active) return
active = false
for (const channel of [
- WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey, WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
+ WELCOME_IPC.analyticsEnabled, WELCOME_IPC.analytics, WELCOME_IPC.takeNotice, WELCOME_IPC.saveApiKey,
+ WELCOME_IPC.skip, WELCOME_IPC.start, WELCOME_IPC.cancel, WELCOME_IPC.copyLink,
]) {
ipcMain.removeHandler(channel)
}
@@ -76,6 +78,20 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
throw new Error('desktop welcome: rejected action from an unowned frame')
}
}
+ ipcMain.handle(WELCOME_IPC.analyticsEnabled, (event) => {
+ assertSender(event)
+ return operations.analyticsEnabled()
+ })
+ ipcMain.handle(WELCOME_IPC.analytics, async (event, eventName: unknown, attributes: unknown) => {
+ assertSender(event)
+ if (typeof attributes !== 'object' || attributes === null || Array.isArray(attributes)) throw new Error('desktop welcome: invalid analytics attributes')
+ if (eventName === 'auth_page_click' && 'button_name' in attributes && Object.keys(attributes).length === 1
+ && (attributes.button_name === 'sign_in' || attributes.button_name === 'api-key')) {
+ await operations.analytics?.(eventName, { button_name: attributes.button_name })
+ } else if ((eventName === 'auth_page_view' || eventName === 'api_key_save_click') && Object.keys(attributes).length === 0) {
+ await operations.analytics?.(eventName, {})
+ } else throw new Error('desktop welcome: invalid analytics event')
+ })
ipcMain.handle(WELCOME_IPC.takeNotice, async (event) => { assertSender(event); return operations.takeNotice() })
ipcMain.handle(WELCOME_IPC.saveApiKey, async (event, value: unknown) => {
assertSender(event)
@@ -108,6 +124,9 @@ export async function openWelcomeWindow(locale: DesktopLocale, operations: Welco
throw error
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Another window can replace ownership during loadFile.
- if (active && !window.isDestroyed()) window.show()
+ if (active && !window.isDestroyed()) {
+ window.show()
+ void operations.analytics?.('auth_page_view', {})
+ }
return window
}
diff --git a/apps/desktop/tests/account-backend.spec.ts b/apps/desktop/tests/account-backend.spec.ts
index 5dde64c7d0..b9cee7b61a 100644
--- a/apps/desktop/tests/account-backend.spec.ts
+++ b/apps/desktop/tests/account-backend.spec.ts
@@ -49,14 +49,17 @@ it('receives live expiry separately from account snapshots', async () => {
if (typeof address === 'string' || address === null) throw new Error('missing server address')
const connected = once(server, 'connection')
const expired = vi.fn()
+ const onAnalyticsEnabledChanged = vi.fn()
const snapshot = { status: 'signed-out', attempt: null,
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }
const received = Promise.withResolvers()
const backend = desktopAccountBackend(`http://127.0.0.1:${address.port}`, () => Promise.resolve(snapshot), () => Promise.resolve(''))
- const stop = backend.watch((value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired)
+ const stop = backend.watch(
+ (value) => { expect(value).toEqual(snapshot); received.resolve(undefined) }, vi.fn(), expired, onAnalyticsEnabledChanged,
+ )
onTestFinished(stop)
await connected
- // Both logical streams share the socket; observe their opening frames before sending data.
+ // Logical streams share the socket; observe their opening frames before sending data.
const streams = new Map()
const opened = Promise.withResolvers()
const socket = [...server.clients][0]!
@@ -64,11 +67,19 @@ it('receives live expiry separately from account snapshots', async () => {
if (!Buffer.isBuffer(data)) throw new Error('expected a Buffer WebSocket frame')
const frame = JSON.parse(data.toString('utf8')) as { endpoint: string; streamId: string }
streams.set(frame.endpoint, frame.streamId)
- if (streams.size === 2) opened.resolve(undefined)
+ if (streams.size === 3) opened.resolve(undefined)
})
await opened.promise
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watch'), value: snapshot }))
await received.promise
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: false }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: true }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(true) })
+ socket.send(JSON.stringify({ type: 'item', streamId: streams.get('productAnalytics/watchPolicy'), value: 'invalid policy' }))
+ await vi.waitFor(() => { expect(onAnalyticsEnabledChanged).toHaveBeenLastCalledWith(false) })
expect(expired).not.toHaveBeenCalled()
socket.send(JSON.stringify({ type: 'item', streamId: streams.get('account/watchExpiry'), value: 'session-expired' }))
await vi.waitFor(() => { expect(expired).toHaveBeenCalledOnce() })
diff --git a/apps/desktop/tests/expected/mandatory-update-zh.json b/apps/desktop/tests/expected/mandatory-update-zh.json
index fbe0356bcc..13a960e51e 100644
--- a/apps/desktop/tests/expected/mandatory-update-zh.json
+++ b/apps/desktop/tests/expected/mandatory-update-zh.json
@@ -1,8 +1,8 @@
{
"title": "仍有进行中的任务",
- "detail": "重启更新可能中断这些任务,是否要继续更新?",
+ "detail": "更新将停止进行中的任务并重启应用,是否继续?",
"status": "",
- "version": "V1.0.1-nightly.1",
+ "version": "新版本:1.0.1-nightly.1",
"buttons": [
"停止任务并更新",
"稍后更新"
diff --git a/apps/desktop/tests/expected/policy-login-en.json b/apps/desktop/tests/expected/policy-login-en.json
index 3415d20680..822c1a0f30 100644
--- a/apps/desktop/tests/expected/policy-login-en.json
+++ b/apps/desktop/tests/expected/policy-login-en.json
@@ -1,18 +1,18 @@
{
"returned": [
"Checking for updates…",
- "No updates available. Current version: V1.0.0",
- "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update."
+ "You’re up to date!",
+ "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update."
],
"cancelled": [
"Checking for updates…",
- "No updates available. Current version: V1.0.0",
- "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update."
+ "You’re up to date!",
+ "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update."
],
"failed": [
"Checking for updates…",
- "No updates available. Current version: V1.0.0",
- "This is a test build. Checking update requirements needs Feishu sign-in. Signing in does not download or install an update.",
- "Test environment sign-in did not complete. Please check your connection and try again."
+ "You’re up to date!",
+ "Sign in with Feishu to check update requirements for this test build. Signing in will not download or install an update.",
+ "Feishu sign-in did not complete. Please try again."
]
}
diff --git a/apps/desktop/tests/expected/update-guidance-en.json b/apps/desktop/tests/expected/update-guidance-en.json
new file mode 100644
index 0000000000..13becc60a1
--- /dev/null
+++ b/apps/desktop/tests/expected/update-guidance-en.json
@@ -0,0 +1,49 @@
+[
+ {
+ "title": "Update to continue",
+ "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Download update"
+ },
+ {
+ "title": "Update ready",
+ "detail": "The app will close during the update and reopen automatically when it is complete.",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Install and Restart"
+ },
+ {
+ "title": "Tasks are still in progress",
+ "detail": "Updating will stop the tasks in progress and restart the app. Continue?",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Stop tasks and update",
+ "later": "Update later"
+ },
+ {
+ "title": "Update ready",
+ "detail": "Tasks in progress will keep running. Complete the update before using the app again.",
+ "version": "New version: 0.1.7-alpha.2",
+ "update": "Continue update"
+ },
+ {
+ "title": "Preparing to restart…",
+ "detail": "Stopping tasks…",
+ "version": "New version: 0.1.7-alpha.2"
+ },
+ {
+ "title": "Update to continue",
+ "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
+ "version": "New version: 0.1.7-alpha.2",
+ "error": "The update has not been installed. Check the tasks again and retry.",
+ "update": "Retry update",
+ "page": "Download from the official website"
+ },
+ {
+ "title": "Update to continue",
+ "detail": "This version is no longer supported. Update to continue. Tasks in progress will keep running until you confirm installation and restart.",
+ "version": "",
+ "error": "No compatible update was found. Check again or contact support.",
+ "page": "Open the official download page again",
+ "browser-message": "Could not open the official download page. Copy the link and open it in your browser.",
+ "copy-message": "Could not copy the link. Select and copy it below."
+ }
+]
diff --git a/apps/desktop/tests/expected/update-guidance-zh-CN.json b/apps/desktop/tests/expected/update-guidance-zh-CN.json
new file mode 100644
index 0000000000..8ff226d70e
--- /dev/null
+++ b/apps/desktop/tests/expected/update-guidance-zh-CN.json
@@ -0,0 +1,49 @@
+[
+ {
+ "title": "请更新后继续使用",
+ "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "下载更新"
+ },
+ {
+ "title": "更新已准备就绪",
+ "detail": "更新期间应用将暂时关闭,完成后会自动打开。",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "安装并重启"
+ },
+ {
+ "title": "仍有进行中的任务",
+ "detail": "更新将停止进行中的任务并重启应用,是否继续?",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "停止任务并更新",
+ "later": "稍后更新"
+ },
+ {
+ "title": "更新已准备就绪",
+ "detail": "进行中的任务会继续运行。请完成更新后再操作应用。",
+ "version": "新版本:0.1.7-alpha.2",
+ "update": "继续更新"
+ },
+ {
+ "title": "正在准备重启…",
+ "detail": "正在停止任务…",
+ "version": "新版本:0.1.7-alpha.2"
+ },
+ {
+ "title": "请更新后继续使用",
+ "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
+ "version": "新版本:0.1.7-alpha.2",
+ "error": "更新尚未安装,请重新检查任务后重试。",
+ "update": "重试更新",
+ "page": "前往官网下载"
+ },
+ {
+ "title": "请更新后继续使用",
+ "detail": "当前版本已停止支持,请更新后继续使用。确认安装并重启前,进行中的任务会继续运行。",
+ "version": "",
+ "error": "暂未找到适用的更新,请重新检查或联系支持人员。",
+ "page": "重新前往官网下载",
+ "browser-message": "无法打开官网下载页面,请复制链接后在浏览器中打开。",
+ "copy-message": "复制失败,请手动选择并复制下方链接。"
+ }
+]
diff --git a/apps/desktop/tests/expected/update-restart-darwin-en-US.json b/apps/desktop/tests/expected/update-restart-darwin-en-US.json
index 599f39fa68..744475cb22 100644
--- a/apps/desktop/tests/expected/update-restart-darwin-en-US.json
+++ b/apps/desktop/tests/expected/update-restart-darwin-en-US.json
@@ -1,6 +1,6 @@
{
- "message": "DeepSeek Harness v0.1.99 downloaded",
- "detail": "The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.",
+ "message": "Version 0.1.7-alpha.2 is ready to install",
+ "detail": "The app will close during the update and reopen automatically when it is complete.",
"buttons": [
"Install and Restart"
]
diff --git a/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json
index d7ea2daa57..0bc196f79a 100644
--- a/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json
+++ b/apps/desktop/tests/expected/update-restart-darwin-zh-CN.json
@@ -1,6 +1,6 @@
{
- "message": "DeepSeek Harness v0.1.99 下载完成",
- "detail": "安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。",
+ "message": "新版本 0.1.7-alpha.2 已准备就绪",
+ "detail": "更新期间应用将暂时关闭,完成后会自动打开。",
"buttons": [
"安装并重启"
]
diff --git a/apps/desktop/tests/expected/update-restart-win32-en-US.json b/apps/desktop/tests/expected/update-restart-win32-en-US.json
index 0e8af77ca2..71a7f43253 100644
--- a/apps/desktop/tests/expected/update-restart-win32-en-US.json
+++ b/apps/desktop/tests/expected/update-restart-win32-en-US.json
@@ -1,5 +1,5 @@
{
- "message": "New version v0.1.99 is ready",
+ "message": "Version 0.1.7-alpha.2 is ready to install",
"detail": "The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.",
"buttons": [
"Install and Restart"
diff --git a/apps/desktop/tests/expected/update-restart-win32-zh-CN.json b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json
index 104f40b266..520dda5711 100644
--- a/apps/desktop/tests/expected/update-restart-win32-zh-CN.json
+++ b/apps/desktop/tests/expected/update-restart-win32-zh-CN.json
@@ -1,5 +1,5 @@
{
- "message": "新版本 v0.1.99 已准备就绪",
+ "message": "新版本 0.1.7-alpha.2 已准备就绪",
"detail": "更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。",
"buttons": [
"安装并重启"
diff --git a/apps/desktop/tests/fixtures/workspace-updates.mjs b/apps/desktop/tests/fixtures/workspace-updates.mjs
index b136c79ce0..b7f79c777b 100644
--- a/apps/desktop/tests/fixtures/workspace-updates.mjs
+++ b/apps/desktop/tests/fixtures/workspace-updates.mjs
@@ -218,7 +218,7 @@ async function qualify() {
const checking = await dialogWith(messages.updateChecking)
await screenshot(checking, 'checking.png')
server.release()
- const current = await dialogWith(messages.updateCurrent.replace('{version}', app.getVersion()))
+ const current = await dialogWith(messages.updateCurrent)
assert.equal(current, checking)
await clickText(current, messages.updateAcknowledge)
assert.equal(server.requests.filter(path => path === '/payload.exe').length, 0)
@@ -227,7 +227,7 @@ async function qualify() {
server.select('healthy', '0.1.6-nightly.1')
checkMenu.click()
- const available = await dialogWith(messages.updateAvailable)
+ const available = await dialogWith(messages.updateAvailable.replace('{version}', '0.1.6-nightly.1'))
server.select('corrupt', '0.1.6-nightly.1')
await clickText(available, messages.updateDownload)
await waitFor(() => fixture.coordinator.state.phase === 'error', 'checksum failure')
diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts
index e47edce02d..abce2c0bbc 100644
--- a/apps/desktop/tests/main-startup.spec.ts
+++ b/apps/desktop/tests/main-startup.spec.ts
@@ -172,6 +172,9 @@ const harness = await vi.hoisted(async () => {
}),
})
let accountListener: ((state: AccountView) => void) | undefined
+ let analyticsEnabled = true
+ let analyticsEnabledListener: ((enabled: boolean) => void) | undefined
+ const analytics = vi.fn(async (_event: unknown) => {})
const nativeTheme = { themeSource: 'system', shouldUseDarkColors: false }
const trays: FakeTray[] = []
class FakeTray extends EventEmitter {
@@ -184,14 +187,21 @@ const harness = await vi.hoisted(async () => {
const shellDialog = { isOpen: false, focus: vi.fn() }
return {
failWindow(error: Error) { windowFailure = error },
- windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, trays, FakeTray, backgroundNotice, shellDialog,
+ windows, hosts, handlers, app, FakeWindow, FakeHost, powerMonitor, nativeTheme, analytics,
+ trays, FakeTray, backgroundNotice, shellDialog,
menu, popup, socketHeaders: vi.fn(), updateCheck, updateDownload, updateInstall,
platformDispose,
platformCloseAndWait,
- watchAccount: (listener: (state: AccountView) => void) => {
+ get analyticsEnabled() { return analyticsEnabled },
+ set analyticsEnabled(value: boolean) { analyticsEnabled = value; analyticsEnabledListener?.(value) },
+ watchAccount: (
+ listener: (state: AccountView) => void, _failed: () => void, _expired: () => void,
+ onAnalyticsEnabledChanged?: (enabled: boolean) => void,
+ ) => {
+ analyticsEnabledListener = onAnalyticsEnabledChanged
accountListener = listener
- return () => { accountListener = undefined }
+ return () => { accountListener = undefined; analyticsEnabledListener = undefined }
},
publishAccount(state: AccountView) { accountListener?.(state) },
ipcOn: vi.fn<(channel: string, listener: (event: { sender: unknown; senderFrame: unknown }, ...args: unknown[]) => void) => void>(),
@@ -343,6 +353,8 @@ vi.mock('../src/platform-view.ts', async importOriginal => ({
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
+ analyticsEnabled: async () => harness.analyticsEnabled,
+ report: harness.analytics,
readLocalePreference: async () => null,
read: async (): Promise => (await harness.hosts.at(-1)!.fetch()).json() as Promise,
save: async () => ({ ok: true }),
@@ -396,6 +408,7 @@ beforeEach(() => {
vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined)
vi.stubEnv('DSH_DESKTOP_UPDATE_JOURNAL_DIR', undefined)
vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
+ harness.analyticsEnabled = true
})
afterEach(async () => {
@@ -543,7 +556,7 @@ describe('desktop main startup', () => {
expect(testAuth.login).toHaveBeenCalledOnce()
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
expect(messages.filter(message => message === en.policyLoginRequired)).toHaveLength(1)
- expect(messages).toContain('No updates available. Current version: V1.0.0')
+ expect(messages).toContain('You’re up to date!')
} finally {
explanation.resolve({ response: 1 })
login.resolve('cancelled')
@@ -1062,7 +1075,7 @@ describe('desktop main startup', () => {
vi.stubGlobal('process', { ...process, platform })
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([language])
await readyForUpdate()
- harness.updateState = { phase: 'ready', version: '0.1.99' }
+ harness.updateState = { phase: 'ready', version: '0.1.7-alpha.2' }
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 })
await expect(harness.prepareUpdate()).resolves.toBe(false)
const { message, detail, buttons } = harness.dialog.showMessageBox.mock.lastCall![0] as MessageBoxOptions
@@ -1441,7 +1454,7 @@ describe('desktop main startup', () => {
await prompt
expect(signal.aborted).toBe(true)
expect(harness.dialog.showMessageBox).toHaveBeenLastCalledWith(expect.objectContaining({
- message: 'No updates available. Current version: V1.0.0',
+ message: 'You’re up to date!', detail: 'Current version: 1.0.0',
}))
})
@@ -1506,7 +1519,7 @@ describe('desktop main startup', () => {
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => policy.promise))
harness.updateCheck.mockResolvedValue({ phase: 'available', version: '1.0.1-nightly.1' })
harness.dialog.showMessageBox.mockImplementation(({ signal, message }: { signal?: AbortSignal; message: string }) => {
- if (message !== en.updateAvailable || signal === undefined) return Promise.resolve({ response: 1 })
+ if (message !== en.updateAvailable.replace('{version}', '1.0.1-nightly.1') || signal === undefined) return Promise.resolve({ response: 1 })
available.resolve(signal)
return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve({ response: 0 }) }, { once: true }) })
})
@@ -1543,7 +1556,7 @@ describe('desktop main startup', () => {
const ordinaryShown = Promise.withResolvers()
const policyShown = Promise.withResolvers()
harness.dialog.showMessageBox.mockImplementation(({ message }: { message: string }) => {
- if (message.startsWith('No updates available.')) {
+ if (message === en.updateCurrent) {
ordinaryShown.resolve(undefined)
return ordinaryResult.promise
}
@@ -1638,6 +1651,23 @@ describe('desktop main startup', () => {
await expect(preparing).resolves.toBe(true)
})
+ it.each(['accepted', 'failed'] as const)('settles analytics intake before locking API admission and continues after intake failure: %s', async (outcome) => {
+ const host = await readyForUpdate()
+ await vi.waitFor(() => { expect(harness.analytics).toHaveBeenCalledWith(expect.objectContaining({ eventName: 'desktop_app_launch' })) })
+ const intake = Promise.withResolvers()
+ harness.analytics.mockImplementationOnce(() => intake.promise)
+ harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
+ const preparing = harness.prepareUpdate()
+ await vi.waitFor(() => { expect(harness.analytics).toHaveBeenLastCalledWith(expect.objectContaining({ eventName: 'desktop_upgrade_install_restart_click' })) })
+ expect(host.updateTasks.mock.calls).toEqual([['inspect']])
+ expect(host.stop).not.toHaveBeenCalled()
+ if (outcome === 'accepted') intake.resolve(undefined)
+ else intake.reject(new Error('local analytics intake timed out'))
+ await host.stopping.promise
+ host.exited.resolve()
+ await expect(preparing).resolves.toBe(true)
+ })
+
it('reports a Platform storage cleanup failure as preparation failure without stopping the Host', async () => {
const host = await readyForUpdate()
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
@@ -2034,7 +2064,9 @@ describe('desktop main startup', () => {
primaryRuntime: join('desktop-test-resources', 'runtime', 'primary-runtime'),
profile: 'desktop-test-profile',
})
- expect(harness.hosts[0]!.environment).toBe(process.env)
+ expect(harness.hosts[0]!.environment).not.toBe(process.env)
+ expect(harness.hosts[0]!.environment?.DSH_CLIENT_VERSION).toBe('1.2.3')
+ expect(harness.analytics).toHaveBeenCalledExactlyOnceWith({ eventName: 'desktop_app_launch', timestamp: Date.now(), attributes: {} })
expect(harness.hosts[0]!.start).toHaveBeenCalledTimes(1)
expect(harness.windows).toHaveLength(1)
expect(window.urls).toEqual(['dsh-app://app/'])
@@ -2159,3 +2191,15 @@ it.each([['light', false], ['dark', true]] as const)('opens Platform authorizati
harness.publishAccount(state)
expect(harness.openExternal).toHaveBeenCalledExactlyOnceWith(`https://platform.deepseek.com/dsh/authorize?state=state-1&theme=${theme}`)
})
+
+it('disables native product events for a disabled Desktop launch', async () => {
+ harness.analyticsEnabled = false
+ await import('../src/main.ts')
+ await harness.preparing.promise
+ harness.prepared.resolve()
+ await harness.hostStarted.promise
+ harness.hosts[0]!.ready.resolve()
+ await harness.navigated.promise
+ expect(harness.analytics).not.toHaveBeenCalled()
+ harness.analyticsEnabled = true
+})
diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts
index 484d01065f..901205bdb6 100644
--- a/apps/desktop/tests/mandatory-update-window.spec.ts
+++ b/apps/desktop/tests/mandatory-update-window.spec.ts
@@ -205,3 +205,10 @@ it('rebinds policy updates and reloads to a replacement Windows main window', ()
sender: window.webContents, senderFrame: window.webContents.mainFrame,
})).not.toThrow()
})
+
+it.each(['darwin', 'win32'] as const)('includes the installation wait notice only on Windows: %s', (platform) => {
+ const f = setup(platform)
+ expect(f.view().locale.messages.mandatoryReadyDetail).toBe(platform === 'win32'
+ ? '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。'
+ : '更新期间应用将暂时关闭,完成后会自动打开。')
+})
diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts
index d600b5eecb..790ad63fcf 100644
--- a/apps/desktop/tests/update-coordinator.spec.ts
+++ b/apps/desktop/tests/update-coordinator.spec.ts
@@ -61,14 +61,15 @@ function fixture() {
})
const quitAndInstall = vi.fn()
const beforeRestart = vi.fn(async () => true)
+ const downloadResult = vi.fn()
const states: DesktopUpdateState[] = []
const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater
const coordinator = new DesktopUpdateCoordinator(
(state) => { states.push(state); return state },
- beforeRestart, updater, () => true, () => '1.1.0-alpha.1',
+ beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult,
)
coordinators.push(coordinator)
- return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart }
+ return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult }
}
describe('desktop update coordinator', () => {
@@ -222,3 +223,22 @@ describe('desktop update coordinator', () => {
expect(f.events.listenerCount('download-progress')).toBe(0)
})
})
+
+
+it('reports one download result when callers share an operation and none for cached readiness', async () => {
+ const f = fixture()
+ await f.coordinator.check()
+ await Promise.all([f.coordinator.download('1.1.0-rc.2'), f.coordinator.download('1.1.0-rc.2')])
+ await f.coordinator.download('1.1.0-rc.2')
+ expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(true)
+})
+
+
+it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), new Error('private URL')])('reports safe download failure classification: %s', async (error) => {
+ const f = fixture()
+ await f.coordinator.check()
+ f.downloadUpdate.mockRejectedValueOnce(error)
+ await f.coordinator.download('1.1.0-rc.2')
+ expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed')
+ expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private')
+})
diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts
index ad92f2df8f..54bc5d5e2e 100644
--- a/apps/desktop/tests/update-error-renderer.spec.ts
+++ b/apps/desktop/tests/update-error-renderer.spec.ts
@@ -95,7 +95,7 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori
expect([p.element('error').textContent, p.element('technical-details-label').textContent,
p.element('update').textContent]).toMatchInlineSnapshot(`
[
- "未能安全停止任务,更新未安装。请稍后重试。",
+ "未能安全停止任务,更新尚未安装,请稍后重试。",
"查看技术详情",
"重试更新",
]
@@ -182,7 +182,7 @@ it('uses the same modal for download, verification, inspected confirmation and t
p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'preparing' })
expect(p.element('detail').textContent).toBe('应用即将重启,请稍候。')
p.publish({ ...p.initial, update: { phase: 'installing' }, restart: 'stopping-tasks' })
- expect(p.element('detail').textContent).toBe('正在安全结束应用中的任务。')
+ expect(p.element('detail').textContent).toBe('正在停止任务…')
expect(p.document.querySelector('main')).toBe(modal)
})
@@ -191,7 +191,7 @@ it('reveals the complete selectable address only after copy failure, without rep
await expect.poll(() => p.element('error').textContent).toBe('更新文件下载或准备失败,请重试。')
const originalError = p.element('error').textContent
p.publish({ ...p.initial, navigation: { page: 'requested' } })
- expect(p.element('browser-message').textContent).toBe('若页面未打开,可')
+ expect(p.element('browser-message').textContent).toBe('页面未打开?')
expect(p.element('manual-copy').hidden).toBe(true)
p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'failed' } })
expect(p.element('manual-copy').hidden).toBe(false)
@@ -199,11 +199,11 @@ it('reveals the complete selectable address only after copy failure, without rep
expect((p.element('address') as HTMLTextAreaElement).readOnly).toBe(true)
expect(p.element('error').textContent).toBe(originalError)
expect(p.element('technical-details-content').textContent).toBe('HASH_MISMATCH')
- expect(p.element('copy-message').textContent).toBe('复制失败,请手动选择下方地址复制。')
+ expect(p.element('copy-message').textContent).toBe('复制失败,请手动选择并复制下方链接。')
p.publish({ ...p.initial, navigation: { page: 'failed', copy: 'copied' } })
expect(p.element('manual-copy').hidden).toBe(true)
expect((p.element('address') as HTMLTextAreaElement).value).toBe('')
- expect(p.element('copy').textContent).toBe('已复制链接')
+ expect(p.element('copy').textContent).toBe('链接已复制')
expect(p.action).not.toHaveBeenCalled()
})
@@ -215,3 +215,31 @@ it('renders server markup literally in a dedicated safety case', async () => {
expect(p.element('title').childElementCount).toBe(0)
expect(p.element('detail').childElementCount).toBe(0)
})
+
+it.each(['en', 'zh-CN'])('records mandatory update guidance and actions across its states: %s', async (language) => {
+ const p = page('mandatory-update')
+ const locale = resolveDesktopLocale(language)
+ const base: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false,
+ page: 'https://example.invalid/download' }, update: { phase: 'available', version: '0.1.7-alpha.2' } }
+ let publish!: (view: MandatoryUpdateView) => void
+ const api: MandatoryUpdateApi = { status: async () => base, action: async () => {},
+ subscribe: (listener) => { publish = listener; return () => {} } }
+ Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: api })
+ p.run()
+ await expect.poll(() => p.element('title').textContent).toBe(locale.messages.mandatoryTitle)
+ const states: MandatoryUpdateView[] = [base,
+ { ...base, update: { ...base.update, phase: 'ready' }, confirmation: { active: false, version: '0.1.7-alpha.2', revision: 1 } },
+ { ...base, update: { ...base.update, phase: 'ready' }, confirmation: { active: true, version: '0.1.7-alpha.2', revision: 1 } },
+ { ...base, update: { ...base.update, phase: 'ready' }, deferred: true },
+ { ...base, update: { ...base.update, phase: 'installing' }, restart: 'stopping-tasks' },
+ { ...base, update: { ...base.update, phase: 'error', failedOperation: 'install' } },
+ { ...base, update: { phase: 'idle' }, navigation: { page: 'failed', copy: 'failed' } },
+ ]
+ const snapshots = states.map((state) => {
+ publish(state)
+ return Object.fromEntries(['title', 'detail', 'version', 'error', 'update', 'later', 'page', 'browser-message', 'copy-message']
+ .filter(id => !p.element(id).hidden && !p.element(id).closest('[hidden]'))
+ .map(id => [id, p.element(id).textContent]))
+ })
+ await expect(JSON.stringify(snapshots, null, 2) + '\n').toMatchFileSnapshot(`./expected/update-guidance-${language}.json`)
+})
diff --git a/apps/desktop/tests/welcome-host.spec.ts b/apps/desktop/tests/welcome-host.spec.ts
index 3c2066ae34..dea2905583 100644
--- a/apps/desktop/tests/welcome-host.spec.ts
+++ b/apps/desktop/tests/welcome-host.spec.ts
@@ -20,6 +20,8 @@ function transport(preference?: string) {
if (method === 'account/getState') value = { links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }
else if (method === 'settings/describe') value = { namespaces }
else if (method === 'llm/listConfigurableProviders') value = [{ settingsNs: 'llm-pi-ai', settingsPath: ['profiles', 'example'] }]
+ else if (method === 'productAnalytics/enabled') value = true
+ else if (method === 'productAnalytics/report') value = undefined
else if (method === 'credentials/set') keys.set(payload.args.ref, payload.args.value)
else value = Object.fromEntries(payload.args.refs.map(ref => [ref, { configured: keys.has(ref), writable: true }]))
return Response.json({ type: 'server-response', rpcId, result: { ok: true, value } })
@@ -34,6 +36,7 @@ describe('desktop welcome Web operations', () => {
const host = transport()
const backend = await connectDesktopWelcome(url, host.send)
expect(host.send).toHaveBeenCalledExactlyOnceWith(url, { credentials: 'include' })
+ expect(await backend.analyticsEnabled()).toBe(true)
expect(await backend.save('sk-example')).toEqual({ ok: true })
expect(host.keys.get('CUSTOM_DEEPSEEK_KEY')).toBe('sk-example')
expect(await backend.read()).toEqual({ loggedIn: false, hasApiKey: true, writable: true, localePreference: null })
@@ -90,6 +93,19 @@ describe('desktop welcome Web operations', () => {
await expect(backend.read()).rejects.toThrow('Web RPC failed')
})
+
+ it('submits native analytics through authenticated RPC with a bounded request', async () => {
+ const host = transport()
+ const backend = await connectDesktopWelcome(url, host.send)
+ const event = { eventName: 'desktop_app_launch' as const, timestamp: 100, attributes: {} }
+ await backend.report(event)
+ const [input, init] = host.send.mock.calls.at(-1)!
+ expect(input).toBe('http://127.0.0.1:19387/api/productAnalytics/report')
+ expect(init?.credentials).toBe('include')
+ expect(init?.signal).toBeInstanceOf(AbortSignal)
+ expect(JSON.parse(init!.body as string)).toMatchObject({ payload: { args: { event } } })
+ })
+
it('refuses an unauthenticated Web launch', async () => {
const send = vi.fn[1]>(async () => new Response(null, { status: 401 }))
await expect(connectDesktopWelcome(url, send)).rejects.toThrow('Web authentication failed')
diff --git a/apps/desktop/tests/welcome-renderer.client.spec.tsx b/apps/desktop/tests/welcome-renderer.client.spec.tsx
index ee0f06fb09..575c7423a0 100644
--- a/apps/desktop/tests/welcome-renderer.client.spec.tsx
+++ b/apps/desktop/tests/welcome-renderer.client.spec.tsx
@@ -16,6 +16,8 @@ function mount(language = 'zh-CN', takeNotice = vi.fn<() => Promise {}),
+ analyticsEnabled: async () => true,
onAccountState: vi.fn((_listener: (state: AccountView) => void) => stopAccount),
startSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
cancelSignIn: vi.fn(async (): Promise => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null })),
@@ -326,3 +328,16 @@ it.each(['zh-CN', 'en'])('returns from completed sign-in to the initial page aft
expect(view.document.querySelector('#auth-page')!.hidden).toBe(true)
await expect(view.copy()).toMatchFileSnapshot(`./expected/welcome/${language}.expected.txt`)
})
+
+it('reports each return to the welcome entry once, including account cancellation', async () => {
+ const view = mount()
+ expect(view.api.analytics).not.toHaveBeenCalled()
+ fireEvent.click(view.button('#api-key'))
+ fireEvent.click(view.button('#back-to-login'))
+ expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view'])
+ const pending = Promise.withResolvers()
+ view.api.startSignIn.mockReturnValueOnce(pending.promise)
+ fireEvent.click(view.button('#sign-in'))
+ await act(async () => { pending.resolve({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out', attempt: null }) })
+ expect(view.api.analytics.mock.calls.map(([action]) => action)).toEqual(['auth_page_click', 'auth_page_view', 'auth_page_click', 'auth_page_view'])
+})
diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts
index 7518cc74af..86a3da51e4 100644
--- a/apps/desktop/tests/welcome-startup.spec.ts
+++ b/apps/desktop/tests/welcome-startup.spec.ts
@@ -119,6 +119,7 @@ vi.mock('../src/host-process.ts', () => ({
}))
vi.mock('../src/welcome-backend.ts', () => ({
connectDesktopWelcome: async () => ({
+ analyticsEnabled: async () => false,
readLocalePreference: async () => state.preference,
read: async () => {
await state.beforeRead()
@@ -175,6 +176,7 @@ it.each([false, true])('starts welcome onboarding without carrying update focus
state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } })
if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] })
vi.useFakeTimers()
+ vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3')
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm')
diff --git a/apps/desktop/tests/welcome-window.spec.ts b/apps/desktop/tests/welcome-window.spec.ts
index 5ab4a1c031..6d2807c425 100644
--- a/apps/desktop/tests/welcome-window.spec.ts
+++ b/apps/desktop/tests/welcome-window.spec.ts
@@ -6,13 +6,13 @@ import { needsWelcome, WELCOME_IPC } from '../src/welcome-api.ts'
const electron = vi.hoisted(() => ({
create: vi.fn<(options: unknown) => ReturnType>(),
root: '/desktop-app',
- handlers: new Map Promise>(),
+ handlers: new Map Promise>(),
}))
vi.mock('electron', () => ({
app: { getAppPath: () => electron.root },
BrowserWindow: vi.fn(function (options: unknown) { return electron.create(options) }),
ipcMain: {
- handle: (name: string, handler: (event: unknown, value?: unknown) => Promise) => {
+ handle: (name: string, handler: (event: unknown, value?: unknown, attributes?: unknown) => Promise) => {
if (electron.handlers.has(name)) throw new Error(`duplicate IPC handler: ${name}`)
electron.handlers.set(name, handler)
},
@@ -40,6 +40,7 @@ function createWindow() {
beforeEach(() => { electron.create.mockReset(); electron.handlers.clear() })
const operations = {
+ analyticsEnabled: async () => true,
takeNotice: async () => undefined,
startSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
cancelSignIn: async () => ({ links: { usageUrl: 'http://localhost/usage', topUpUrl: 'http://localhost/top_up' }, status: 'signed-out' as const, attempt: null }),
@@ -186,3 +187,24 @@ describe('desktop welcome window', () => {
expect(needsWelcome({ loggedIn: true, hasApiKey: true })).toBe(false)
})
})
+
+it('accepts only permitted event fields and reads the current policy over IPC', async () => {
+ const window = createWindow()
+ electron.create.mockReturnValue(window)
+ const analytics = vi.fn(async () => {})
+ let enabled = true
+ await openWelcomeWindow(resolveDesktopLocale('en'), { ...operations, analytics, analyticsEnabled: async () => enabled })
+ const own = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
+ const policy = electron.handlers.get(WELCOME_IPC.analyticsEnabled)!
+ expect(await policy(own)).toBe(true)
+ enabled = false
+ expect(await policy(own)).toBe(false)
+ const report = electron.handlers.get(WELCOME_IPC.analytics)!
+ await report(own, 'auth_page_click', { button_name: 'api-key' })
+ expect(analytics).toHaveBeenLastCalledWith('auth_page_click', { button_name: 'api-key' })
+ await expect(report(own, 'auth_page_click', { button_name: 'api-key', token: 'private' })).rejects.toThrow('invalid analytics')
+ await expect(report(own, 'desktop_app_launch', {})).rejects.toThrow('invalid analytics')
+ await expect(report(own, 'auth_page_view', null)).rejects.toThrow('invalid analytics')
+ await report(own, 'api_key_save_click', {})
+ expect(analytics).toHaveBeenLastCalledWith('api_key_save_click', {})
+})
diff --git a/apps/desktop/tsconfig.client.json b/apps/desktop/tsconfig.client.json
index 9af87aed6b..3de33f06dd 100644
--- a/apps/desktop/tsconfig.client.json
+++ b/apps/desktop/tsconfig.client.json
@@ -15,6 +15,9 @@
},
{
"path": "../../packages/credentials/deepseek-account"
+ },
+ {
+ "path": "../../packages/client/product-analytics/tsconfig.client.json"
}
]
}
diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json
index 370ee0a448..982d7835ee 100644
--- a/apps/desktop/tsconfig.host.json
+++ b/apps/desktop/tsconfig.host.json
@@ -28,6 +28,9 @@
},
{
"path": "../../packages/util/atomic-write"
+ },
+ {
+ "path": "../../packages/client/product-analytics/tsconfig.host.json"
}
],
"exclude": [
diff --git a/apps/web/package.json b/apps/web/package.json
index d161811059..87654ff312 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-frontend",
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml
index 2148e033fb..1c111b2040 100644
--- a/apps/web/tests/README.i18n.yaml
+++ b/apps/web/tests/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 3e8ecd3509fa944f
zh: 3fd3ff9fcd1e1e90
/apps-web-browser-e2e/completion-observations:
- en: 47f5168d5f93be53
- zh: 79fc78415e76950c
+ en: 1a93c8d1a0e07ad8
+ zh: e7c252f54f19c137
/apps-web-browser-e2e/these-are-host-face-tests:
en: 8c406d423c8e2137
zh: a66a144fd146f56c
diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md
index 14628c3006..98d1ced569 100644
--- a/apps/web/tests/README.md
+++ b/apps/web/tests/README.md
@@ -16,7 +16,7 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m
## Completion observations
-State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
+State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md).
Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling.
diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md
index 6dd369f5bd..e804246f3d 100644
--- a/apps/web/tests/README.zh.md
+++ b/apps/web/tests/README.zh.md
@@ -16,7 +16,7 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
## 完成状态观察
-依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
+依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。
显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。
diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts
index cfa4e81252..48f1722d7c 100644
--- a/apps/web/tests/agent-preset-selection.e2e.ts
+++ b/apps/web/tests/agent-preset-selection.e2e.ts
@@ -354,7 +354,7 @@ describe('web e2e: agent-preset selection', () => {
await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
await dialog.getByRole('button', { name: 'General', exact: true }).click()
- const developerTools = dialog.getByRole('switch', { name: 'Developer tools' })
+ const developerTools = dialog.getByRole('switch', { name: 'Coding Tools' })
await developerTools.click()
await expect.poll(() => developerTools.getAttribute('aria-checked')).toBe('false')
await dialog.getByRole('button', { name: 'Close' }).last().click()
@@ -366,7 +366,7 @@ describe('web e2e: agent-preset selection', () => {
await openSettings(page, 'en')
const reopened = page.getByRole('dialog', { name: 'Settings' })
await reopened.getByRole('button', { name: 'General', exact: true }).click()
- const reopenedDeveloperTools = reopened.getByRole('switch', { name: 'Developer tools' })
+ const reopenedDeveloperTools = reopened.getByRole('switch', { name: 'Coding Tools' })
await reopenedDeveloperTools.click()
await expect.poll(() => reopenedDeveloperTools.getAttribute('aria-checked')).toBe('true')
await reopened.getByRole('button', { name: 'Agent presets' }).click()
diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts
index 7569106fc6..e8d0141ba1 100644
--- a/apps/web/tests/assembled-remote.ts
+++ b/apps/web/tests/assembled-remote.ts
@@ -223,8 +223,10 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
return ok(undefined)
})
mock.unary('session/list', () => ok({ items: structuredClone(sessions) }))
- // The shipped Web composition mounts the Schedule client, whose catalog and
- // per-Session list read empty here: these scenarios drive no reminder.
+ // The shipped bundle layers this graph composes carry the `ui-schedule` row
+ // disabled, so the Schedule client does not mount and no case here reads the
+ // catalog or the per-Session list. Both stay declared so an enabled
+ // composition reads empty lists instead of a missing-rule failure.
mock.unary('schedule/catalog', () => ok([]))
mock.unary('schedule/list', () => ok([]))
mock.unary('session/projections', (request: unknown) => {
diff --git a/apps/web/tests/auto-review-approval.e2e.ts b/apps/web/tests/auto-review-approval.e2e.ts
new file mode 100644
index 0000000000..b430de35f4
--- /dev/null
+++ b/apps/web/tests/auto-review-approval.e2e.ts
@@ -0,0 +1,89 @@
+// Web e2e scenario: a cold recording renders an Auto-review denial the user
+// rejected and a reviewer failure without replaying a reviewer or model call.
+// The real persistence reader, shipped Web composition, permission
+// projection, conversation assembler, and Tool rows all participate.
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
+
+import { AUTO_REVIEW_FIXTURE } from './auto-review-fixture.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/auto-review-approval', import.meta.url))
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/auto-review-approval/session.v3.jsonl', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/auto-review-approval/ui.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'auto-review-approval-web-e2e'
+const FAILURE = 'Auto review of tool "mystery" failed; its body was not executed: auto-review: reviewer ended with error UNKNOWN: provider unavailable'
+
+describe.skipIf(MODE === 'record')('web e2e: cold Auto-review user rejection and reviewer failure', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ const fixture = await readFile(FIXTURE, 'utf8')
+ scaffold = await launchWebScaffold(AUTO_REVIEW_FIXTURE)
+ await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ await page.clock.setFixedTime(WEB_FIXTURE_TIME)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+
+ const groupRow = page.locator('[role="treeitem"]').first()
+ await groupRow.waitFor({ timeout: 15_000 })
+ await groupRow.click()
+ const sessionRow = page.locator('[role="treeitem"]').nth(1)
+ await sessionRow.waitFor({ timeout: 10_000 })
+ await sessionRow.click()
+ const call = page.locator('[data-tool="mystery"]').first()
+ await expandOwningTurnProcess(page, call)
+ await call.waitFor({ state: 'visible', timeout: 15_000 })
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('shows the user rejection and the specific reviewer failure, not an Auto denial', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-auto-review-approval'))
+ const calls = page.locator('[data-tool="mystery"]')
+ await expect.poll(() => calls.count()).toBe(2)
+ expect(await page.getByText('Rejected by Auto review', { exact: true }).count()).toBe(0)
+
+ const access = page.locator('button[aria-label^="Access mode"]').first()
+ await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('Access mode, current: Auto review EXP')
+ const collapsed = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+ .split(SEED_ID).join('{{seededId}}')
+
+ for (const index of [0, 1]) {
+ const row = calls.nth(index).locator('[data-expandable]')
+ await row.click()
+ await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
+ }
+ await calls.nth(1).getByText(FAILURE, { exact: false }).first().waitFor({ state: 'visible' })
+ const expanded = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+ .split(SEED_ID).join('{{seededId}}')
+
+ await compareOrRefreshGolden(UI_EXPECTED, `## Collapsed\n\n${collapsed.trim()}\n\n## Expanded\n\n${expanded.trim()}`, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ }, 60_000)
+
+ it('keeps its snapshot inventory closed', async () => {
+ await assertFixtureInventory(SNAPSHOT_DIR, [
+ 'ui.expected.md', 'session.v3.jsonl',
+ ])
+ })
+})
diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts
index fb47567d36..d356d48a64 100644
--- a/apps/web/tests/changed-files-turn.e2e.ts
+++ b/apps/web/tests/changed-files-turn.e2e.ts
@@ -175,8 +175,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
expect(compactHeight).toBeLessThan(60)
await openSettings(page, 'zh')
const settings = page.getByRole('dialog', { name: '设置' })
- await settings.getByRole('switch', { name: '开发者工具' }).click()
- await expect.poll(() => settings.getByRole('switch', { name: '开发者工具' }).getAttribute('aria-checked')).toBe('true')
+ await settings.getByRole('switch', { name: '代码工作工具' }).click()
+ await expect.poll(() => settings.getByRole('switch', { name: '代码工作工具' }).getAttribute('aria-checked')).toBe('true')
await settings.getByRole('button', { name: '关闭', exact: true }).click()
await header.getByRole('tablist').waitFor({ state: 'visible' })
expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBeGreaterThan(compactHeight)
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 5e0e23934a..4d22e66e98 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -23,7 +23,7 @@ import {
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
-import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, openSettings, saveFailureShot } from './support.ts'
const MODE = webSnapshotMode()
const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
@@ -677,7 +677,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
- it.skipIf(MODE === 'record')('follows a growing process group independently of the outer transcript', async () => {
+ it.skipIf(MODE === 'record')('follows a growing Standard-mode process group independently of the outer transcript', async () => {
const parts = [
'GROUP_SCROLL_START\n\n',
...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) =>
@@ -701,6 +701,14 @@ describe('web e2e: long Chat scroll contract', () => {
}, async (world) => {
await world.page.emulateMedia({ reducedMotion: 'no-preference' })
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
+ await openSettings(world.page, 'en')
+ await world.page.getByRole('dialog', { name: 'Settings', exact: true })
+ .getByText('Work details', { exact: true }).locator('../..')
+ .getByRole('button', { name: 'Detailed', exact: true }).click()
+ await world.page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
+ await expect.poll(() => world.scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value)
+ .toMatchObject({ transcriptView: 'standard' })
+ await world.page.keyboard.press('Escape')
const gates = parts.slice(1).map(() => Promise.withResolvers())
const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) {
let index = -1
diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts
index b67bcfb4ef..2ddd8b3711 100644
--- a/apps/web/tests/clickable-links-gallery.e2e.ts
+++ b/apps/web/tests/clickable-links-gallery.e2e.ts
@@ -372,10 +372,24 @@ describe('web e2e: clickable links gallery', () => {
// — TOOL_VARIANTS has no entry for it, so it falls to the generic row with
// no openable path even though its create still joins the produced chips.
expect(await page.locator('button[class*="fileLink"]').count()).toBe(7)
+ // The collapsed web-fetch row's summary is its URL, opened in a new tab.
+ const fetchSummaryLink = page.locator(`a[class*="fileLink"][href="${FETCH_URL}"]`)
+ expect(await fetchSummaryLink.count()).toBe(1)
+ expect(await fetchSummaryLink.getAttribute('target')).toBe('_blank')
- // A row-center click can hit the nested fileLink button and invoke the
- // Host opener; right-edge clicks expand the card itself.
await expandTurnProcesses(page)
+ // Clicking the collapsed summary link opens the page without expanding the row.
+ const fetchPopupPromise = page.waitForEvent('popup')
+ await fetchSummaryLink.click()
+ const fetchPopup = await fetchPopupPromise
+ try {
+ await fetchPopup.waitForURL(FETCH_URL)
+ } finally {
+ await fetchPopup.close()
+ }
+ expect(await page.locator('[data-web="fetch"]').count()).toBe(0)
+ // A row-center click can hit a nested summary link and invoke its opener;
+ // right-edge clicks expand the card itself.
for (const row of [
/^Search clickable link styles/,
/^Fetch /,
@@ -400,7 +414,7 @@ describe('web e2e: clickable links gallery', () => {
const sourceLink = page.locator(`a[href="${SOURCE_URL}"]`)
await expect.poll(() => sourceLink.count(), { timeout: 10_000 }).toBe(1)
expect(await page.locator('a[href^="ftp:"]').count()).toBe(0)
- expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(1)
+ expect(await page.locator(`a[href="${FETCH_URL}"]`).count()).toBe(2)
expect(await page.locator(`a[href="${GUIDE_URL}"]`).count()).toBe(2)
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
@@ -423,7 +437,7 @@ describe('web e2e: clickable links gallery', () => {
['markdown anchor', guideLink],
['file mention', mentions.first()],
['search source', sourceLink.first()],
- ['fetch url', page.locator(`a[href="${FETCH_URL}"]`).first()],
+ ['fetch url', page.locator(`[data-web="fetch"] a[href="${FETCH_URL}"]`)],
] as const) {
expect.soft(await styleOf(link, 'color'), `${name} color`).toBe(LINK_BLUE)
expect.soft(await styleOf(link, 'font-weight'), `${name} weight`).toBe('500')
diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts
index 6079e187bf..3331d36416 100644
--- a/apps/web/tests/declared-reasoning.e2e.ts
+++ b/apps/web/tests/declared-reasoning.e2e.ts
@@ -197,14 +197,16 @@ describe.skipIf(MODE === 'record').each([
await target.getByText('Acme Swift', { exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(selections).toBe(1)
- expect(scaffold.ctx.agentDefaultModel.currentSelection()?.model).toBe('acme-swift')
+ await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().model, { timeout: 10_000 })
+ .toBe('acme-swift')
await trigger.click()
await page.getByRole('menuitem', { name: /推理等级/ }).click()
await page.getByRole('menuitemradio', { name: 'Max', exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(selections).toBe(2)
- expect(scaffold.ctx.agentDefaultModel.currentSelection()?.reasoningEffort).toBe('max')
+ await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().reasoningEffort, { timeout: 10_000 })
+ .toBe('max')
await page.route('**/api/session/selectModel', async (route) => {
const envelope = route.request().postDataJSON() as { rpcId: string }
diff --git a/apps/web/tests/default-model.e2e.ts b/apps/web/tests/default-model.e2e.ts
index 04cbd9b80f..aa17206061 100644
--- a/apps/web/tests/default-model.e2e.ts
+++ b/apps/web/tests/default-model.e2e.ts
@@ -107,7 +107,24 @@ describe('web e2e: the composer model switch is the default for later sessions',
expect(await trigger.evaluate(element => getComputedStyle(element).fontWeight)).toBe('400')
await trigger.click()
await page.getByRole('menuitem', { name: /模型/ }).click()
- await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
+ const entered = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ const blocked = scaffold.ctx.hmr.runExclusive(async () => {
+ entered.resolve(undefined)
+ await release.promise
+ })
+ try {
+ await entered.promise
+ await page.getByRole('menuitemradio', { name: 'Acme Large' }).click()
+ await expect.poll(() => trigger.getAttribute('aria-busy')).toBe('false')
+ await expect.poll(() => trigger.textContent()).toContain('Acme Large')
+ expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: START_ROUTE, model: START_MODEL })
+ const aria = await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(fileURLToPath(new URL('./expected/default-model/background-save.expected.md', import.meta.url)), aria, webSnapshotMode())
+ } finally {
+ release.resolve(undefined)
+ await blocked
+ }
// The switch is what sets the default: the shared Agent-route settings section
// now names it, beside the provider profiles the Models page writes.
@@ -119,6 +136,8 @@ describe('web e2e: the composer model switch is the default for later sessions',
expect(document).toContain(`provider: ${ROUTE}`)
expect(document).toContain(`model: ${MODEL}`)
+ await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection())
+ .toEqual({ provider: ROUTE, model: MODEL })
// A session created after the switch starts from it...
expect(await currentOf(await createSession('default-model-after')))
.toEqual({ provider: ROUTE, model: MODEL })
diff --git a/apps/web/tests/desktop-locale.e2e.ts b/apps/web/tests/desktop-locale.e2e.ts
index e392789d8d..7065c95552 100644
--- a/apps/web/tests/desktop-locale.e2e.ts
+++ b/apps/web/tests/desktop-locale.e2e.ts
@@ -33,6 +33,7 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference
})
await page.exposeFunction('nativeLocaleChanged', (locale: string) => { reported.push(locale) })
await page.addInitScript(() => {
+ Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } })
const bridge = globalThis as typeof globalThis & {
nativeLocaleRead(): Promise
nativeLocaleChanged(locale: string): Promise
diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts
index aec863b432..40481128d2 100644
--- a/apps/web/tests/developer-tools-settings.e2e.ts
+++ b/apps/web/tests/developer-tools-settings.e2e.ts
@@ -14,7 +14,7 @@ it('persists developer tools in the Host settings document and restores the acce
const page = await newEnglishPage(browser)
await page.goto(scaffold.authenticatedUrl)
await openSettings(page, 'en')
- const toggle = page.getByRole('switch', { name: 'Developer tools' })
+ const toggle = page.getByRole('switch', { name: 'Coding Tools' })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8')).not.toMatch(/id: ui-settings(?:\r?\n|$)/)
await toggle.click()
@@ -29,7 +29,7 @@ it('persists developer tools in the Host settings document and restores the acce
// The page owns no selection switch: only the General one gates the choice.
const section = page.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Agent presets', exact: true }) })
- const refused = section.getByRole('button', { name: /^Turn on Developer tools in General settings to choose a default: / })
+ const refused = section.getByRole('button', { name: /^Turn on Coding Tools in General settings to choose a default: / })
expect(await section.getByRole('switch').count()).toBe(0)
await expect.poll(() => refused.count()).toBeGreaterThan(0)
await scaffold.ctx.settings.update('ui-settings', { enabled: true })
diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts
index c4bf43d2eb..cc5c1fa763 100644
--- a/apps/web/tests/document-preview.e2e.ts
+++ b/apps/web/tests/document-preview.e2e.ts
@@ -837,8 +837,8 @@ else process.exit(1);
].join('\n'))
await openSettings(page, 'en')
const settings = page.getByRole('dialog', { name: 'Settings' })
- await settings.getByRole('switch', { name: 'Developer tools' }).click()
- await expect.poll(() => settings.getByRole('switch', { name: 'Developer tools' }).getAttribute('aria-checked')).toBe('true')
+ await settings.getByRole('switch', { name: 'Coding Tools' }).click()
+ await expect.poll(() => settings.getByRole('switch', { name: 'Coding Tools' }).getAttribute('aria-checked')).toBe('true')
await successShot(page, 'developer-tools-setting')
await settings.getByRole('button', { name: 'Close', exact: true }).click()
await expect.poll(() => iframe.getAttribute('sandbox')).toBe('allow-scripts')
diff --git a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md
index 9fcc346ad2..b9967c9722 100644
--- a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md
+++ b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md
@@ -1,7 +1,7 @@
- dialog "确认启用 Auto review(实验)?":
- heading "确认启用 Auto review(实验)?" [level=2]
- button "关闭"
- - paragraph: Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。
+ - paragraph: Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查;审查拒绝的调用由你批准或拒绝。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。
- checkbox "我已了解这些风险,并愿意继续"
- text: 我已了解这些风险,并愿意继续
- button "取消"
diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
index 25c6502dad..f85e34a349 100644
--- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
+++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
@@ -83,7 +83,10 @@
- text: One cursor token, one focus ring.
- listitem: Mirror spec (non-http) A non-http source renders inert.
- button "Inspect"
-- button "Fetch https://docs.example.test/tokens" [expanded]
+- button "Fetch https://docs.example.test/tokens" [expanded]:
+ - text: Fetch
+ - link "https://docs.example.test/tokens":
+ - /url: https://docs.example.test/tokens
- link "https://docs.example.test/tokens":
- /url: https://docs.example.test/tokens
- text: HTTP 200
diff --git a/apps/web/tests/expected/default-model/background-save.expected.md b/apps/web/tests/expected/default-model/background-save.expected.md
new file mode 100644
index 0000000000..b98bf59432
--- /dev/null
+++ b/apps/web/tests/expected/default-model/background-save.expected.md
@@ -0,0 +1,5 @@
+- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话"
+- button "添加文件或调用指令"
+- button "访问模式,当前:工作区内修改": 工作区内修改
+- button "选择模型,当前 Acme Large": Acme Large
+- button "发送消息" [disabled]
diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md
index 4e10073523..5eb07e6be3 100644
--- a/apps/web/tests/expected/desktop-locale/automatic.expected.md
+++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md
@@ -22,11 +22,11 @@
- button "标准"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- - text: 开发者工具 显示用于调试和排查问题的工具与信息
- - switch "开发者工具" [checked]
+ - text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
+ - switch "代码工作工具" [checked]
- text: 快捷键
- - paragraph: 查看当前可用的快捷键和输入操作
- - button "查看快捷键"
+ - paragraph: 查看和编辑当前可用的快捷键和输入操作
+ - button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md
index eadbe2289d..1d68d15d1f 100644
--- a/apps/web/tests/expected/desktop-locale/selected.expected.md
+++ b/apps/web/tests/expected/desktop-locale/selected.expected.md
@@ -22,11 +22,11 @@
- button "Standard"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- - text: Developer tools Show tools and information for debugging and troubleshooting
- - switch "Developer tools" [checked]
+ - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
+ - switch "Coding Tools" [checked]
- text: Keyboard shortcuts
- - paragraph: Browse available shortcuts and input actions
- - button "View shortcuts"
+ - paragraph: View and edit available shortcuts and input actions
+ - button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md
index 4e53ddd016..dddc056ff2 100644
--- a/apps/web/tests/expected/plugin-config/official.expected.md
+++ b/apps/web/tests/expected/plugin-config/official.expected.md
@@ -3,7 +3,7 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
-- text: "7"
+- text: "8"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
@@ -13,6 +13,10 @@
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 自动化任务": 自动化任务
+ - text: 实验性 按设定的时间或周期,在会话中自动执行任务。
+ - switch "启用 自动化任务"
- listitem:
- button "查看 语音输入": 语音输入
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
@@ -24,8 +28,8 @@
- button "查看 Agent 循环": Agent 循环
- text: 控制 Agent 派发工具调用的方式。
- listitem:
- - button "查看 Subagent": Subagent
- - text: 设置 Subagent 的递归层级、数量和模型。
+ - button "查看 子智能体": 子智能体
+ - text: 设置子智能体的递归层级、数量和模型。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: 设置 DeepSeek 的搜索提供方。
diff --git a/apps/web/tests/expected/plugin-config/subagent.expected.md b/apps/web/tests/expected/plugin-config/subagent.expected.md
index e1214b7fd7..1f324d7161 100644
--- a/apps/web/tests/expected/plugin-config/subagent.expected.md
+++ b/apps/web/tests/expected/plugin-config/subagent.expected.md
@@ -1,6 +1,6 @@
- button "返回插件列表": 插件列表
-- heading "Subagent" [level=3]
-- paragraph: 设置 Subagent 的递归层级、数量和模型。
+- heading "子智能体" [level=3]
+- paragraph: 设置子智能体的递归层级、数量和模型。
- region "运行限制":
- heading "运行限制" [level=3]
- text: 最大递归深度
@@ -10,16 +10,16 @@
- textbox "最大递归深度":
- /placeholder: ""
- text: "2"
- - text: Subagent 并行数量上限
- - button "Subagent 并行数量上限说明"
+ - text: 子智能体并行数量上限
+ - button "子智能体并行数量上限说明"
- text: 已覆盖
- button "恢复默认"
- - textbox "Subagent 并行数量上限":
+ - textbox "子智能体并行数量上限":
- /placeholder: ""
- text: "12"
- region "模型选择":
- heading "模型选择" [level=3]
- - text: 允许 Agent 为 Subagent 选择模型
- - switch "允许 Agent 为 Subagent 选择模型"
- - paragraph: 关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。
+ - text: 允许 Agent 为子智能体选择模型
+ - switch "允许 Agent 为子智能体选择模型"
+ - paragraph: 关闭后,子智能体使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。
- button "保存" [disabled]
diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
index e9b345be5f..086bfee63c 100644
--- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
+++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
@@ -3,7 +3,7 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
-- text: "7"
+- text: "8"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
@@ -13,6 +13,10 @@
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 自动化任务": 自动化任务
+ - text: 实验性 按设定的时间或周期,在会话中自动执行任务。
+ - switch "启用 自动化任务"
- listitem:
- button "查看 语音输入": 语音输入
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
@@ -24,8 +28,8 @@
- button "查看 Agent 循环": Agent 循环
- text: 控制 Agent 派发工具调用的方式。
- listitem:
- - button "查看 Subagent": Subagent
- - text: 设置 Subagent 的递归层级、数量和模型。
+ - button "查看 子智能体": 子智能体
+ - text: 设置子智能体的递归层级、数量和模型。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: 设置 DeepSeek 的搜索提供方。
diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md
index 978daa2f30..90f137a80e 100644
--- a/apps/web/tests/expected/plugin-manager/manager.expected.md
+++ b/apps/web/tests/expected/plugin-manager/manager.expected.md
@@ -3,7 +3,7 @@
- button "刷新"
- button "添加插件"
- heading "官方" [level=3]
-- text: "7"
+- text: "8"
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
@@ -13,6 +13,10 @@
- button "查看 自动授权审查": 自动授权审查
- text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 自动化任务": 自动化任务
+ - text: 实验性 按设定的时间或周期,在会话中自动执行任务。
+ - switch "启用 自动化任务"
- listitem:
- button "查看 语音输入": 语音输入
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
@@ -24,8 +28,8 @@
- button "查看 Agent 循环": Agent 循环
- text: 控制 Agent 派发工具调用的方式。
- listitem:
- - button "查看 Subagent": Subagent
- - text: 设置 Subagent 的递归层级、数量和模型。
+ - button "查看 子智能体": 子智能体
+ - text: 设置子智能体的递归层级、数量和模型。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: 设置 DeepSeek 的搜索提供方。
diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
index 2ad9903a2e..a31abbd9a6 100644
--- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
@@ -19,14 +19,14 @@
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Choose how much detail to show for tool calls
- - button "Standard"
+ - button "Detailed"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- - text: Developer tools Show tools and information for debugging and troubleshooting
- - switch "Developer tools"
+ - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
+ - switch "Coding Tools"
- text: Keyboard shortcuts
- - paragraph: Browse available shortcuts and input actions
- - button "View shortcuts"
+ - paragraph: View and edit available shortcuts and input actions
+ - button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
index 2ad9903a2e..a31abbd9a6 100644
--- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
@@ -19,14 +19,14 @@
- button "Increase font size"
- button "Decrease font size"
- text: px Work details Choose how much detail to show for tool calls
- - button "Standard"
+ - button "Detailed"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- - text: Developer tools Show tools and information for debugging and troubleshooting
- - switch "Developer tools"
+ - text: Coding Tools Shows trajectory, code diffs, and Agent preset switching in new chats
+ - switch "Coding Tools"
- text: Keyboard shortcuts
- - paragraph: Browse available shortcuts and input actions
- - button "View shortcuts"
+ - paragraph: View and edit available shortcuts and input actions
+ - button "Edit shortcuts"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
- button "Queue"
- text: "Current version: {{version}}"
diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md
index a4a9349ffb..75c367d690 100644
--- a/apps/web/tests/expected/settings-chrome/dialog.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md
@@ -19,14 +19,14 @@
- button "增大字号"
- button "减小字号"
- text: px 工作步骤展示 选择希望看到多少工具调用细节
- - button "标准"
+ - button "详细"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- - text: 开发者工具 显示用于调试和排查问题的工具与信息
- - switch "开发者工具"
+ - text: 代码工作工具 开启后显示轨迹、本轮代码差异,新对话中的 Agent 预设切换
+ - switch "代码工作工具"
- text: 快捷键
- - paragraph: 查看当前可用的快捷键和输入操作
- - button "查看快捷键"
+ - paragraph: 查看和编辑当前可用的快捷键和输入操作
+ - button "编辑快捷键"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
- button "排队发送"
- text: 当前版本:{{version}}
diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
index 612291fe84..16d2109528 100644
--- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md
@@ -1,23 +1,20 @@
- list:
- listitem:
- - button "tool-subagent-control, tool-subagent-control, 已启用":
+ - button "tool-subagent-control, 已启用":
- strong: tool-subagent-control
- - text: 已启用 Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
- - code: tool-subagent-control
+ - text: Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations
- listitem:
- button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用":
- strong: tool-subagent-control/list-agents
- - text: 已启用
- code: tool-subagent-list-agents
- listitem:
- - button "tool-subagent, tool-subagent, 已启用":
+ - button "tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- - code: tool-subagent
+ - text: Model-facing subagent delegation tool over the ctx.subagents seam
- listitem:
- button "tool-subagent, tool-subagent-fork, 已启用":
- strong: tool-subagent
- - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
+ - text: Model-facing subagent delegation tool over the ctx.subagents seam
- code: tool-subagent-fork
- listitem:
- button "tool-subagent, tool-subagent-codex, 已停用":
diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md
index 7cfe33f5f0..20e096a308 100644
--- a/apps/web/tests/expected/settings-chrome/plugins.expected.md
+++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md
@@ -1,5 +1,4 @@
- listitem:
- - button "tool-subagent, tool-subagent, 已启用":
+ - button "tool-subagent, 已启用":
- strong: tool-subagent
- - text: 已启用 Model-facing subagent delegation tool over the ctx.subagents seam
- - code: tool-subagent
+ - text: Model-facing subagent delegation tool over the ctx.subagents seam
diff --git a/apps/web/tests/expected/shortcuts-desktop/chord.expected.md b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md
index 3bf739802b..3105f57dec 100644
--- a/apps/web/tests/expected/shortcuts-desktop/chord.expected.md
+++ b/apps/web/tests/expected/shortcuts-desktop/chord.expected.md
@@ -1,5 +1,3 @@
- listitem:
- - text: New Session
- - button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- - button "New Session A B": A B
+ - text: New Session A B
diff --git a/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md
index 0881936876..9979080ca0 100644
--- a/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md
+++ b/apps/web/tests/expected/shortcuts-desktop/macos-copy-override.expected.md
@@ -1,5 +1,3 @@
- listitem:
- - text: New Session
- - button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- - button "New Session ⌘ C": ⌘ C
+ - text: New Session ⌘ C
diff --git a/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md
index fd3ec59c13..84272e6570 100644
--- a/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md
+++ b/apps/web/tests/expected/shortcuts-desktop/windows-copy-override.expected.md
@@ -1,5 +1,3 @@
- listitem:
- - text: New Session
- - button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- - button "New Session Ctrl + C": Ctrl + C
+ - text: New Session Ctrl + C
diff --git a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md
index 7bddffee98..ceed422820 100644
--- a/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md
+++ b/apps/web/tests/expected/shortcuts-workspace/macos-defaults.expected.md
@@ -7,126 +7,72 @@
- region "Application":
- list:
- listitem:
- - text: Open keyboard shortcuts
- - button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- - button "Open keyboard shortcuts ⌘ /": ⌘ /
+ - text: Open keyboard shortcuts ⌘ /
- listitem:
- - text: New Session
- - button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- - button "New Session ⌥ ⌘ N": ⌥ ⌘ N
+ - text: New Session ⌥ ⌘ N
- listitem:
- - text: Toggle left sidebar
- - button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- - button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B
+ - text: Toggle left sidebar ⌥ ⌘ B
- listitem:
- - text: Search sessions
- - button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- - button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K
+ - text: Search sessions ⌥ ⌘ K
- listitem:
- - text: Add workspace
- - button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- - button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O
+ - text: Add workspace ⌥ ⌘ O
- listitem:
- - text: Rename session
- - button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- - button "Rename session ⇧ ⌘ R": ⇧ ⌘ R
+ - text: Rename session ⇧ ⌘ R
- listitem:
- - text: Fork session
- - button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- - button "Fork session ⇧ ⌘ F": ⇧ ⌘ F
+ - text: Fork session ⇧ ⌘ F
- listitem:
- - text: Archive session
- - button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- - button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
+ - text: Archive session ⌥ ⌘ A
- listitem:
- - text: Open settings
- - button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- - button "Open settings ⌘ ,": ⌘ ,
+ - text: Open settings ⌘ ,
- listitem:
- - text: Toggle right sidebar
- - button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- - button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B
+ - text: Toggle right sidebar ⇧ ⌘ B
- listitem:
- - text: Workspace files
- - button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- - button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P
+ - text: Workspace files ⌥ ⌘ P
- listitem:
- - text: New terminal
- - button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- - 'button "New terminal ⌃ `"': "⌃ `"
+ - text: "New terminal ⌃ `"
- listitem:
- - text: Split
- - button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- - button "Split ⌘ \\": ⌘ \
+ - text: Split ⌘ \
- listitem:
- - text: Toggle panel fullscreen
- - button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- - button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter
+ - text: Toggle panel fullscreen ⌥ ⌘ Enter
- listitem:
- - text: Refresh current page
- button "Edit shortcut for Refresh current page"
- - button "Refresh current page No shortcut": No shortcut
+ - text: Refresh current page No shortcut
- listitem:
- - text: Close current page or window
- - button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- - button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W
- - listitem:
- - text: Stop generating
- - button "Stop generating Esc Esc" [disabled]: Esc Esc
+ - text: Close current page or window ⌥ ⌘ W
- region "Message input":
- heading "Message input" [level=3]
- list:
- - listitem:
- - text: Use the complementary Queue / Steer action
- - button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter
- - listitem:
- - text: Open reference menu
- - button "Open reference menu @" [disabled]: "@"
- - listitem:
- - text: New line
- - button "New line ⇧ Enter" [disabled]: ⇧ Enter
- - listitem:
- - text: Send message
- - button "Send message Enter" [disabled]: Enter
- - listitem:
- - text: Open command menu
- - button "Open command menu /" [disabled]: /
+ - listitem: Use the complementary Queue / Steer action ⌘ Enter
+ - listitem: Open reference menu @
+ - listitem: New line ⇧ Enter
+ - listitem: Send message Enter
+ - listitem: Open command menu /
+ - listitem: Stop generating Esc Esc
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- - listitem:
- - text: Close menu or top dialog
- - button "Close menu or top dialog Esc" [disabled]: Esc
- - listitem:
- - text: Move menu selection
- - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- - listitem:
- - text: Select menu item
- - button "Select menu item Enter" [disabled]: Enter
+ - listitem: Close menu or top dialog Esc
+ - listitem: Move menu selection ↑ ↓
+ - listitem: Select menu item Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- - listitem:
- - text: Allow once
- - button "Allow once Enter" [disabled]: Enter
- - listitem:
- - text: Reject
- - button "Reject Esc" [disabled]: Esc
+ - listitem: Allow once Enter
+ - listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- - text: 0 customized
diff --git a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md
index 0661c89929..94072331f3 100644
--- a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md
+++ b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md
@@ -4,6 +4,7 @@
- button "Close keyboard shortcuts"
- search "Search shortcuts":
- searchbox "Search shortcuts": abc
+ - button "Clear search"
- status: No matching shortcuts
- contentinfo:
- button "Restore all defaults"
diff --git a/apps/web/tests/expected/shortcuts/en-US.expected.md b/apps/web/tests/expected/shortcuts/en-US.expected.md
index 4460a24ce0..d3740de7a2 100644
--- a/apps/web/tests/expected/shortcuts/en-US.expected.md
+++ b/apps/web/tests/expected/shortcuts/en-US.expected.md
@@ -7,127 +7,72 @@
- region "Application":
- list:
- listitem:
- - text: Open keyboard shortcuts
- - button "Remove shortcut for Open keyboard shortcuts"
- button "Edit shortcut for Open keyboard shortcuts"
- - button "Open keyboard shortcuts Ctrl + /": Ctrl + /
+ - text: Open keyboard shortcuts Ctrl + /
- listitem:
- - text: New Session
- - button "Remove shortcut for New Session"
- button "Edit shortcut for New Session"
- - button "New Session Ctrl + Alt + N": Ctrl + Alt + N
+ - text: New Session Ctrl + Alt + N
- listitem:
- - text: Toggle left sidebar
- - button "Remove shortcut for Toggle left sidebar"
- button "Edit shortcut for Toggle left sidebar"
- - button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
+ - text: Toggle left sidebar Ctrl + Alt + B
- listitem:
- - text: Search sessions
- - button "Remove shortcut for Search sessions"
- button "Edit shortcut for Search sessions"
- - button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
+ - text: Search sessions Ctrl + Alt + K
- listitem:
- - text: Add workspace
- - button "Remove shortcut for Add workspace"
- button "Edit shortcut for Add workspace"
- - button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O
+ - text: Add workspace Ctrl + Alt + O
- listitem:
- - text: Rename session
- - button "Remove shortcut for Rename session"
- button "Edit shortcut for Rename session"
- - button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
+ - text: Rename session Ctrl + Shift + R
- listitem:
- - text: Fork session
- - button "Remove shortcut for Fork session"
- button "Edit shortcut for Fork session"
- - button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
+ - text: Fork session Ctrl + Shift + F
- listitem:
- - text: Archive session
- - button "Remove shortcut for Archive session"
- button "Edit shortcut for Archive session"
- - button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
+ - text: Archive session Ctrl + Alt + A
- listitem:
- - text: Open settings
- - button "Remove shortcut for Open settings"
- button "Edit shortcut for Open settings"
- - button "Open settings Ctrl + ,": Ctrl + ,
+ - text: Open settings Ctrl + ,
- listitem:
- - text: Toggle right sidebar
- - button "Remove shortcut for Toggle right sidebar"
- button "Edit shortcut for Toggle right sidebar"
- - button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
+ - text: Toggle right sidebar Ctrl + Shift + B
- listitem:
- - text: Workspace files
- - button "Remove shortcut for Workspace files"
- button "Edit shortcut for Workspace files"
- - button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
+ - text: Workspace files Ctrl + Alt + P
- listitem:
- - text: New terminal
- - button "Remove shortcut for New terminal"
- button "Edit shortcut for New terminal"
- - 'button "New terminal Ctrl + `"': "Ctrl + `"
+ - text: "New terminal Ctrl + `"
- listitem:
- - text: Split
- - button "Remove shortcut for Split"
- button "Edit shortcut for Split"
- - button "Split Ctrl + \\": Ctrl + \
+ - text: Split Ctrl + \
- listitem:
- - text: Toggle panel fullscreen
- - button "Remove shortcut for Toggle panel fullscreen"
- button "Edit shortcut for Toggle panel fullscreen"
- - button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
+ - text: Toggle panel fullscreen Ctrl + Alt + Enter
- listitem:
- - text: Refresh current page
- - button "Remove shortcut for Refresh current page"
- button "Edit shortcut for Refresh current page"
- - button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
+ - text: Refresh current page Ctrl + Alt + R
- listitem:
- - text: Close current page or window
- - button "Remove shortcut for Close current page or window"
- button "Edit shortcut for Close current page or window"
- - button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
- - listitem:
- - text: Stop generating
- - button "Stop generating Esc Esc" [disabled]: Esc Esc
+ - text: Close current page or window Ctrl + Alt + W
- region "Message input":
- heading "Message input" [level=3]
- list:
- - listitem:
- - text: Use the complementary Queue / Steer action
- - button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter
- - listitem:
- - text: Open reference menu
- - button "Open reference menu @" [disabled]: "@"
- - listitem:
- - text: New line
- - button "New line Shift + Enter" [disabled]: Shift + Enter
- - listitem:
- - text: Send message
- - button "Send message Enter" [disabled]: Enter
- - listitem:
- - text: Open command menu
- - button "Open command menu /" [disabled]: /
+ - listitem: Use the complementary Queue / Steer action Ctrl + Enter
+ - listitem: Open reference menu @
+ - listitem: New line Shift + Enter
+ - listitem: Send message Enter
+ - listitem: Open command menu /
+ - listitem: Stop generating Esc Esc
- region "Menus and dialogs":
- heading "Menus and dialogs" [level=3]
- list:
- - listitem:
- - text: Close menu or top dialog
- - button "Close menu or top dialog Esc" [disabled]: Esc
- - listitem:
- - text: Move menu selection
- - button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
- - listitem:
- - text: Select menu item
- - button "Select menu item Enter" [disabled]: Enter
+ - listitem: Close menu or top dialog Esc
+ - listitem: Move menu selection ↑ ↓
+ - listitem: Select menu item Enter
- region "Approval area":
- heading "Approval area" [level=3]
- list:
- - listitem:
- - text: Allow once
- - button "Allow once Enter" [disabled]: Enter
- - listitem:
- - text: Reject
- - button "Reject Esc" [disabled]: Esc
+ - listitem: Allow once Enter
+ - listitem: Reject Esc
- contentinfo:
- button "Restore all defaults" [disabled]
- - text: 0 customized
diff --git a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md
index 8bc73cf849..f43195fe3f 100644
--- a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md
+++ b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md
@@ -4,6 +4,7 @@
- button "关闭快捷键"
- search "搜索快捷键":
- searchbox "搜索快捷键": abc
+ - button "清空搜索"
- status: 没有匹配的快捷键
- contentinfo:
- button "恢复全部默认"
diff --git a/apps/web/tests/expected/shortcuts/zh-CN.expected.md b/apps/web/tests/expected/shortcuts/zh-CN.expected.md
index 8fa16c3e99..ce19e0b876 100644
--- a/apps/web/tests/expected/shortcuts/zh-CN.expected.md
+++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md
@@ -7,126 +7,72 @@
- region "应用操作":
- list:
- listitem:
- - text: 快捷键速查
- - button "移除快捷键速查快捷键"
- button "修改快捷键速查快捷键"
- - button "快捷键速查 ⌘ /": ⌘ /
+ - text: 快捷键速查 ⌘ /
- listitem:
- - text: 新会话
- - button "移除新会话快捷键"
- button "修改新会话快捷键"
- - button "新会话 ⌥ ⌘ N": ⌥ ⌘ N
+ - text: 新会话 ⌥ ⌘ N
- listitem:
- - text: 展开/收起左侧栏
- - button "移除展开/收起左侧栏快捷键"
- button "修改展开/收起左侧栏快捷键"
- - button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B
+ - text: 展开/收起左侧栏 ⌥ ⌘ B
- listitem:
- - text: 搜索会话
- - button "移除搜索会话快捷键"
- button "修改搜索会话快捷键"
- - button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K
+ - text: 搜索会话 ⌥ ⌘ K
- listitem:
- - text: 添加工作区
- - button "移除添加工作区快捷键"
- button "修改添加工作区快捷键"
- - button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O
+ - text: 添加工作区 ⌥ ⌘ O
- listitem:
- - text: 重命名会话
- - button "移除重命名会话快捷键"
- button "修改重命名会话快捷键"
- - button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R
+ - text: 重命名会话 ⇧ ⌘ R
- listitem:
- - text: 分叉会话
- - button "移除分叉会话快捷键"
- button "修改分叉会话快捷键"
- - button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F
+ - text: 分叉会话 ⇧ ⌘ F
- listitem:
- - text: 归档会话
- - button "移除归档会话快捷键"
- button "修改归档会话快捷键"
- - button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
+ - text: 归档会话 ⌥ ⌘ A
- listitem:
- - text: 打开设置
- - button "移除打开设置快捷键"
- button "修改打开设置快捷键"
- - button "打开设置 ⌘ ,": ⌘ ,
+ - text: 打开设置 ⌘ ,
- listitem:
- - text: 展开/收起右侧栏
- - button "移除展开/收起右侧栏快捷键"
- button "修改展开/收起右侧栏快捷键"
- - button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B
+ - text: 展开/收起右侧栏 ⇧ ⌘ B
- listitem:
- - text: 工作区文件
- - button "移除工作区文件快捷键"
- button "修改工作区文件快捷键"
- - button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P
+ - text: 工作区文件 ⌥ ⌘ P
- listitem:
- - text: 新建终端
- - button "移除新建终端快捷键"
- button "修改新建终端快捷键"
- - 'button "新建终端 ⌃ `"': "⌃ `"
+ - text: "新建终端 ⌃ `"
- listitem:
- - text: 分栏
- - button "移除分栏快捷键"
- button "修改分栏快捷键"
- - button "分栏 ⌘ \\": ⌘ \
+ - text: 分栏 ⌘ \
- listitem:
- - text: 面板全屏/退出全屏
- - button "移除面板全屏/退出全屏快捷键"
- button "修改面板全屏/退出全屏快捷键"
- - button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter
+ - text: 面板全屏/退出全屏 ⌥ ⌘ Enter
- listitem:
- - text: 刷新当前页面
- button "修改刷新当前页面快捷键"
- - button "刷新当前页面 暂无快捷键": 暂无快捷键
+ - text: 刷新当前页面 暂无快捷键
- listitem:
- - text: 关闭当前页面/窗口
- - button "移除关闭当前页面/窗口快捷键"
- button "修改关闭当前页面/窗口快捷键"
- - button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W
- - listitem:
- - text: 停止生成
- - button "停止生成 Esc Esc" [disabled]: Esc Esc
+ - text: 关闭当前页面/窗口 ⌥ ⌘ W
- region "消息输入":
- heading "消息输入" [level=3]
- list:
- - listitem:
- - text: 使用互补的 Queue/Steer 方式
- - button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter
- - listitem:
- - text: 打开引用菜单
- - button "打开引用菜单 @" [disabled]: "@"
- - listitem:
- - text: 换行
- - button "换行 ⇧ Enter" [disabled]: ⇧ Enter
- - listitem:
- - text: 发送消息
- - button "发送消息 Enter" [disabled]: Enter
- - listitem:
- - text: 打开命令菜单
- - button "打开命令菜单 /" [disabled]: /
+ - listitem: 使用互补的 Queue/Steer 方式 ⌘ Enter
+ - listitem: 打开引用菜单 @
+ - listitem: 换行 ⇧ Enter
+ - listitem: 发送消息 Enter
+ - listitem: 打开命令菜单 /
+ - listitem: 停止生成 Esc Esc
- region "菜单与弹层":
- heading "菜单与弹层" [level=3]
- list:
- - listitem:
- - text: 关闭菜单或顶层弹窗
- - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
- - listitem:
- - text: 移动菜单选择
- - button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓
- - listitem:
- - text: 选择菜单项
- - button "选择菜单项 Enter" [disabled]: Enter
+ - listitem: 关闭菜单或顶层弹窗 Esc
+ - listitem: 移动菜单选择 ↑ ↓
+ - listitem: 选择菜单项 Enter
- region "审批区域":
- heading "审批区域" [level=3]
- list:
- - listitem:
- - text: 允许一次
- - button "允许一次 Enter" [disabled]: Enter
- - listitem:
- - text: 拒绝
- - button "拒绝 Esc" [disabled]: Esc
+ - listitem: 允许一次 Enter
+ - listitem: 拒绝 Esc
- contentinfo:
- button "恢复全部默认" [disabled]
- - text: 0 项已自定义
diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md
index 37a6c38e98..136b0ae8b5 100644
--- a/apps/web/tests/expected/steer-all/mid-steer.expected.md
+++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md
@@ -10,7 +10,7 @@
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
-- button "Analyzing the request · The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
+- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply."
diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md
index 62c89bd029..1f2fb03954 100644
--- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md
+++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md
@@ -1 +1 @@
-Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
+Prefer showing the primary results within your final response alongside a brief explanation. Use ![Description]() when an image supports an explanation or comparison. Use [Description]() when referring to an image or listing files. Enclose Markdown file destinations in angle brackets, especially paths containing spaces. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, including images, Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.
diff --git a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md
index 6009227833..ec04e12324 100644
--- a/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md
+++ b/apps/web/tests/expected/workspace-new-session-folding/first-batch.expected.md
@@ -1,18 +1,18 @@
- tree "Sessions":
- treeitem "{{workspace}}" [expanded]
- treeitem "New Session" [selected]
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} Session actions for {{workspace}} Archive session Pin session":
- - text: {{workspace}}
- - button "Session actions for {{workspace}}"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled Session actions for Untitled Archive session Pin session":
+ - text: Untitled
+ - button "Session actions for Untitled"
- button "Archive session"
- button "Pin session"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
- button "Show 6 more sessions"
diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md
index 8778baac12..1aeb3e5a77 100644
--- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md
+++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md
@@ -4,9 +4,9 @@
- button "Workspace actions for {{workspace}}"
- button "New session in {{workspace}}"
- treeitem "New Session" [selected]
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
- - treeitem "{{workspace}} 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
+ - treeitem "Untitled 1min"
- button "Show 11 more sessions"
diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts
index 4643da9cf8..3de0ea2fca 100644
--- a/apps/web/tests/feedback-release.e2e.ts
+++ b/apps/web/tests/feedback-release.e2e.ts
@@ -27,6 +27,7 @@ const MODE = webSnapshotMode()
interface OtlpCapture {
resourceLogs: { scopeLogs: { logRecords: {
+ eventName: string
attributes: { key: string; value: { stringValue?: string; intValue?: number | string } }[]
}[] }[] }[]
}
@@ -53,8 +54,11 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
return capture.resourceLogs.flatMap(resource => resource.scopeLogs.flatMap(scope =>
scope.logRecords.map((record) => {
const attribute = (key: string) => record.attributes.find(value => value.key === key)?.value
- return [attribute('session.id')?.stringValue, Number(attribute('event.seq')?.intValue),
- attribute('event.type')?.stringValue] as [string | undefined, number, string | undefined]
+ expect(record.eventName).toBe('session-log')
+ const content = attribute('content')?.stringValue
+ expect(typeof content).toBe('string')
+ const event = JSON.parse(content!) as SessionEvent
+ return [attribute('sessionId')?.stringValue, event.seq, event.type] as [string | undefined, number, string | undefined]
})))
})
}
@@ -71,6 +75,10 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
const expected = authorized.map(event => [sessionId, event.seq, event.type])
// No teardown, flush hook, or subsequent interaction may cause this delivery.
await expect.poll(captured, { timeout: 10_000 }).toEqual(expected)
+ const uploadedEvents = uploads.flatMap(upload => (JSON.parse(upload) as OtlpCapture).resourceLogs
+ .flatMap(resource => resource.scopeLogs.flatMap(scope => scope.logRecords))
+ .map(record => JSON.parse(record.attributes.find(attribute => attribute.key === 'content')!.value.stringValue!) as SessionEvent))
+ expect(uploadedEvents).toEqual(authorized)
suffixes.push(authorized.slice(releasedCount).map(event => event.type))
releasedCount = authorized.length
expect(scaffold.ctx.agents.get(sessionId)).toBe(agent)
@@ -268,7 +276,9 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
}
})
await compareOrRefreshGolden(RELEASE_EXPECTED, JSON.stringify({
- mode: 'FEEDBACK_ONLY', feedback,
+ mode: 'FEEDBACK_ONLY',
+ eventName: (JSON.parse(uploads[0]!) as OtlpCapture).resourceLogs[0]!.scopeLogs[0]!.logRecords[0]!.eventName,
+ feedback,
// The prefix is compared with each provider's actual canonical log above.
laterSubmissionSuffixes: suffixes.slice(1),
}, null, 2), MODE)
diff --git a/apps/web/tests/fixtures/time-context-every-step.patch.yml b/apps/web/tests/fixtures/time-context-every-step.patch.yml
index 22c6bd919f..7a1937d44d 100644
--- a/apps/web/tests/fixtures/time-context-every-step.patch.yml
+++ b/apps/web/tests/fixtures/time-context-every-step.patch.yml
@@ -1,4 +1,6 @@
# Browser-zone assertions need a fresh reading after the non-browser preparation turn.
+# The Schedule rows stay disabled in the shipped composition; the scenario enables them
+# with the shipped `@deepseek-ai/dsh-experimental-schedule-bundle` patch.
- id: time-context
config:
refreshIntervalMs: 0
diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts
index 8083a28e0f..586efd80e0 100644
--- a/apps/web/tests/minimal-preset.snapshot.ts
+++ b/apps/web/tests/minimal-preset.snapshot.ts
@@ -123,10 +123,6 @@ describe('minimal agent preset', () => {
"prompt": "You are a helpful software engineer assistant.",
"tools": [
"bash",
- "schedule_create",
- "schedule_delete",
- "schedule_list",
- "schedule_update",
],
}
`)
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 68857956f3..4a206e7299 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -345,6 +345,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
inputModalities: ['image'],
})
+ // The saved notice confirms the browser directory refresh after the editor closes.
+ await dialog.getByText('已保存 Acme 网关 (acme-gateway)。', { exact: true }).waitFor({ timeout: 10_000 })
await dialog.getByRole('button', { name: '编辑 Acme 网关 (acme-gateway)' }).click()
await dialog.getByText('自定义设置').click()
await dialog.getByRole('button', { name: '模型选项 1' }).click()
diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts
index 3a65d644da..9a140bcf46 100644
--- a/apps/web/tests/plugin-config.e2e.ts
+++ b/apps/web/tests/plugin-config.e2e.ts
@@ -94,7 +94,7 @@ describe('web e2e: plugin configuration pages', () => {
const official = panel.locator('[data-plugin-group="official"]')
expect(await official.locator('[data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length)
expect(await official.locator('[data-plugin-item]').count()).toBe(4)
- for (const title of ['终端', 'Agent 循环', 'Subagent', '网页搜索']) {
+ for (const title of ['终端', 'Agent 循环', '子智能体', '网页搜索']) {
expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
}
// A card carries the one-liner; the fields wait for the page.
@@ -108,9 +108,9 @@ describe('web e2e: plugin configuration pages', () => {
it('saves subagent limits and resets them to the deployment defaults', async () => {
const panel = await openPlugins()
- await openPage(panel, 'Subagent')
+ await openPage(panel, '子智能体')
const depth = panel.getByLabel('最大递归深度', { exact: true })
- const capacity = panel.getByLabel('Subagent 并行数量上限', { exact: true })
+ const capacity = panel.getByLabel('子智能体并行数量上限', { exact: true })
expect(await depth.inputValue()).toBe('1')
expect(await capacity.inputValue()).toBe('8')
await depth.fill('2')
@@ -120,7 +120,7 @@ describe('web e2e: plugin configuration pages', () => {
await expect.poll(settingsDocument).toContain('maxActiveSubagents: 12')
await expect.poll(settingsDocument).toContain('maxDepth: 2')
await openPlugins()
- await openPage(panel, 'Subagent')
+ await openPage(panel, '子智能体')
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'subagent.expected.md'), snapshot, MODE)
const controlHeight = await depth.evaluate(element => element.getBoundingClientRect().height)
@@ -133,7 +133,7 @@ describe('web e2e: plugin configuration pages', () => {
await panel.getByRole('button', { name: '保存', exact: true }).click()
await expect.poll(() => panel.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true)
await openPlugins()
- await openPage(panel, 'Subagent')
+ await openPage(panel, '子智能体')
expect(await depth.inputValue()).toBe('1')
expect(await capacity.inputValue()).toBe('8')
await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
@@ -141,7 +141,7 @@ describe('web e2e: plugin configuration pages', () => {
it('opens field explanations with the keyboard and retains unsaved edits', async () => {
const panel = await openPlugins()
- await openPage(panel, 'Subagent')
+ await openPage(panel, '子智能体')
const depth = panel.getByLabel('最大递归深度', { exact: true })
await depth.fill('2')
const depthHelp = panel.getByRole('button', { name: '最大递归深度说明', exact: true })
@@ -149,27 +149,27 @@ describe('web e2e: plugin configuration pages', () => {
await depthHelp.press('Enter')
const depthRules = panel.getByRole('region', { name: '最大递归深度说明', exact: true })
await depthRules.waitFor()
- expect(await depthRules.getByText('限制 Agent 创建 Subagent 的递归层级。', { exact: true }).count()).toBe(1)
+ expect(await depthRules.getByText('限制 Agent 创建子智能体的递归层级。', { exact: true }).count()).toBe(1)
const depthTable = depthRules.getByRole('table', { name: '最大递归深度说明', exact: true })
- expect(await depthTable.getByRole('row', { name: '0 禁用 Subagent', exact: true }).count()).toBe(1)
- expect(await depthTable.getByRole('row', { name: '1 仅允许主 Agent 创建 Subagent', exact: true }).count()).toBe(1)
+ expect(await depthTable.getByRole('row', { name: '0 禁用子智能体', exact: true }).count()).toBe(1)
+ expect(await depthTable.getByRole('row', { name: '1 仅允许主 Agent 创建子智能体', exact: true }).count()).toBe(1)
expect(await depthRules.getByText('如果某个工具单独设置了最大递归深度,以该工具的设置为准。', { exact: true }).count()).toBe(1)
await depthHelp.press('Enter')
expect(await depthRules.count()).toBe(0)
expect(await depth.inputValue()).toBe('2')
- await panel.getByRole('button', { name: 'Subagent 并行数量上限说明', exact: true }).click()
- const capacityRules = panel.getByRole('region', { name: 'Subagent 并行数量上限说明', exact: true })
- expect(await capacityRules.getByText('同一主 Agent 下,所有递归层级同时存活的 Subagent 总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', { exact: true }).count()).toBe(1)
+ await panel.getByRole('button', { name: '子智能体并行数量上限说明', exact: true }).click()
+ const capacityRules = panel.getByRole('region', { name: '子智能体并行数量上限说明', exact: true })
+ expect(await capacityRules.getByText('同一主 Agent 下,所有递归层级同时存活的子智能体总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', { exact: true }).count()).toBe(1)
await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
- await openPage(panel, 'Subagent')
+ await openPage(panel, '子智能体')
expect(await depth.inputValue()).toBe('1')
})
it('saves limits and the model allowlist together from the shared card', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
const panel = await openPlugins()
- await openPage(panel, 'Subagent')
- const toggle = panel.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' })
+ await openPage(panel, '子智能体')
+ const toggle = panel.getByRole('switch', { name: '允许 Agent 为子智能体选择模型' })
await panel.getByLabel('最大递归深度', { exact: true }).fill('2')
await toggle.click()
diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts
index cfa6a9a763..01523cc13e 100644
--- a/apps/web/tests/plugin-manager.e2e.ts
+++ b/apps/web/tests/plugin-manager.e2e.ts
@@ -239,6 +239,8 @@ describe('web e2e: plugin manager', () => {
await panel.getByRole('button', { name: 'Back to plugins' }).click()
await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor()
expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1)
+ await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).waitFor()
+ expect(await panel.getByText('Run tasks in your sessions at a set time or on a repeating schedule.', { exact: true }).count()).toBe(1)
// The official configuration pages follow the language too, from their own dictionary.
for (const title of ['Shell', 'Agent loop', 'Subagent', 'Web search']) {
await panel.getByRole('button', { name: `View ${title}`, exact: true }).waitFor()
@@ -312,6 +314,37 @@ describe('web e2e: plugin manager', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+ it('lists the Automation tasks rows and mounts them with the bundle switch', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-schedule'))
+ const panel = await openPluginsPanel()
+ const scheduleRows = () => [...scaffold.ctx.loader.entries()]
+ .filter(entry => ['time-context', 'schedule', 'ui-schedule'].includes(entry.options.id))
+ const running = () => scheduleRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length
+ expect(running()).toBe(0)
+ await panel.getByRole('button', { name: '查看 自动化任务', exact: true }).click()
+ const rows = panel.locator('[data-plugin-rows]')
+ for (const title of ['时间感知', '任务调度', '任务界面']) {
+ await rows.locator('[data-plugin-row]', { hasText: title }).waitFor()
+ }
+ // A bundle that is off offers no row switches.
+ expect(await rows.getByRole('switch').count()).toBe(0)
+ const toggle = panel.getByRole('switch', { name: '启用 自动化任务', exact: true })
+ await toggle.click()
+ try {
+ await expect.poll(running, { timeout: 20_000 }).toBe(3)
+ await expect.poll(() => rows.locator('[data-plugin-row]', { hasText: '运行中' }).count(), { timeout: 20_000 }).toBe(3)
+ for (const title of ['时间感知', '任务调度', '任务界面']) {
+ await rows.getByRole('switch', { name: `启用组件 ${title}`, exact: true }).waitFor()
+ }
+ await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '自动化任务', exact: true }).waitFor()
+ } finally {
+ if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click()
+ await expect.poll(running, { timeout: 20_000 }).toBe(0)
+ await panel.getByRole('button', { name: '返回插件列表' }).click()
+ }
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
it('checks a spec before installing it and words what the check refused', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-install'))
const panel = await openPluginsPanel()
diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts
index 5d718f3efe..f8bafcb699 100644
--- a/apps/web/tests/preview-boot.e2e.ts
+++ b/apps/web/tests/preview-boot.e2e.ts
@@ -437,18 +437,10 @@ async function bootPreview(origin: string, browser: Browser): Promise {
const sessions = page.getByRole('tree', { name: 'Sessions' })
const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE })
await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1)
- // The fixture's DSH_HOME carries two active Schedule tasks for this
- // Session, so the catalog the Schedule client half reads over the tunnel
- // must reach the row mark and the header entry. A composition without the
- // schedule overlay, or a Host that dropped the seeded storage, shows
- // neither.
- await showcase.locator('[data-session-schedule-mark]').waitFor({ timeout: 15_000 })
+ expect(await showcase.locator('[data-session-schedule-mark]').count()).toBe(0)
await showcase.click()
await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 })
- await expect.poll(
- async () => page.locator('[data-schedule-reminder-entry]').getAttribute('aria-label'),
- { timeout: 15_000 },
- ).toBe('2 reminders')
+ expect(await page.locator('[data-schedule-reminder-entry]').count()).toBe(0)
expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0)
// Complete Turns can fold while earlier history is still unloaded.
diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts
index 4c95f6f14a..96960dba6b 100644
--- a/apps/web/tests/ptc-round.e2e.ts
+++ b/apps/web/tests/ptc-round.e2e.ts
@@ -81,7 +81,8 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
await row.waitFor({ state: 'attached', timeout: 30_000 })
await expandOwningTurnProcess(page, row)
await row.waitFor({ state: 'visible' })
- await page.getByRole('button', { name: 'Preparing to run code', exact: true }).waitFor()
+ await row.getByText('Code', { exact: true }).waitFor()
+ expect(await row.getAttribute('data-tool')).toBe('run_code')
expect(sessionEvents.some(event => event.type === 'tool/call')).toBe(false)
expect(await row.getByRole('button').count()).toBe(0)
expect(await row.locator('pre').count()).toBe(0)
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index 7d9970e9cd..974d742353 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -734,7 +734,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise {
beforeAll(async () => {
scaffold = await launchWebScaffold({
- extraOverlayPath: fileURLToPath(new URL('./fixtures/time-context-every-step.patch.yml', import.meta.url)),
+ extraOverlayPath: [SCHEDULE_BUNDLE, TIME_CONTEXT_EVERY_STEP],
})
scaffold.ctx.effect(
() => scaffold.ctx.llm.registerAdapter([AFTER_PROVIDER], afterAdapter),
@@ -709,7 +712,9 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
beforeAll(async () => {
const fixture = await readFile(CATALOG_FIXTURE, 'utf8')
- scaffold = await launchWebScaffold()
+ scaffold = await launchWebScaffold({
+ extraOverlayPath: [SCHEDULE_BUNDLE, TIME_CONTEXT_EVERY_STEP],
+ })
await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard')
const records = foldScheduleEvents(fixture.trim().split('\n').slice(1).map(line => JSON.parse(line) as SessionEvent)).active
const domain = scaffold.ctx.storageDomain.get('schedule')
@@ -834,19 +839,20 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
it('reads stored tasks without a live Session and deletes them through the catalog', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog'))
- const shipped = composeEntries([
+ const layers = [
loadOverlayPatches('Schedule catalog shipped roster', BASE_PATCH),
...WEB_PATCHES.map(file => loadOverlayPatches('Schedule catalog shipped roster', file)),
- ])
- expect(shipped.find(entry => entry.id === 'ui-schedule')).toMatchObject({
- name: '@deepseek-ai/dsh-client-ui-schedule',
- })
- expect(shipped.find(entry => entry.id === 'ui-schedule')?.disabled).toBeUndefined()
+ ]
+ const shipped = composeEntries(layers)
+ const withBundle = composeEntries([...layers, loadOverlayPatches('Schedule catalog bundle', SCHEDULE_BUNDLE)])
for (const row of [
{ id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
{ id: 'schedule', name: '@deepseek-ai/dsh-schedule' },
+ { id: 'ui-schedule', name: '@deepseek-ai/dsh-client-ui-schedule' },
]) {
- expect(shipped.filter(entry => entry.id === row.id && entry.name === row.name)).toHaveLength(1)
+ // The shipped Web composition carries none of the rows; the bundle inserts each once, switched on.
+ expect(shipped.some(entry => entry.id === row.id)).toBe(false)
+ expect(withBundle.filter(entry => entry.id === row.id && entry.name === row.name && entry.disabled !== true)).toHaveLength(1)
}
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index 14278995c4..705098e2d4 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -71,6 +71,10 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.getByRole('button', { name: '工作区内修改' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
+ await dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
+ .getByRole('button', { name: '详细', exact: true }).waitFor({ timeout: 10_000 })
+ expect(await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'))
+ .not.toContain('transcriptView:')
const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
await openDocument.waitFor({ timeout: 10_000 })
let openRequests = 0
@@ -111,16 +115,17 @@ describe('web e2e: settings modal and General preferences', () => {
// rewrite this surface's golden.
await dialog.getByRole('button', { name: '内置插件', exact: true }).click()
await dialog.getByRole('heading', { name: '内置插件', exact: true }).waitFor({ timeout: 10_000 })
- // Both groups start collapsed; the preset group's header still carries its display-only switcher.
+ // The preset group starts open under its display-only switcher; the global group starts folded.
const presetSwitcher = dialog.getByRole('button', { name: '选择要查看的 Agent 预设' })
await presetSwitcher.waitFor({ timeout: 10_000 })
// The shipped default's zh display name comes from the zh dictionaries.
expect(await presetSwitcher.textContent()).toBe('标准模式(默认)')
const presetToggle = dialog.getByRole('button', { name: '会话插件', exact: true })
- expect(await presetToggle.getAttribute('aria-expanded')).toBe('false')
- expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBe(0)
- await presetToggle.click()
- await dialog.getByRole('button', { name: /^全局/ }).click()
+ expect(await presetToggle.getAttribute('aria-expanded')).toBe('true')
+ expect(await dialog.locator('[data-plugin-scope="preset"] [data-plugin-entry]').count()).toBeGreaterThan(0)
+ const globalToggle = dialog.getByRole('button', { name: /^全局/ })
+ expect(await globalToggle.getAttribute('aria-expanded')).toBe('false')
+ await globalToggle.click()
const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR)
await pluginRow.waitFor({ timeout: 10_000 })
const expectedPluginCount = [...scaffold.ctx.loader.entries()]
@@ -132,11 +137,12 @@ describe('web e2e: settings modal and General preferences', () => {
// presets took over included, preset compositions excluded.
expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count())
.toBe(expectedPluginCount)
- // The enablement tag is the row's collapsed status: an active fiber draws no
- // dot, so no global row names the active phase. Guard the assertion against
- // matching nothing because no row is enabled.
- expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]').count())
- .toBeGreaterThan(0)
+ // A plainly enabled row states its status only in its accessible name: it
+ // carries no tag, and an active fiber draws no dot, so no global row shows
+ // either. Guard the assertions against matching nothing because no row is enabled.
+ const enabledRows = dialog.locator('[data-plugin-scope="global"] [data-plugin-entry] button[aria-label$="已启用"]')
+ expect(await enabledRows.count()).toBeGreaterThan(0)
+ expect(await enabledRows.getByText('已启用', { exact: true }).count()).toBe(0)
expect(await dialog.locator('[data-plugin-scope="global"] [role="img"][aria-label="运行中"]').count()).toBe(0)
expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count'))
.toBe(String(expectedPluginCount))
@@ -159,10 +165,17 @@ describe('web e2e: settings modal and General preferences', () => {
] as const
for (const [entryId, status] of instanceRows) {
const row = dialog.locator(`[data-plugin-scope="preset"] [data-plugin-entry="${entryId}"]`)
- const trigger = row.getByRole('button', { name: `tool-subagent, ${entryId}, ${status}`, exact: true })
+ // An id that repeats the title adds nothing, so its card shows no chip and its name omits it.
+ const repeatsTitle = entryId === 'tool-subagent'
+ const name = repeatsTitle ? `tool-subagent, ${status}` : `tool-subagent, ${entryId}, ${status}`
+ const trigger = row.getByRole('button', { name, exact: true })
await trigger.waitFor({ timeout: 10_000 })
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
const identity = row.locator('code')
+ if (repeatsTitle) {
+ expect(await identity.count()).toBe(0)
+ continue
+ }
expect(await identity.textContent()).toBe(entryId)
expect(await identity.getAttribute('title')).toBe(entryId)
}
@@ -560,7 +573,7 @@ describe('web e2e: settings modal and General preferences', () => {
}, 90_000)
it.each([
- ['compact', '简洁'], ['detailed', '详细'], ['verbose', '完全展开'],
+ ['compact', '简洁'], ['standard', '标准'], ['verbose', '完全展开'],
] as const)('persists the %s work-details mode across reload', async (mode, label) => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
await openSettings(page, 'zh')
@@ -568,7 +581,7 @@ describe('web e2e: settings modal and General preferences', () => {
await dialog.waitFor({ timeout: 10_000 })
await dialog.getByText('工作步骤展示', { exact: true }).waitFor({ timeout: 10_000 })
const details = dialog.getByText('工作步骤展示', { exact: true }).locator('../..')
- await details.getByRole('button', { name: '标准', exact: true }).click()
+ await details.getByRole('button', { name: '详细', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await details.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
@@ -585,10 +598,10 @@ describe('web e2e: settings modal and General preferences', () => {
await restoredDetails.getByRole('button', { name: label, exact: true }).waitFor({ timeout: 10_000 })
await restoredDetails.getByRole('button', { name: label, exact: true }).click()
- await page.getByRole('menuitem', { name: '标准', exact: true }).click()
- await restoredDetails.getByRole('button', { name: '标准', exact: true }).waitFor({ timeout: 10_000 })
+ await page.getByRole('menuitem', { name: '详细', exact: true }).click()
+ await restoredDetails.getByRole('button', { name: '详细', exact: true }).waitFor({ timeout: 10_000 })
await expect.poll(async () => readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'), { timeout: 5_000 })
- .toContain('transcriptView: standard')
+ .toContain('transcriptView: detailed')
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 90_000)
@@ -798,7 +811,8 @@ describe('web e2e: settings modal and General preferences', () => {
await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
await openSettings(withoutBrowser, 'en')
const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' })
- await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor()
+ await dialog.getByText('Work details', { exact: true }).locator('../..')
+ .getByRole('button', { name: 'Detailed', exact: true }).waitFor()
expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0)
const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd, versionCapture)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE)
diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts
index 0b8617c16f..00dae46f4c 100644
--- a/apps/web/tests/shipped-composition.e2e.ts
+++ b/apps/web/tests/shipped-composition.e2e.ts
@@ -37,7 +37,7 @@ const AUTO_PROVIDER = 'shipped-auto-review-test'
const AUTO_MODEL = 'same-route'
const AUTO_CALL_ID = ToolCallId('shipped-auto-review-denied-delete')
const AUTO_RAW_REASON = ` direct user authorized inspection only\nTEST_ONLY_SECRET_${'x'.repeat(16_384)} `
-const AUTO_FINAL_TEXT = 'SHIPPED_AUTO_REVIEW_DENIAL_OBSERVED'
+const AUTO_FINAL_TEXT = 'SHIPPED_AUTO_REVIEW_REJECTION_OBSERVED'
const AUTO_CHILD_ONE_SHOT = 'AUTO_CHILD_ONE_SHOT'
const AUTO_CHILD_CONTINUABLE = 'AUTO_CHILD_CONTINUABLE'
const AUTO_CHILD_ADJUSTED = 'AUTO_CHILD_ADJUSTED'
@@ -489,10 +489,6 @@ const EXPECTED_TOOLS = [
'present',
'read',
'read_image',
- 'schedule_create',
- 'schedule_delete',
- 'schedule_list',
- 'schedule_update',
'send_message',
'skill',
'subagent',
@@ -734,7 +730,7 @@ it('lets a preset producer reach the background-job registry', async () => {
}
}, 120_000)
-it('routes one browser-authored Auto request through the same model before a real tool body', async () => {
+it('routes one browser-authored Auto request through the same model and asks the user after a denial', async () => {
scaffold = await launchWebScaffold(AUTO_REVIEW_FIXTURE)
const ctx = scaffold.ctx
const targetPath = join(scaffold.workspaceCwd, 'auto-review-pre-existing.txt')
@@ -744,6 +740,11 @@ it('routes one browser-authored Auto request through the same model before a rea
() => ctx.llm.registerAdapter([AUTO_PROVIDER], adapter),
'shipped Auto review same-route adapter',
)
+ const approvalReasons: Array = []
+ ctx.effect(() => ctx.on('approval/request', (request) => {
+ approvalReasons.push(request.reason)
+ return Promise.resolve('rejected' as const)
+ }, { prepend: true }), 'shipped Auto rejecting user')
const created = await remote<{ sessionId: string }>(scaffold, 'session/create', {
request: { cwd: scaffold.workspaceCwd },
@@ -792,8 +793,9 @@ it('routes one browser-authored Auto request through the same model before a rea
expect(reviewInput).toContain('PENDING_ACTION')
expect(reviewInput).toContain(requestId)
expect(reviewInput).toContain(targetPath)
+ expect(approvalReasons).toEqual([`Auto review denied tool "bash": ${AUTO_RAW_REASON}`])
const finalModelInput = JSON.stringify(finalMain?.messages)
- expect(finalModelInput).toContain('Auto review rejected tool \\"bash\\"; its body was not executed')
+ expect(finalModelInput).toContain('the user rejected tool \\"bash\\"')
expect(finalModelInput).not.toContain('direct user authorized inspection only')
expect(finalModelInput).not.toContain('TEST_ONLY_SECRET_')
@@ -809,13 +811,9 @@ it('routes one browser-authored Auto request through the same model before a rea
event.type === 'tool/result'
&& event.data.message.toolCallId === AUTO_CALL_ID
))
- expect(result?.data.error).toEqual({
- name: 'AutoReviewDeniedError',
- code: 'AUTO_REVIEW_DENIED',
- reason: AUTO_RAW_REASON,
- })
+ expect(result?.data.error).toBeUndefined()
const durableModelResult = JSON.stringify(result?.data.message)
- expect(durableModelResult).toContain('Auto review rejected tool \\"bash\\"; its body was not executed')
+ expect(durableModelResult).toContain('the user rejected tool \\"bash\\"')
expect(durableModelResult).not.toContain('direct user authorized inspection only')
expect(durableModelResult).not.toContain('TEST_ONLY_SECRET_')
expect(events.some(event => (
diff --git a/apps/web/tests/shortcuts-panels.e2e.ts b/apps/web/tests/shortcuts-panels.e2e.ts
index 9f3bd76b10..9968341f1b 100644
--- a/apps/web/tests/shortcuts-panels.e2e.ts
+++ b/apps/web/tests/shortcuts-panels.e2e.ts
@@ -23,9 +23,9 @@ async function bind(page: Page, primary: string, label: string, previous?: strin
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
- const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true })
- await remove.click()
- await remove.waitFor({ state: 'hidden' })
+ await dialog.getByRole('button', { name: `Edit shortcut for ${previous}`, exact: true }).click()
+ await dialog.getByRole('button', { name: 'Remove', exact: true }).click()
+ await dialog.getByRole('group').waitFor({ state: 'hidden' })
}
await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
const recorder = dialog.getByRole('group', { name: label, exact: true })
@@ -125,6 +125,19 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
await terminalGuide.focus()
expect(await page.getByRole('tooltip').count()).toBe(0)
+ const terminalEntry = panel.locator('[data-sidebar-right-guide-entry="terminal"]')
+ const shellMenu = terminalEntry.getByRole('button', { name: 'Choose shell', exact: true })
+ const titleBounds = await terminalEntry.getByText('New terminal', { exact: true }).boundingBox()
+ const arrowBounds = await shellMenu.boundingBox()
+ expect(arrowBounds!.x).toBeGreaterThanOrEqual(titleBounds!.x + titleBounds!.width)
+ expect(arrowBounds!.x - titleBounds!.x - titleBounds!.width).toBeLessThan(8)
+ await shellMenu.click()
+ await page.getByRole('menu').waitFor()
+ expect(await panel.locator('[data-sidebar-terminal]').count()).toBe(0)
+ await page.keyboard.press('Escape')
+ await page.getByRole('menu').waitFor({ state: 'detached' })
+ expect(await shellMenu.evaluate(element => element === document.activeElement)).toBe(true)
+
await bind(page, primary, 'Browser', 'Toggle right sidebar')
const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]')
await browserGuide.hover()
diff --git a/apps/web/tests/shortcuts-workspace.e2e.ts b/apps/web/tests/shortcuts-workspace.e2e.ts
index f269d80e55..e84cdc280d 100644
--- a/apps/web/tests/shortcuts-workspace.e2e.ts
+++ b/apps/web/tests/shortcuts-workspace.e2e.ts
@@ -61,8 +61,9 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
}
if (previous !== undefined) {
- await reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).click()
- await expect.poll(() => reference.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true }).count()).toBe(0)
+ await reference.getByRole('button', { name: `Edit shortcut for ${previous}`, exact: true }).click()
+ await reference.getByRole('button', { name: 'Remove', exact: true }).click()
+ await reference.getByRole('group').waitFor({ state: 'hidden' })
}
await reference.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
await page.keyboard.press('Meta+Shift+Comma')
diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts
index cbfc49732a..0bbe929b34 100644
--- a/apps/web/tests/shortcuts.e2e.ts
+++ b/apps/web/tests/shortcuts.e2e.ts
@@ -19,8 +19,8 @@ describe('web e2e: shortcut reference', () => {
afterAll(async () => { await browser?.close(); await scaffold?.close() })
it.each([
- { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' },
- { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' },
+ { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '编辑快捷键', search: '搜索快捷键', key: 'Meta' },
+ { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'Edit shortcuts', search: 'Search shortcuts', key: 'Control' },
])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => {
const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash'
const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } })
@@ -46,14 +46,13 @@ describe('web e2e: shortcut reference', () => {
const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true })
expect(await resetAll.isDisabled()).toBe(true)
expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 })
- expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 })
+ expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 440, height: 36 })
const rows = dialog.getByRole('listitem')
- const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '应用操作' : 'Application', exact: true })
+ const stopRow = dialog.getByRole('region', { name: locale === 'zh-CN' ? '消息输入' : 'Message input', exact: true })
.getByRole('listitem').last()
await stopRow.getByText(locale === 'zh-CN' ? '停止生成' : 'Stop generating', { exact: true }).waitFor()
const stopButtons = stopRow.getByRole('button')
- expect(await stopButtons.count()).toBe(1)
- expect(await stopButtons.isDisabled()).toBe(true)
+ expect(await stopButtons.count()).toBe(0)
expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height))))
.toEqual(new Set([42]))
expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42
@@ -72,29 +71,22 @@ describe('web e2e: shortcut reference', () => {
const dialogShadow = await dialog.evaluate(element => getComputedStyle(element).boxShadow)
await compareOrRefreshGolden(join(expected, `${locale}.expected.md`),
await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode)
- const fixedButtons = dialog.getByRole('button', { disabled: true })
- const fixedButton = fixedButtons.first()
- expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45')
- const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
- await fixedButton.hover()
- expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
+ const fixedBackground = await stopRow.evaluate(element => getComputedStyle(element).backgroundColor)
+ await stopRow.hover()
+ expect(await stopRow.evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground)
const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar'
- const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar'
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) })
const editButton = page.getByRole('button', { name: editLabel, exact: true })
- expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
+ expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('0')
const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText(
locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true },
) })
- const badge = boundRow.getByRole('button').last()
- const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)
- const restingColor = await badgeColor()
- await boundRow.locator(':scope > span').first().hover()
- expect(await badgeColor()).toBe(restingColor)
- await badge.hover()
- expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)')
- await boundRow.getByRole('button').first().hover()
- expect(await badgeColor()).toBe(restingColor)
+ const badge = boundRow.getByRole('button', { name: locale === 'zh-CN' ? '修改快捷键速查快捷键' : 'Edit shortcut for Open keyboard shortcuts', exact: true })
+ const restingColor = await boundRow.evaluate(element => getComputedStyle(element).backgroundColor)
+ await badge.hover({ position: { x: 12, y: 12 } })
+ expect(await boundRow.evaluate(element => getComputedStyle(element).backgroundColor)).not.toBe(restingColor)
+ expect(await boundRow.evaluate(element => getComputedStyle(element).borderRadius)).toBe('12px')
+ expect(await badge.boundingBox()).toEqual(await boundRow.boundingBox())
await badge.click()
await boundRow.getByRole('group').waitFor()
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
@@ -104,7 +96,7 @@ describe('web e2e: shortcut reference', () => {
await page.keyboard.press('Escape')
await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await dialog.getByRole('heading', { name: title, exact: true }).hover()
- const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity)
+ const boundActionsOpacity = () => boundRow.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)
expect(await boundActionsOpacity()).toBe('0')
expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true)
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
@@ -118,14 +110,17 @@ describe('web e2e: shortcut reference', () => {
await dialog.getByRole('heading', { name: title, exact: true }).hover()
expect(await boundActionsOpacity()).toBe('0')
await row.hover()
- expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1')
- expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1)
+ expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('1')
+ expect(await row.getByRole('button').count()).toBe(1)
await editButton.click()
const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true })
const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true })
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1)
expect(await page.getByRole('dialog').count()).toBe(2)
- expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
+ expect(await recorder.evaluate(element => ({
+ color: getComputedStyle(element).color, border: getComputedStyle(element).borderColor,
+ })))
+ .toEqual({ color: 'rgb(97, 102, 107)', border: 'rgba(0, 0, 0, 0.16)' })
expect(await recorder.evaluate(element => ({
focused: element === document.activeElement,
outline: getComputedStyle(element).outlineStyle, shadow: getComputedStyle(element).boxShadow,
@@ -155,7 +150,7 @@ describe('web e2e: shortcut reference', () => {
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
- expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
+ expect(await row.locator('[class*="rowActions"]').evaluate(element => getComputedStyle(element).opacity)).toBe('0')
await page.keyboard.press('Shift+Tab')
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
expect(await resetAll.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
@@ -168,12 +163,14 @@ describe('web e2e: shortcut reference', () => {
}))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' })
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)')
- await page.getByRole('button', { name: removeLabel, exact: true }).click()
+ await editButton.click()
+ await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click()
+ await inline.waitFor({ state: 'hidden' })
const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true })
await unbound.waitFor()
expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)')
- expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0)
- await unbound.click()
+ expect(await row.getByRole('button').count()).toBe(1)
+ await editButton.click({ position: { x: 12, y: 12 } })
await inline.waitFor()
expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0)
await page.keyboard.press('Escape')
@@ -182,7 +179,7 @@ describe('web e2e: shortcut reference', () => {
expect(await dialog.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await dialog.evaluate(element => getComputedStyle(element).boxShadow)).toBe(dialogShadow)
await dialog.getByRole('heading', { name: title, exact: true }).hover()
- expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0')
+ expect(await row.getByRole('button').count()).toBe(1)
expect(await page.getByRole('dialog').count()).toBe(2)
for (const query of ['abc', 'sendEnter']) {
await page.getByRole('searchbox').fill(query)
@@ -245,11 +242,13 @@ describe('web e2e: shortcut reference', () => {
await page.setViewportSize({ width: 1440, height: 1000 })
await openSettings(page, locale === 'zh-CN' ? 'zh' : 'en')
const viewButton = page.getByRole('button', { name: view, exact: true })
- const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
+ const viewHint = `${locale === 'zh-CN' ? '全局唤起查看' : 'Open from anywhere'} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}`
await viewButton.hover()
- expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
+ expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint)
await viewButton.click()
- await boundRow.getByRole('button').first().click()
+ await badge.click()
+ await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).click()
+ await boundRow.getByRole('group').waitFor({ state: 'hidden' })
await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor()
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
@@ -266,7 +265,7 @@ describe('web e2e: shortcut reference', () => {
await page.keyboard.press('Escape')
await dialog.waitFor({ state: 'hidden' })
await viewButton.hover()
- expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
+ expect(await page.getByRole('tooltip').getAttribute('aria-label')).toBe(viewHint)
await viewButton.click()
await page.getByRole('searchbox').fill('abc')
expect(await dialog.getByRole('listitem').count()).toBe(0)
@@ -286,7 +285,7 @@ describe('web e2e: shortcut reference', () => {
await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
await confirm.waitFor({ state: 'hidden' })
expect(await resetAll.isDisabled()).toBe(true)
- expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized')
+ expect(await dialog.locator('footer').textContent()).toBe(locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults')
expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true)
expect(await searchInput.evaluate(element => getComputedStyle(element).outlineStyle)).toBe('none')
expect(await searchSurfaceStyle()).toEqual(focusedSearchSurface)
@@ -322,6 +321,65 @@ describe('web e2e: shortcut reference', () => {
} finally { await context.close() }
})
+ it.each([
+ { platform: 'Win32', primary: 'Control', keys: 'Ctrl + Alt + N' },
+ { platform: 'MacIntel', primary: 'Meta', keys: '⌥ ⇧ ⌘ F12' },
+ ])('shows the shortcut inside New Session on hover and keyboard focus at wide and narrow widths on $platform', async ({ platform, primary, keys }) => {
+ const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
+ try {
+ await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
+ const page = await context.newPage()
+ await page.goto(scaffold.authenticatedUrl)
+ const newSession = page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' })
+ await newSession.waitFor()
+ if (platform === 'MacIntel') await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' })
+ await page.keyboard.press(`${primary}+/`)
+ await page.getByRole('button', { name: 'Edit shortcut for New Session', exact: true }).click()
+ await page.keyboard.press(platform === 'Win32' ? 'Control+Alt+N' : 'Meta+Alt+Shift+F12')
+ await page.getByRole('group', { name: 'New Session', exact: true }).waitFor({ state: 'hidden' })
+ await page.keyboard.press('Escape')
+ for (const colorScheme of ['light', 'dark'] as const) {
+ await page.emulateMedia({ colorScheme })
+ for (const width of [420, 264]) {
+ const handle = (await page.locator('[data-side="sidebar"]').boundingBox())!
+ await page.mouse.move(handle.x + handle.width / 2, 240)
+ await page.mouse.down()
+ await page.mouse.move(width, 240, { steps: 10 })
+ await page.mouse.up()
+ await expect.poll(() => newSession.locator('kbd').allTextContents()).toEqual(keys.split(' '))
+ const centerOffset = () => newSession.evaluate((element) => {
+ const button = element.getBoundingClientRect()
+ const content = element.querySelector('[class*="newSessionContent"]')!.getBoundingClientRect()
+ return Math.abs(button.x + button.width / 2 - content.x - content.width / 2)
+ })
+ expect(await centerOffset()).toBeLessThan(1)
+ const hintStyle = () => newSession.locator('[class*="newSessionShortcut"]').evaluate(element => ({
+ opacity: getComputedStyle(element).opacity, mask: getComputedStyle(element.previousElementSibling!).maskImage,
+ }))
+ expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' })
+ await newSession.hover()
+ expect(await centerOffset()).toBeLessThan(1)
+ const mask = await newSession.locator('[class*="newSessionLabelMask"]').boundingBox()
+ const hint = await newSession.locator('[class*="newSessionShortcut"]').boundingBox()
+ expect(mask!.x + mask!.width).toBeLessThanOrEqual(hint!.x + 1)
+ expect((await hintStyle()).opacity).toBe('1')
+ expect((await hintStyle()).mask).toContain('linear-gradient')
+ await newSession.screenshot({ path: fileURLToPath(new URL(`../../../.artifacts/new-session-${process.pid}-${platform}-${colorScheme}-${width}.png`, import.meta.url)) })
+ expect(await page.getByRole('tooltip').count()).toBe(0)
+ await page.mouse.move(700, 100)
+ expect(await hintStyle()).toEqual({ opacity: '0', mask: 'none' })
+ await newSession.focus()
+ await page.keyboard.press('Shift+Tab')
+ await page.keyboard.press('Tab')
+ expect(await newSession.evaluate(element => element.matches(':focus-visible'))).toBe(true)
+ expect((await hintStyle()).opacity).toBe('1')
+ expect(await page.getByRole('tooltip').count()).toBe(0)
+ await newSession.evaluate((element) => { element.blur() })
+ }
+ }
+ } finally { await context.close() }
+ })
+
it.each([
{ platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' },
{ platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' },
@@ -371,7 +429,8 @@ describe('web e2e: shortcut reference', () => {
await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await other.getByRole('button', { name: 'Settings', exact: true }).waitFor()
await other.keyboard.press(referenceKey)
- await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click()
+ await other.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click()
+ await other.getByRole('button', { name: 'Remove', exact: true }).click()
await editor.getByText('Shortcut configuration or available commands changed. Review the latest bindings before saving.', { exact: true }).waitFor()
await page.bringToFront()
await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus()
diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts
index e3c2a34c87..b389687677 100644
--- a/apps/web/tests/sidebar-right.e2e.ts
+++ b/apps/web/tests/sidebar-right.e2e.ts
@@ -1176,8 +1176,9 @@ describe('web e2e: shipped right Sidebar', () => {
}
expect(await terminalEntry.evaluate(node => getComputedStyle(node).overflow)).toBe('hidden')
const terminalActions = terminalEntry.getByRole('button')
- for (const action of await terminalActions.all()) {
- expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe('0px')
+ const terminalButtons = await terminalActions.all()
+ for (const [index, action] of terminalButtons.entries()) {
+ expect(await action.evaluate(node => getComputedStyle(node).borderRadius)).toBe(index === 0 ? '0px' : '4px')
await action.hover()
expect(await action.evaluate(node => getComputedStyle(node).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)')
}
diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts
index 556fd63e12..91725764e7 100644
--- a/apps/web/tests/sidebar-terminal.e2e.ts
+++ b/apps/web/tests/sidebar-terminal.e2e.ts
@@ -341,9 +341,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
const entry = page.locator('[data-sidebar-right-guide-entry="terminal"]')
await page.locator('[data-sidebar-right-guide]').screenshot({ path: `${shots}/terminal-guide.png`, animations: 'disabled' })
const selector = entry.getByRole('button', { name: 'Choose shell', exact: true })
- const cardBox = (await entry.boundingBox())!
+ const titleBox = (await entry.getByText('New terminal', { exact: true }).boundingBox())!
const triggerBox = (await selector.boundingBox())!
- expect(Math.abs(cardBox.x + cardBox.width - triggerBox.x - triggerBox.width)).toBeLessThanOrEqual(2)
+ expect(triggerBox.x).toBeGreaterThanOrEqual(titleBox.x + titleBox.width)
+ expect(triggerBox.x - titleBox.x - titleBox.width).toBeLessThan(8)
await page.emulateMedia({ colorScheme: 'dark' })
await selector.click()
await page.getByRole('menuitem', { name: 'bash', exact: true }).waitFor()
diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts
index e00c958e26..d808a2fb34 100644
--- a/apps/web/tests/turn-tail-actions.e2e.ts
+++ b/apps/web/tests/turn-tail-actions.e2e.ts
@@ -244,7 +244,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
}, 60_000)
it.skipIf(MODE === 'record').each([
- ['compact', 'Compact'], ['detailed', 'Detailed'], ['verbose', 'Verbose'],
+ ['compact', 'Compact'], ['standard', 'Standard'], ['verbose', 'Verbose'],
] as const)('applies whole-Turn presentation in %s mode', async (mode, label) => {
await launch()
onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
@@ -259,7 +259,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings' })
await dialog.getByText('Work details', { exact: true }).locator('../..')
- .getByRole('button', { name: 'Standard', exact: true }).click()
+ .getByRole('button', { name: 'Detailed', exact: true }).click()
await page.getByRole('menuitem', { name: label, exact: true }).click()
await page.keyboard.press('Escape')
diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts
index bbb270a1f8..82634fa7eb 100644
--- a/apps/web/tests/voice-input.e2e.ts
+++ b/apps/web/tests/voice-input.e2e.ts
@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFinished, vi } from 'vitest'
-import type { SpeechInput, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
+import type { SpeechInput, SpeechPreparationState, SpeechProviderId } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
import type {} from '@deepseek-ai/dsh-experimental-speech-to-text'
import {
captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, webSnapshotMode, watchConsole,
@@ -19,7 +19,7 @@ const interruptedExpected = fileURLToPath(new URL('../../../snapshots/web/voice-
const recordingExpected = fileURLToPath(new URL('../../../snapshots/web/voice-input/recording.expected.md', import.meta.url))
const bundle = fileURLToPath(new URL('../../../packages/experimental/voice-input-bundle', import.meta.url))
-it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submits only the reviewed transcript through Session replay', async () => {
+it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from standby and submits only the reviewed transcript through Session replay', async () => {
const scratch = await mkdtemp(join(tmpdir(), 'dsh-voice-browser-'))
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
onTestFinished(async () => {
@@ -40,11 +40,14 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi
expect(input.audio.byteLength).toBeGreaterThan(44)
return { text: prompt.slice(prefix.length), audioSeconds: 1, inferenceSeconds: 0.1 }
})
+ let preparation: SpeechPreparationState = { phase: 'unprepared' }
+ const readinessListeners = new Set<() => void>()
await scaffold.ctx.plugin({ inject: ['speechToText'], apply(ctx) {
ctx.effect(() => ctx.speechToText.register({
info: { id: 'sensevoice-local' as SpeechProviderId, name: 'Recorded recognizer', location: 'host-local', languages: ['auto'] },
preparation: {
- snapshot: () => ({ phase: 'standby' }), subscribe: () => () => {},
+ snapshot: () => preparation,
+ subscribe: (listener) => { readinessListeners.add(listener); return () => { readinessListeners.delete(listener) } },
prepare: () => { throw new Error('Cached resources must allow recording without preparation') },
cancel: async () => {},
},
@@ -68,6 +71,61 @@ it.skipIf(webSnapshotMode() === 'record')('records from cached standby and submi
await page.goto(scaffold.authenticatedUrl)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const input = page.locator('[data-composer-input]'), mic = page.getByRole('button', { name: 'Start recording', exact: true })
+ const setupMic = page.getByRole('button', { name: 'Open voice input setup', exact: true })
+ await input.fill(prefix)
+ await setupMic.click()
+ const setup = page.getByRole('dialog', { name: 'Set up voice input before recording', exact: true })
+ await setup.waitFor()
+ const setupAction = setup.getByRole('button', { name: 'Go to setup', exact: true })
+ await expect.poll(() => setupAction.evaluate(element => element === document.activeElement)).toBe(true)
+ await page.keyboard.type('NoDraftEdits')
+ expect(await input.innerText()).toBe(prefix)
+ expect(messages).toBe(0)
+ await page.keyboard.press('Tab')
+ expect(await setup.getByRole('button', { name: 'Cancel', exact: true }).evaluate(element => element === document.activeElement)).toBe(true)
+ await page.keyboard.press('Shift+Tab')
+ expect(await setupAction.evaluate(element => element === document.activeElement)).toBe(true)
+ await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/setup.expected.md', import.meta.url)),
+ await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
+ await setup.getByRole('button', { name: 'Later', exact: true }).click()
+ expect(await setup.count()).toBe(0)
+ expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
+ expect(recognize).not.toHaveBeenCalled()
+ preparation = { phase: 'checking', step: 'check', startedAt: 0 }
+ for (const listener of readinessListeners) listener()
+ await setupMic.click()
+ const unavailable = page.getByRole('dialog', { name: 'Speech recognition is not ready', exact: true })
+ await unavailable.waitFor()
+ await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/unavailable.expected.md', import.meta.url)),
+ await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
+ await page.setViewportSize({ width: 420, height: 900 })
+ const dialogBox = await unavailable.boundingBox()
+ expect(dialogBox!.x).toBeGreaterThanOrEqual(0)
+ expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(420)
+ await page.keyboard.press('Escape')
+ await unavailable.waitFor({ state: 'hidden' })
+ expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
+ expect(await input.innerText()).toBe(prefix)
+ preparation = { phase: 'failed', message: 'Model preparation failed' }
+ for (const listener of readinessListeners) listener()
+ await setupMic.click()
+ const detailsAction = unavailable.getByRole('button', { name: 'Open voice plugin settings', exact: true })
+ await expect.poll(() => detailsAction.evaluate(element => element === document.activeElement)).toBe(true)
+ await page.keyboard.press('Enter')
+ await page.setViewportSize({ width: 1280, height: 900 })
+ await page.locator('[data-plugin-detail="@deepseek-ai/dsh-experimental-voice-input-bundle"]')
+ .getByRole('button', { name: 'Retry preparation', exact: true }).waitFor()
+ expect(await unavailable.count()).toBe(0)
+ expect(messages).toBe(0)
+ expect(recognize).not.toHaveBeenCalled()
+ preparation = { phase: 'standby' }
+ for (const listener of readinessListeners) listener()
+ await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click()
+ await mic.waitFor()
+ await page.getByRole('button', { name: 'Plugins', exact: true }).click()
+ await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').waitFor()
+ expect(await page.locator('[data-plugin-detail]').count()).toBe(0)
+ await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click()
await mic.waitFor()
const micBox = await mic.boundingBox(), modelBox = await page.getByRole('button', { name: /^Select model, current/ }).boundingBox()
const sendBox = await page.getByRole('button', { name: 'Send message', exact: true }).boundingBox()
diff --git a/apps/web/tests/workspace-new-session-folding.e2e.ts b/apps/web/tests/workspace-new-session-folding.e2e.ts
index e23e0e9a80..9567153471 100644
--- a/apps/web/tests/workspace-new-session-folding.e2e.ts
+++ b/apps/web/tests/workspace-new-session-folding.e2e.ts
@@ -71,7 +71,7 @@ describe('web e2e: blank New Session folding quota', () => {
const sidebar = page.getByRole('tree', { name: 'Sessions' })
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 15_000 }).toBe(7)
expect(await sidebar.getByText('New Session', { exact: true }).count()).toBe(1)
- expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(6)
+ expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(5)
const showMore = sidebar.getByRole('button', { name: 'Show 11 more sessions' })
await showMore.waitFor({ timeout: 15_000 })
await compareOrRefreshGolden(
@@ -82,7 +82,7 @@ describe('web e2e: blank New Session folding quota', () => {
await showMore.click()
await expect.poll(() => sidebar.getByRole('treeitem').count(), { timeout: 10_000 }).toBe(12)
- expect(await sidebar.getByText(basename(scaffold.workspaceCwd), { exact: true }).count()).toBe(11)
+ expect(await sidebar.getByText('Untitled', { exact: true }).count()).toBe(10)
await compareOrRefreshGolden(
join(EXPECTED_DIR, 'first-batch.expected.md'),
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd),
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index ffd6681b4e..2f2bd905ef 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -163,6 +163,7 @@
"tests/sidebar-subagent-activity.e2e.ts",
"tests/background-job-list.e2e.ts",
"tests/live-job-stream.e2e.ts",
+ "tests/auto-review-approval.e2e.ts",
"tests/auto-review-denial.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.e2e.ts",
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index efc66e50ef..06758c387e 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -24,8 +24,8 @@
en: 214c3c81c21df6f9
zh: 75d2cdc3c9fc9930
/deepseek-harness-architecture/turn-flow:
- en: e13500f6d5b614d6
- zh: 7e6acb96a520ded2
+ en: badc0ffea030cdce
+ zh: 3241f0b37c3ec067
/deepseek-harness-architecture/session-log:
en: e1e3a44140036f25
zh: 84c70c969e5ad216
diff --git a/docs/architecture.md b/docs/architecture.md
index 0919d65a30..b5dfb6571b 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -114,6 +114,8 @@ One inbox feeds the driver; injected context waits for a waking message. AgentLo
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
+Failed steps [record missing tool results](../packages/core/agent-loop/README.md#understand-the-implementation).
+
Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
## Session log
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index bf3fb3b649..c7fdc96a90 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -118,6 +118,8 @@ turn/end
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
+失败步骤会[记录缺失的工具结果](../packages/core/agent-loop/README.zh.md#understand-the-implementation)。
+
详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
## 会话日志
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index a20cbf9cde..abf47a1663 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.i18n.yaml
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
- en: 0d14d210d41c6770
- zh: 964e92962b7cfa88
+ en: 00ee1236e43f5538
+ zh: aed1f351bf657eea
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 21b4a8205b..4d6fd35fa2 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -112,11 +112,15 @@ flowchart LR
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization Authorization flow registry"]
+ pkg_client_product_analytics["client-product-analytics"]
+ svc_productAnalytics["ctx.productAnalytics Desktop interaction collection"]
+ pkg_otel["otel"]
+ svc_otel["ctx.otel Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry Product usage event sender"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry Session telemetry seam"]
- pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -296,6 +300,7 @@ flowchart LR
pkg_client_connection --> svc_connection
pkg_client_file_upload --> svc_fileUploads
pkg_client_modules --> svc_clientModules
+ pkg_client_product_analytics --> svc_productAnalytics
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
pkg_command_feedback --> svc_sessionFeedback
pkg_commands --> svc_commands
@@ -348,6 +353,7 @@ flowchart LR
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
+ pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
@@ -472,6 +478,8 @@ flowchart LR
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
+ svc_otel --> pkg_host_product_telemetry_otel
+ svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
@@ -605,6 +613,8 @@ flowchart LR
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The Host owns browser authorization and local credentials; UI consumers receive state without tokens. |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
+| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | Accepts selected Desktop events, enriches available login identity, and observes live compaction under the live Host collection policy. |
+| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | Product analytics and Session feedback adapters create independent reporting channels through one injected service. |
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | Exports explicitly submitted analytics events through OTLP/HTTP; mounting alone collects nothing. |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index 996fc1b863..2068e7f369 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -114,11 +114,15 @@ flowchart LR
pkg_api_account_controller["api-account-controller"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization Authorization flow registry"]
+ pkg_client_product_analytics["client-product-analytics"]
+ svc_productAnalytics["ctx.productAnalytics Desktop interaction collection"]
+ pkg_otel["otel"]
+ svc_otel["ctx.otel Shared OTel reporting channels"]
pkg_host_product_telemetry_otel["host-product-telemetry-otel"]
+ pkg_session_telemetry_otel["session-telemetry-otel"]
svc_productTelemetry["ctx.productTelemetry Product usage event sender"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry Session telemetry seam"]
- pkg_session_telemetry_otel["session-telemetry-otel"]
pkg_storage["storage"]
svc_storage["ctx.storage Non-session storage hub"]
pkg_storage_json["storage-json"]
@@ -298,6 +302,7 @@ flowchart LR
pkg_client_connection --> svc_connection
pkg_client_file_upload --> svc_fileUploads
pkg_client_modules --> svc_clientModules
+ pkg_client_product_analytics --> svc_productAnalytics
pkg_client_ui_plugin_manager --> svc_pluginRegistryProbe
pkg_command_feedback --> svc_sessionFeedback
pkg_commands --> svc_commands
@@ -350,6 +355,7 @@ flowchart LR
pkg_mcp_resources --> svc_mcpResources
pkg_message_feedback --> svc_messageFeedback
pkg_office_to_pdf --> svc_officeToPdf
+ pkg_otel --> svc_otel
pkg_permission_presets --> svc_permissionPresets
pkg_plan_mode --> svc_planMode
pkg_plugin_manager --> svc_pluginManager
@@ -474,6 +480,8 @@ flowchart LR
svc_lsp --> pkg_tool_lsp
svc_mcpResources --> pkg_mcp_resources
svc_officeToPdf --> pkg_client_ui_sidebar_documentpreview
+ svc_otel --> pkg_host_product_telemetry_otel
+ svc_otel --> pkg_session_telemetry_otel
svc_pluginManager --> pkg_plugin_manager
svc_pluginManager --> pkg_ui_settings_plugin_inventory
svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager
@@ -607,6 +615,8 @@ flowchart LR
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 |
| `ctx.deepseekAccount` | `seam` | [`deepseek-account`](../packages/credentials/deepseek-account) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | [`api-account-controller`](../packages/api/account-controller), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Host 负责浏览器授权和本地凭证;UI 使用方只接收不含 token 的状态。 |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
+| `ctx.productAnalytics` | `service` | [`client-product-analytics`](../packages/client/product-analytics) | - | - | - | 根据 Host 动态采集策略接收指定桌面事件、补充可用登录身份并监听实时压缩。 |
+| `ctx.otel` | `service` | [`otel`](../packages/telemetry/otel) | - | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel), [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | 产品埋点和 Session 反馈适配器通过同一个注入服务创建独立上报通道。 |
| `ctx.productTelemetry` | `service` | [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | - | - | - | 通过 OTLP/HTTP 发送显式提交的分析事件;仅挂载插件不会采集信息。 |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index b2bb047e82..cf43f07cb8 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -493,6 +493,26 @@ export interface Config {
```
+
+
+
+## `@deepseek-ai/dsh-client-product-analytics`
+
+- `inject`: `deepseekAccount` · `productTelemetry`
+- `refs`: `Volatile` (`@deepseek-ai/cordis`)
+- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
+
+```ts config-catalog
+/** Application-owned collection policy; no user settings surface. */
+export interface Config {
+ /** Live application collection policy; ordinary Web does not mount this service. */
+ enabled: Volatile
+ /** Running Desktop release, absent when unavailable. */
+ appVersion?: string
+}
+```
+
+
@@ -1435,7 +1455,8 @@ export interface Config {
## `@deepseek-ai/dsh-host-product-telemetry-otel`
-- `source`: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts)
+- `inject`: `otel`
+- `source`: [`packages/host/product-telemetry-otel/src/index.ts:18`](../packages/host/product-telemetry-otel/src/index.ts)
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
@@ -2139,7 +2160,7 @@ export interface Config {
- `inject`: `shell` · `approval` · `sessions` · `sessionProjections`
- `refs`: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) · `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/interaction/permission-presets/src/index.ts:155`](../packages/interaction/permission-presets/src/index.ts)
+- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts)
```ts config-catalog
/** The {@link PermissionPresetService} config: preset table and composition default. */
@@ -2536,13 +2557,19 @@ export interface JsonRpcConfig {
## `@deepseek-ai/dsh-session-log-deepseek`
- `inject`: `deepseekLlmApiExtensions` · `sessions`
-- `source`: [`packages/session/session-log-deepseek/src/index.ts:38`](../packages/session/session-log-deepseek/src/index.ts)
+- `source`: [`packages/session/session-log-deepseek/src/index.ts:39`](../packages/session/session-log-deepseek/src/index.ts)
```ts config-catalog
/** Session-log request contribution configuration. */
export interface Config {
/** Contribute `dsh_session_log` to official DeepSeek requests. Defaults to `true`. */
enabled?: boolean
+ /**
+ * Largest serialized `dsh_session_log` field, in UTF-8 bytes, that one request carries.
+ * A request uploads the longest pending event prefix that fits; later requests continue
+ * after its acceptance. Defaults to 8 MiB.
+ */
+ maxBytes?: number
}
```
@@ -2695,36 +2722,36 @@ export interface Config {
## `@deepseek-ai/dsh-session-telemetry-otel`
-- `inject`: `sessions`
+- `inject`: `sessions` · `otel`
- `refs`: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
-- `source`: [`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
+- `source`: [`packages/session/session-telemetry-otel/src/index.ts:87`](../packages/session/session-telemetry-otel/src/index.ts)
```ts config-catalog
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option objects,
- * and one DSH-owned shutdown bound. Uploading modes validate their endpoint
+ * Plugin configuration: sharing policy, SDK transport options, byte/count queue
+ * settings, and an overall shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
mode?: SessionTelemetryMode
/**
- * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
- * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
- * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
- * is the one field this package requires and validates itself.
+ * Explicit SDK HTTP transport settings, including optional routing headers.
+ * Ambient credentials are not inherited. URL is required while uploading.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
- * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
- * which this plugin fills); the SDK owns and documents these knobs.
+ * Count, queue, cadence, and per-request watchdog settings for the byte-bounded
+ * processor. A watchdog warning never releases an unsettled transport slot.
*/
processor?: Omit
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
+ /** Uncompressed OTLP request byte limit, at most 4,000,000. */
+ maxRequestBytes?: number
}
/** Session-sharing policy selected by {@link Config.mode}. */
@@ -3454,7 +3481,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-bash-persistent`
- `inject`: `tools` · `terminals`
-- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts)
+- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:444`](../packages/shell/tool-bash-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent Bash tool. */
@@ -3658,7 +3685,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-pwsh-persistent`
- `inject`: `tools` · `terminals`
-- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
+- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:456`](../packages/shell/tool-pwsh-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent pwsh tool. */
@@ -3740,7 +3767,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-str-replace-editor`
- `inject`: `tools` · `fs`
-- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts)
+- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts)
```ts config-catalog
/** Configuration for the string-replacement editor tool. */
@@ -3951,7 +3978,7 @@ export interface Config {
## `@deepseek-ai/dsh-tools`
- `inject`: `systemPrompt`
-- `source`: [`packages/core/tools/src/index.ts:673`](../packages/core/tools/src/index.ts)
+- `source`: [`packages/core/tools/src/index.ts:674`](../packages/core/tools/src/index.ts)
```ts config-catalog
/** Plugin config: how the registered tools are presented to the model. */
@@ -4115,7 +4142,7 @@ export interface Config {
- `inject`: `web`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts)
+- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
```ts config-catalog
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
@@ -4335,7 +4362,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) |
| `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) |
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
-| `@deepseek-ai/dsh-experimental-auto-review` | `llm` · `permissionPresets` · `sessions` · `tools` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
+| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
@@ -4348,6 +4375,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
+| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
| `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) |
@@ -4418,6 +4446,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) |
| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) |
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
+| `@deepseek-ai/dsh-experimental-schedule-bundle` | — | [`packages/experimental/schedule-bundle/src/index.ts`](../packages/experimental/schedule-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 689cefb76d..e9e22b4c7f 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -495,6 +495,26 @@ export interface Config {
```
+
+
+
+## `@deepseek-ai/dsh-client-product-analytics`
+
+- `inject`: `deepseekAccount` · `productTelemetry`
+- `refs`: `Volatile` (`@deepseek-ai/cordis`)
+- `source`: [`packages/client/product-analytics/src/index.ts:14`](../packages/client/product-analytics/src/index.ts)
+
+```ts config-catalog
+/** Application-owned collection policy; no user settings surface. */
+export interface Config {
+ /** Live application collection policy; ordinary Web does not mount this service. */
+ enabled: Volatile
+ /** Running Desktop release, absent when unavailable. */
+ appVersion?: string
+}
+```
+
+
@@ -1437,7 +1457,8 @@ export interface Config {
## `@deepseek-ai/dsh-host-product-telemetry-otel`
-- `source`: [`packages/host/product-telemetry-otel/src/index.ts:37`](../packages/host/product-telemetry-otel/src/index.ts)
+- `inject`: `otel`
+- `source`: [`packages/host/product-telemetry-otel/src/index.ts:18`](../packages/host/product-telemetry-otel/src/index.ts)
```ts config-catalog
/** Collector routing, application identity, and bounded in-memory batch settings. */
@@ -2141,7 +2162,7 @@ export interface Config {
- `inject`: `shell` · `approval` · `sessions` · `sessionProjections`
- `refs`: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) · `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/interaction/permission-presets/src/index.ts:155`](../packages/interaction/permission-presets/src/index.ts)
+- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts)
```ts config-catalog
/** The {@link PermissionPresetService} config: preset table and composition default. */
@@ -2538,13 +2559,19 @@ export interface JsonRpcConfig {
## `@deepseek-ai/dsh-session-log-deepseek`
- `inject`: `deepseekLlmApiExtensions` · `sessions`
-- `source`: [`packages/session/session-log-deepseek/src/index.ts:38`](../packages/session/session-log-deepseek/src/index.ts)
+- `source`: [`packages/session/session-log-deepseek/src/index.ts:39`](../packages/session/session-log-deepseek/src/index.ts)
```ts config-catalog
/** Session-log request contribution configuration. */
export interface Config {
/** Contribute `dsh_session_log` to official DeepSeek requests. Defaults to `true`. */
enabled?: boolean
+ /**
+ * Largest serialized `dsh_session_log` field, in UTF-8 bytes, that one request carries.
+ * A request uploads the longest pending event prefix that fits; later requests continue
+ * after its acceptance. Defaults to 8 MiB.
+ */
+ maxBytes?: number
}
```
@@ -2697,36 +2724,36 @@ export interface Config {
## `@deepseek-ai/dsh-session-telemetry-otel`
-- `inject`: `sessions`
+- `inject`: `sessions` · `otel`
- `refs`: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`)
-- `source`: [`packages/session/session-telemetry-otel/src/index.ts:100`](../packages/session/session-telemetry-otel/src/index.ts)
+- `source`: [`packages/session/session-telemetry-otel/src/index.ts:87`](../packages/session/session-telemetry-otel/src/index.ts)
```ts config-catalog
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option objects,
- * and one DSH-owned shutdown bound. Uploading modes validate their endpoint
+ * Plugin configuration: sharing policy, SDK transport options, byte/count queue
+ * settings, and an overall shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
export interface Config {
/** Defaults to `FEEDBACK_ONLY`: capture session history only when feedback is explicitly submitted. */
mode?: SessionTelemetryMode
/**
- * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete
- * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`,
- * `compression`, `keepAlive`, …), owned and documented by the SDK. `url`
- * is the one field this package requires and validates itself.
+ * Explicit SDK HTTP transport settings, including optional routing headers.
+ * Ambient credentials are not inherited. URL is required while uploading.
*/
exporter?: OTLPExporterNodeConfigBase & {
/** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */
url?: string
}
/**
- * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot,
- * which this plugin fills); the SDK owns and documents these knobs.
+ * Count, queue, cadence, and per-request watchdog settings for the byte-bounded
+ * processor. A watchdog warning never releases an unsettled transport slot.
*/
processor?: Omit
/** Maximum time spent awaiting the SDK provider's complete shutdown path. */
shutdownTimeoutMillis?: number
+ /** Uncompressed OTLP request byte limit, at most 4,000,000. */
+ maxRequestBytes?: number
}
/** Session-sharing policy selected by {@link Config.mode}. */
@@ -3456,7 +3483,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-bash-persistent`
- `inject`: `tools` · `terminals`
-- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:443`](../packages/shell/tool-bash-persistent/src/index.ts)
+- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:444`](../packages/shell/tool-bash-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent Bash tool. */
@@ -3660,7 +3687,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-pwsh-persistent`
- `inject`: `tools` · `terminals`
-- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:455`](../packages/shell/tool-pwsh-persistent/src/index.ts)
+- `source`: [`packages/shell/tool-pwsh-persistent/src/index.ts:456`](../packages/shell/tool-pwsh-persistent/src/index.ts)
```ts config-catalog
/** Configuration for the persistent pwsh tool. */
@@ -3742,7 +3769,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-str-replace-editor`
- `inject`: `tools` · `fs`
-- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:505`](../packages/fs/tool-str-replace-editor/src/index.ts)
+- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts)
```ts config-catalog
/** Configuration for the string-replacement editor tool. */
@@ -3953,7 +3980,7 @@ export interface Config {
## `@deepseek-ai/dsh-tools`
- `inject`: `systemPrompt`
-- `source`: [`packages/core/tools/src/index.ts:673`](../packages/core/tools/src/index.ts)
+- `source`: [`packages/core/tools/src/index.ts:674`](../packages/core/tools/src/index.ts)
```ts config-catalog
/** Plugin config: how the registered tools are presented to the model. */
@@ -4117,7 +4144,7 @@ export interface Config {
- `inject`: `web`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts)
+- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
```ts config-catalog
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
@@ -4337,7 +4364,7 @@ export interface Config {
| `@deepseek-ai/dsh-config-editor` | `loader` · `profileContext` | [`packages/boot/config-editor/src/index.ts`](../packages/boot/config-editor/src/index.ts) |
| `@deepseek-ai/dsh-cordis-client-runner` | — | [`packages/extensions/cordis-client-runner/src/index.ts`](../packages/extensions/cordis-client-runner/src/index.ts) |
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
-| `@deepseek-ai/dsh-experimental-auto-review` | `llm` · `permissionPresets` · `sessions` · `tools` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
+| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
@@ -4350,6 +4377,7 @@ export interface Config {
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
| `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) |
| `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) |
+| `@deepseek-ai/dsh-otel` | — | [`packages/telemetry/otel/src/index.ts`](../packages/telemetry/otel/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) |
| `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) |
| `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) |
@@ -4420,6 +4448,7 @@ export interface Config {
| `@deepseek-ai/dsh-deque` | — | [`packages/util/deque/src/index.ts`](../packages/util/deque/src/index.ts) |
| `@deepseek-ai/dsh-experimental-agent-team-profile` | — | [`packages/experimental/agent-team-profile/src/index.ts`](../packages/experimental/agent-team-profile/src/index.ts) |
| `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) |
+| `@deepseek-ai/dsh-experimental-schedule-bundle` | — | [`packages/experimental/schedule-bundle/src/index.ts`](../packages/experimental/schedule-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-voice-input-bundle` | — | [`packages/experimental/voice-input-bundle/src/index.ts`](../packages/experimental/voice-input-bundle/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-packer` | — | [`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts) |
| `@deepseek-ai/dsh-experimental-webworker-runtime` | — | [`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts) |
diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml
index 3510441d73..64bde5b974 100644
--- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml
+++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml
@@ -12,14 +12,14 @@
en: 740e92e903ecb594
zh: f934d3c9e5c56bfe
/official-deepseek-llm-api-wire-extensions/body-extension-transaction:
- en: 8c05b525a3f1358e
- zh: 4c5bccf12f000038
+ en: f0b1443c364a431c
+ zh: 16d16ddf5c451e8e
/official-deepseek-llm-api-wire-extensions/dsh-plugin-packages:
en: 49d6f011e9d985a7
zh: bbf61b4e4480c555
/official-deepseek-llm-api-wire-extensions/dsh-session-log:
- en: 5dc19f72ee3db8ab
- zh: df9997b805cd01fa
+ en: de1a3f2c377d74e3
+ zh: 7c803345f71117c5
/official-deepseek-llm-api-wire-extensions/dsh-session-log/wire-session-header:
en: ccd8fcd773df21c4
zh: 7ea020068ad093ae
@@ -27,8 +27,8 @@
en: 008a21df5e314154
zh: 8855710a37542ec5
/official-deepseek-llm-api-wire-extensions/dsh-session-log/acceptance-watermark-and-at-least-once-delivery:
- en: 64ba792a2a6af122
- zh: 0f560c3610e07e81
+ en: 4d84cb1e61f9cf58
+ zh: 8a63ce3069e2f0b3
/official-deepseek-llm-api-wire-extensions/exposure-and-receiver-requirements:
- en: 9000a8295ff540ac
- zh: b56802d83a04175d
+ en: ae93d9a44245b12f
+ zh: 8b95fc66c6e653c2
diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md
index 47ecc9db54..790b51fd88 100644
--- a/docs/deepseek-llm-api-wire-extensions.md
+++ b/docs/deepseek-llm-api-wire-extensions.md
@@ -34,7 +34,7 @@ Credential failure happens before anonymous-user-id resolution, so an unauthoriz
The adapter serializes the complete base body, including the exact `messages`, before it asks registered providers to prepare fields. A provider receives that immutable body, the request cancellation signal, and optional `sessionId` and auxiliary-call `purpose`. Returning `undefined` omits that provider's field for the request.
-Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. Preparation or collision failure prevents the request. A composition without the registry sends the unextended base body.
+Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. Preparation or collision failure prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body.
After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure fails the model request even though the endpoint returned 2xx. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension.
@@ -73,7 +73,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
## `dsh_session_log`
-[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is enabled by default. It applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field, and a composition disables it with `enabled: false`. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md).
+[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.md) contributes one contiguous suffix of the canonical Session log. The field is enabled by default. It applies to a request with a live Session and at least one event; a direct request, a stale Session id, or an empty log omits the field, as does a request whose first pending event alone exceeds `maxBytes` or cannot be serialized; a composition disables it with `enabled: false`. The examples below use logical Session format 2 only to illustrate the wire fields; they do not identify the [current writer format](session-format-status.md).
```json
{
@@ -110,7 +110,7 @@ An enabled inventory with no qualifying entries sends `packages: []`; disabling
| `throughSeq` | non-negative integer | Greatest sequence represented by this request |
| `events` | array | Contiguous events from `afterSeq + 1` through `throughSeq` |
-The first upload uses `afterSeq: -1` and carries the complete current log. Each later upload starts after the greatest accepted watermark for the same Session id. The sender snapshots the event array once per request; appends after that snapshot belong to a later request.
+The first upload uses `afterSeq: -1`, and each later upload starts after the greatest accepted watermark for the same Session id. Each upload carries the longest contiguous run from that point whose serialized field fits `maxBytes`, 8 MiB by default and counted in UTF-8 bytes including the header and numeric fields, so a backlog above the limit spans several accepted requests. The sender snapshots the event array once per request; appends after that snapshot belong to a later request.
### Wire Session header
@@ -151,12 +151,12 @@ After the endpoint returns HTTP 2xx, the contribution appends this canonical eve
`delivery-accepted` means that the configured endpoint returned HTTP 2xx for the containing LLM request. It does not assert SSE completion or remote persistence. The event's `throughSeq` must identify an earlier event, its `sessionId` identifies the Session whose suffix was sent, and `sessionFormatVersion` binds the watermark to that exact logical generation. Absence means historical format v0.
-The sender folds the greatest matching `throughSeq` for the current Session id and format generation, so concurrent accepted requests cannot move the cursor backward and a watermark from another generation cannot authorize the current suffix. A resumed process rebuilds the cursor from the durable log. A fork ignores inherited watermarks that name another Session, and therefore sends its own complete inherited prefix before advancing under the child id. The watermark event itself belongs to the next unsent suffix.
+The sender folds the greatest matching `throughSeq` for the current Session id and format generation, so concurrent accepted requests cannot move the cursor backward and a watermark from another generation cannot authorize the current suffix. A resumed process rebuilds the cursor from the durable log. A fork ignores inherited watermarks that name another Session, and therefore uploads its own inherited prefix from sequence zero, in requests bounded by `maxBytes`, before advancing under the child id. The watermark event itself belongs to the next unsent suffix.
-Transport and non-2xx failures append no watermark. A crash after endpoint acceptance but before local persistence may resend an already accepted range; uncertainty produces duplicates, never a sequence gap. There is no independent upload store, size cap, or truncation path.
+Transport failures, non-2xx responses, and requests sent without extension fields after a serialization failure append no watermark. A crash after endpoint acceptance but before local persistence may resend an already accepted range; uncertainty produces duplicates, never a sequence gap. There is no independent upload store or truncation path; `maxBytes` bounds each request's field instead.
## Exposure and receiver requirements
The request headers expose the Harness application version, one anonymous Harness-home identity, and an optional Session identity. `dsh_plugin_packages` exposes active npm package names and versions. Unless a composition disables it, `dsh_session_log` may expose the Session working directory, system-prompt snapshots, user and Assistant content, embedded Assistant streams, failed-attempt output, tool arguments and results, compaction summaries, feedback, and plugin-owned events. Adapter API keys are not Session events and therefore do not enter the field. A gateway selected through `baseURL` receives the same values as the official endpoint.
-Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. A session-log receiver validates the contiguous sequence range before interpreting event types. An unrecognized canonical event without `ignorable: true` prevents lossless reconstruction. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request.
+Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. A session-log receiver validates the contiguous sequence range before interpreting event types. An unrecognized canonical event without `ignorable: true` prevents lossless reconstruction. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request. While a session-log backlog drains, `throughSeq` trails the Session's latest event, so a 2xx does not show that the receiver holds the current log.
diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md
index d79f97fc3a..08be493fcc 100644
--- a/docs/deepseek-llm-api-wire-extensions.zh.md
+++ b/docs/deepseek-llm-api-wire-extensions.zh.md
@@ -34,7 +34,7 @@
适配器先序列化包括确切 `messages` 在内的完整基础正文,再让已注册提供方准备字段。提供方会收到该不可变正文、请求取消信号,以及可选的 `sessionId` 和辅助调用 `purpose`。提供方返回 `undefined` 时,本次请求会省略其字段。
-系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。准备失败或冲突会阻止请求。组合未挂载注册表时,适配器发送未经扩展的基础正文。
+系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。准备失败或冲突会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。
已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。即使端点返回 2xx,接受失败仍会使模型请求失败。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。
@@ -73,7 +73,7 @@
## `dsh_session_log`
-[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认启用。它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段,组合可用 `enabled: false` 禁用它。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。
+[`@deepseek-ai/dsh-session-log-deepseek`](../packages/session/session-log-deepseek/README.zh.md) 贡献权威会话日志的一段连续后缀。该字段默认启用。它适用于携带存活会话且至少存在一个事件的请求;直接请求、陈旧会话 id 或空日志会省略该字段,第一条待发事件本身超过 `maxBytes` 或无法序列化的请求也会省略该字段;组合可用 `enabled: false` 禁用它。下方示例使用逻辑 Session 格式 2 仅为说明协议字段,并不标识[当前写入格式](session-format-status.zh.md)。
```json
{
@@ -110,7 +110,7 @@
| `throughSeq` | 非负整数 | 本次请求所表示的最大序号 |
| `events` | 数组 | 从 `afterSeq + 1` 到 `throughSeq` 的连续事件 |
-首次上传使用 `afterSeq: -1`,并携带当前的完整日志。此后每次上传都从同一会话 id 的最大已接受水位(watermark)之后开始。发送方为每次请求仅快照一次事件数组;快照后的追加内容属于后续请求。
+首次上传使用 `afterSeq: -1`,此后每次上传都从同一会话 id 的最大已接受水位(watermark)之后开始。每次上传携带从该位置起、序列化后能放进 `maxBytes` 的最长连续事件段;`maxBytes` 默认 8 MiB,按 UTF-8 字节计算,包括 header 与数值字段,因此超过上限的积压会分多次已接受的请求传完。发送方为每次请求仅快照一次事件数组;快照后的追加内容属于后续请求。
### Session 协议 header
@@ -151,12 +151,12 @@
`delivery-accepted` 表示已配置端点为包含该字段的 LLM 请求返回 HTTP 2xx。它不表示 SSE 已完整结束,也不表示远端已经持久化。该事件的 `throughSeq` 必须标识一项更早的事件,`sessionId` 标识已发送后缀所属的 Session,`sessionFormatVersion` 则把水位绑定到该逻辑 generation。缺少该字段表示历史格式 v0。
-发送方只会为当前 Session id 与格式 generation 折叠最大的匹配 `throughSeq`,因此并发已接受请求无法使游标倒退,其他 generation 的水位也不能授权当前后缀。恢复后的进程会从持久日志重建游标。fork 会忽略命名其他 Session 的继承水位,因此先发送自身完整的继承前缀,再以子会话 id 推进。水位事件自身属于下一段未发送后缀。
+发送方只会为当前 Session id 与格式 generation 折叠最大的匹配 `throughSeq`,因此并发已接受请求无法使游标倒退,其他 generation 的水位也不能授权当前后缀。恢复后的进程会从持久日志重建游标。fork 会忽略命名其他 Session 的继承水位,因此从序号零开始、按 `maxBytes` 分多次请求上传自身的继承前缀,再以子会话 id 推进。水位事件自身属于下一段未发送后缀。
-传输失败和非 2xx 响应不会追加水位。端点接受后、本地持久化前发生崩溃时,系统可能重新发送已接受范围;不确定性只会产生重复,绝不会产生序号缺口。系统没有独立上传存储、大小上限或截断路径。
+传输失败、非 2xx 响应,以及序列化失败后不带扩展字段发出的请求,都不会追加水位。端点接受后、本地持久化前发生崩溃时,系统可能重新发送已接受范围;不确定性只会产生重复,绝不会产生序号缺口。系统没有独立上传存储或截断路径;`maxBytes` 负责限制每次请求的字段大小。
## 暴露内容与接收方要求
请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。除非组合禁用该字段,`dsh_session_log` 可能暴露会话工作目录、系统提示词快照、用户与 Assistant 内容、嵌入式 Assistant stream、失败 attempt 输出、工具参数与结果、压缩摘要、反馈和插件持有的事件。适配器 API key 不是会话事件,因此不会进入该字段。通过 `baseURL` 选择的网关会收到与官方端点相同的值。
-接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。遇到不带 `ignorable: true` 的未知权威事件时,接收方无法进行无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。
+接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。遇到不带 `ignorable: true` 的未知权威事件时,接收方无法进行无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。会话日志积压补传期间,`throughSeq` 落后于会话的最新事件,因此 2xx 并不表示接收方已持有当前完整日志。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 5312354d60..4385771ec8 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -60,10 +60,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
-| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
@@ -102,7 +102,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
-| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
+| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 6baa07d2d2..b3edadf12e 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -62,10 +62,10 @@
| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
-| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
+| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:77`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:86`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
@@ -104,7 +104,7 @@
| `internal/service` | - | [`agent-preset-registry`](../packages/preset/agent-preset-registry), `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
-| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `speech-to-text` |
+| `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 6b3b887df7..0da940458a 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -154,6 +154,7 @@ flowchart TD
pkg_client_hmr["client-hmr"]
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
+ pkg_client_product_analytics["client-product-analytics"]
pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"]
@@ -258,6 +259,7 @@ flowchart TD
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
+ pkg_experimental_schedule_bundle["experimental-schedule-bundle"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
@@ -394,6 +396,9 @@ flowchart TD
pkg_subprocess_local["subprocess-local"]
pkg_win32_process["win32-process"]
end
+ subgraph group_telemetry["packages/telemetry"]
+ pkg_otel["otel"]
+ end
subgraph group_terminal["packages/terminal"]
pkg_terminal["terminal"]
pkg_terminal_bash["terminal-bash"]
@@ -509,6 +514,7 @@ flowchart TD
pkg_settings --> pkg_brand
pkg_settings --> pkg_invariants
pkg_settings --> pkg_session
+ pkg_otel --> pkg_session
pkg_session_snapshot --> pkg_http_proxy
pkg_session_snapshot --> pkg_session
pkg_fs --> pkg_brand
@@ -521,6 +527,7 @@ flowchart TD
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
+ pkg_host_product_telemetry_otel --> pkg_otel
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -626,11 +633,6 @@ flowchart TD
pkg_goal --> pkg_session
pkg_goal --> pkg_session_projection
pkg_goal --> pkg_typert_protocol
- pkg_web_search_deepseek --> pkg_agent
- pkg_web_search_deepseek --> pkg_credentials
- pkg_web_search_deepseek --> pkg_launch_environment
- pkg_web_search_deepseek --> pkg_session
- pkg_web_search_deepseek --> pkg_web
pkg_file_reference --> pkg_agent
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
@@ -718,6 +720,12 @@ flowchart TD
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
+ pkg_web_search_deepseek --> pkg_agent
+ pkg_web_search_deepseek --> pkg_credentials
+ pkg_web_search_deepseek --> pkg_deepseek_account
+ pkg_web_search_deepseek --> pkg_launch_environment
+ pkg_web_search_deepseek --> pkg_session
+ pkg_web_search_deepseek --> pkg_web
pkg_api_account_controller --> pkg_agent
pkg_api_account_controller --> pkg_deepseek_account
pkg_api_account_controller --> pkg_typert_protocol
@@ -808,6 +816,7 @@ flowchart TD
pkg_tool_fs_search --> pkg_timeout
pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs
+ pkg_tool_str_replace_editor --> pkg_output_retention
pkg_tool_str_replace_editor --> pkg_sandbox
pkg_tool_str_replace_editor --> pkg_sandbox_policy
pkg_tool_str_replace_editor --> pkg_tools
@@ -945,10 +954,12 @@ flowchart TD
pkg_shell_env --> pkg_shell
pkg_shell_env --> pkg_tools
pkg_tool_bash_persistent --> pkg_agent
+ pkg_tool_bash_persistent --> pkg_output_retention
pkg_tool_bash_persistent --> pkg_terminal
pkg_tool_bash_persistent --> pkg_timeout
pkg_tool_bash_persistent --> pkg_tools
pkg_tool_pwsh_persistent --> pkg_agent
+ pkg_tool_pwsh_persistent --> pkg_output_retention
pkg_tool_pwsh_persistent --> pkg_terminal
pkg_tool_pwsh_persistent --> pkg_timeout
pkg_tool_pwsh_persistent --> pkg_tools
@@ -1033,6 +1044,7 @@ flowchart TD
pkg_session_telemetry_otel --> pkg_command_feedback
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_message_feedback
+ pkg_session_telemetry_otel --> pkg_otel
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_tool_bash --> pkg_agent
@@ -1391,6 +1403,7 @@ flowchart TD
| [`client-hmr`](../packages/client/hmr) | `client` | — |
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
+| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
@@ -1449,6 +1462,7 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
+| [`experimental-schedule-bundle`](../packages/experimental/schedule-bundle) | `experimental` | — |
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
@@ -1457,7 +1471,6 @@ flowchart TD
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
-| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
@@ -1517,12 +1530,14 @@ flowchart TD
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
+| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1550,7 +1565,6 @@ flowchart TD
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
-| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1570,6 +1584,7 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
@@ -1586,7 +1601,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-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-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`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-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `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) |
@@ -1616,8 +1631,8 @@ flowchart TD
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
-| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1632,7 +1647,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index b3ea2e5ff7..e023865c6b 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -156,6 +156,7 @@ flowchart TD
pkg_client_hmr["client-hmr"]
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
+ pkg_client_product_analytics["client-product-analytics"]
pkg_client_resources["client-resources"]
pkg_client_shortcuts["client-shortcuts"]
pkg_client_store["client-store"]
@@ -260,6 +261,7 @@ flowchart TD
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
pkg_experimental_inspector["experimental-inspector"]
pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"]
+ pkg_experimental_schedule_bundle["experimental-schedule-bundle"]
pkg_experimental_speech_to_text["experimental-speech-to-text"]
pkg_experimental_speech_to_text_sensevoice["experimental-speech-to-text-sensevoice"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
@@ -396,6 +398,9 @@ flowchart TD
pkg_subprocess_local["subprocess-local"]
pkg_win32_process["win32-process"]
end
+ subgraph group_telemetry["packages/telemetry"]
+ pkg_otel["otel"]
+ end
subgraph group_terminal["packages/terminal"]
pkg_terminal["terminal"]
pkg_terminal_bash["terminal-bash"]
@@ -511,6 +516,7 @@ flowchart TD
pkg_settings --> pkg_brand
pkg_settings --> pkg_invariants
pkg_settings --> pkg_session
+ pkg_otel --> pkg_session
pkg_session_snapshot --> pkg_http_proxy
pkg_session_snapshot --> pkg_session
pkg_fs --> pkg_brand
@@ -523,6 +529,7 @@ flowchart TD
pkg_hmr --> pkg_app_boot
pkg_hmr --> pkg_cmdline
pkg_experimental_speech_to_text --> pkg_settings
+ pkg_host_product_telemetry_otel --> pkg_otel
pkg_ptc_runtime --> pkg_sandbox
pkg_sandbox_local --> pkg_llm
pkg_sandbox_local --> pkg_sandbox
@@ -628,11 +635,6 @@ flowchart TD
pkg_goal --> pkg_session
pkg_goal --> pkg_session_projection
pkg_goal --> pkg_typert_protocol
- pkg_web_search_deepseek --> pkg_agent
- pkg_web_search_deepseek --> pkg_credentials
- pkg_web_search_deepseek --> pkg_launch_environment
- pkg_web_search_deepseek --> pkg_session
- pkg_web_search_deepseek --> pkg_web
pkg_file_reference --> pkg_agent
pkg_time_context --> pkg_agent
pkg_time_context --> pkg_invariants
@@ -720,6 +722,12 @@ flowchart TD
pkg_fs_sandbox --> pkg_fs_local
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
+ pkg_web_search_deepseek --> pkg_agent
+ pkg_web_search_deepseek --> pkg_credentials
+ pkg_web_search_deepseek --> pkg_deepseek_account
+ pkg_web_search_deepseek --> pkg_launch_environment
+ pkg_web_search_deepseek --> pkg_session
+ pkg_web_search_deepseek --> pkg_web
pkg_api_account_controller --> pkg_agent
pkg_api_account_controller --> pkg_deepseek_account
pkg_api_account_controller --> pkg_typert_protocol
@@ -810,6 +818,7 @@ flowchart TD
pkg_tool_fs_search --> pkg_timeout
pkg_tool_fs_search --> pkg_tools
pkg_tool_str_replace_editor --> pkg_fs
+ pkg_tool_str_replace_editor --> pkg_output_retention
pkg_tool_str_replace_editor --> pkg_sandbox
pkg_tool_str_replace_editor --> pkg_sandbox_policy
pkg_tool_str_replace_editor --> pkg_tools
@@ -947,10 +956,12 @@ flowchart TD
pkg_shell_env --> pkg_shell
pkg_shell_env --> pkg_tools
pkg_tool_bash_persistent --> pkg_agent
+ pkg_tool_bash_persistent --> pkg_output_retention
pkg_tool_bash_persistent --> pkg_terminal
pkg_tool_bash_persistent --> pkg_timeout
pkg_tool_bash_persistent --> pkg_tools
pkg_tool_pwsh_persistent --> pkg_agent
+ pkg_tool_pwsh_persistent --> pkg_output_retention
pkg_tool_pwsh_persistent --> pkg_terminal
pkg_tool_pwsh_persistent --> pkg_timeout
pkg_tool_pwsh_persistent --> pkg_tools
@@ -1035,6 +1046,7 @@ flowchart TD
pkg_session_telemetry_otel --> pkg_command_feedback
pkg_session_telemetry_otel --> pkg_llm
pkg_session_telemetry_otel --> pkg_message_feedback
+ pkg_session_telemetry_otel --> pkg_otel
pkg_session_telemetry_otel --> pkg_session
pkg_session_telemetry_otel --> pkg_session_telemetry
pkg_tool_bash --> pkg_agent
@@ -1393,6 +1405,7 @@ flowchart TD
| [`client-hmr`](../packages/client/hmr) | `client` | — |
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
+| [`client-product-analytics`](../packages/client/product-analytics) | `client` | — |
| [`client-resources`](../packages/client/resources) | `client` | — |
| [`client-shortcuts`](../packages/client/shortcuts) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
@@ -1451,6 +1464,7 @@ flowchart TD
| [`client-web`](../packages/client/web) | `client` | — |
| [`office-to-pdf`](../packages/document/office-to-pdf) | `document` | — |
| [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — |
+| [`experimental-schedule-bundle`](../packages/experimental/schedule-bundle) | `experimental` | — |
| [`experimental-voice-input-bundle`](../packages/experimental/voice-input-bundle) | `experimental` | — |
| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — |
| [`client-ui-cordis`](../packages/extensions/ui-cordis) | `extensions` | — |
@@ -1459,7 +1473,6 @@ flowchart TD
| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | — |
| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | — |
| [`host-open-in-app`](../packages/host/open-in-app) | `host` | — |
-| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | — |
| [`host-webserver`](../packages/host/webserver) | `host` | — |
| [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — |
| [`session-format`](../packages/session/session-format) | `session` | — |
@@ -1519,12 +1532,14 @@ flowchart TD
| [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`session-projection`](../packages/session/session-projection) | `session` | [`session`](../packages/core/session) |
| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
+| [`otel`](../packages/telemetry/otel) | `telemetry` | [`session`](../packages/core/session) |
| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`session`](../packages/core/session) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
+| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
| [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | `ptc-runtime` | [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
@@ -1552,7 +1567,6 @@ flowchart TD
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
-| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
@@ -1572,6 +1586,7 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) |
| [`api-account-controller`](../packages/api/account-controller) | `api` | [`agent`](../packages/core/agent), [`deepseek-account`](../packages/credentials/deepseek-account), [`typert-protocol`](../packages/typert/protocol) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) | `credentials` | [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`host-webserver`](../packages/host/webserver) |
@@ -1588,7 +1603,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-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-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`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-workspace-dependencies`](../packages/skill/tool-workspace-dependencies) | `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) |
@@ -1618,8 +1633,8 @@ flowchart TD
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
-| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
-| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1634,7 +1649,7 @@ flowchart TD
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) |
-| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
+| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 000e032a8e..6e6a1802d1 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -1116,8 +1116,8 @@
en: 93c4403f94ecdc14
zh: 2b2eb0c400881d71
/session-persistence-event-catalog/resolved-persistence-types/sandboxmode:
- en: 13071a796a3bfcd2
- zh: 93e5fbb1cdf78a79
+ en: c9fa5343ea99733b
+ zh: d6de41351c57a73f
/session-persistence-event-catalog/resolved-persistence-types/schedulechange:
en: 16be53be76c16720
zh: 38fd164dde86ccac
@@ -1218,8 +1218,8 @@
en: f43a635a14568780
zh: 8d22226b045122eb
/session-persistence-event-catalog/resolved-persistence-types/toolrestriction:
- en: 6dfef3196845f287
- zh: cc9adf5c47cdea85
+ en: b6f6001a25bcbf82
+ zh: bd2b3c9fe0ad2ba8
/session-persistence-event-catalog/resolved-persistence-types/toolresultmessage:
en: 2258683c3cfa4ce3
zh: 5663aa76c911761b
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index cdf607da2b..b9a4241eef 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -4307,7 +4307,7 @@ One of:
SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
-Sources: [`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts)
+Sources: [`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts)
One of:
@@ -4932,7 +4932,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts)
SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f`
-Sources: [`packages/core/tools/src/index.ts:699`](../packages/core/tools/src/index.ts)
+Sources: [`packages/core/tools/src/index.ts:700`](../packages/core/tools/src/index.ts)
| Property | Presence | Type |
|---|---|---|
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index 146d6a3b51..139a1fb20b 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -4309,7 +4309,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6`
SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
-来源:[`packages/sandbox/sandbox/src/index.ts:29`](../packages/sandbox/sandbox/src/index.ts)
+来源:[`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts)
以下类型之一:
@@ -4934,7 +4934,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8`
SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f`
-来源:[`packages/core/tools/src/index.ts:699`](../packages/core/tools/src/index.ts)
+来源:[`packages/core/tools/src/index.ts:700`](../packages/core/tools/src/index.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json
index 7472359902..cde6e5c422 100644
--- a/docs/persistence-schema.json
+++ b/docs/persistence-schema.json
@@ -36213,7 +36213,7 @@
"packages/core/tools/src/index.ts#ToolRestriction"
],
"sources": [
- "packages/core/tools/src/index.ts:699"
+ "packages/core/tools/src/index.ts:700"
]
},
{
@@ -93879,7 +93879,7 @@
"packages/sandbox/sandbox/src/index.ts#SandboxMode"
],
"sources": [
- "packages/sandbox/sandbox/src/index.ts:29"
+ "packages/sandbox/sandbox/src/index.ts:30"
]
},
{
diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml
index a7934d3e58..7051478b51 100644
--- a/docs/subsystems/README.i18n.yaml
+++ b/docs/subsystems/README.i18n.yaml
@@ -3,5 +3,5 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
/subsystems:
- en: 58466f6fd60755eb
- zh: ab46a374f5a3c3ed
+ en: ab6cd11804f6b644
+ zh: 7a82dc5b22c40703
diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md
index 479dbe7cb3..e2f8dc51e2 100644
--- a/docs/subsystems/README.md
+++ b/docs/subsystems/README.md
@@ -67,6 +67,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [conversation.md](conversation.md) | target-neutral Session-event assembly: Context identity, Location data, replay paths, view builders, and target-owned render nodes |
| [session-projection.md](session-projection.md) | the projection seam: `SessionProjectionMap`, the pure `ProjectionDefinition` unit, `ProjectionSnapshot`'s consistent cut, the change feed |
| [session-telemetry.md](session-telemetry.md) | the outbound session-reporting capability seam: `SessionTelemetryRecord`/`SessionTelemetrySeverity`, the `SessionTelemetrySink` contract, and the `session-telemetry/record` redact waterfall |
+| [otel.md](otel.md) | Shared ordinary-event and Session-log OTLP channels |
| [product-telemetry.md](product-telemetry.md) | Explicit product analytics submission and OTLP/HTTP transport |
> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services and events use each page's generated **Cordis API** section.
diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md
index a6b9226df6..31f4489d43 100644
--- a/docs/subsystems/README.zh.md
+++ b/docs/subsystems/README.zh.md
@@ -67,6 +67,7 @@
| [conversation.md](conversation.zh.md) | 目标无关的会话事件组装:上下文标识、位置数据、回放路径、视图构建器与目标自有的渲染节点 |
| [session-projection.md](session-projection.zh.md) | 投影 seam:`SessionProjectionMap`、纯函数 `ProjectionDefinition` 单元、`ProjectionSnapshot` 的一致切面、变更馈送 |
| [session-telemetry.md](session-telemetry.zh.md) | 对外会话上报能力 seam:`SessionTelemetryRecord`/`SessionTelemetrySeverity`、`SessionTelemetrySink` 约定和 `session-telemetry/record` 脱敏 waterfall(瀑布式事件) |
+| [otel.md](otel.zh.md) | 共享普通事件与 Session 日志 OTLP 通道 |
| [product-telemetry.md](product-telemetry.zh.md) | 显式产品分析事件提交与 OTLP/HTTP 传输 |
> 这些页面上的类型声明及其 JSDoc 与源码等价,并由 `pnpm run verify-type-equiv` 检查漂移(见 [development.md](../development.zh.md#documenting-types-verbatim-ts-type-equiv))。普通块保留完整声明;`public-api` 块保留去除实现体的公开 class 声明。Cordis 服务与事件使用每页生成的 **Cordis API** 小节。
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index bf68f51a77..15c70ebca6 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -432,7 +432,8 @@ currentSelection(): ModelSelection
/**
* Save the complete default model selection. A deployment without a configuration
- * editor keeps its composition entry.
+ * editor keeps its composition entry. Saves commit in submission order; a failed
+ * save rejects its caller without blocking later saves.
* @param next - resolved selection accepted by an entry point.
* @returns fulfillment after the optional profile write settles.
*/
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index 02a7b4620c..03de9ddb7b 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -442,7 +442,8 @@ currentSelection(): ModelSelection
/**
* Save the complete default model selection. A deployment without a configuration
- * editor keeps its composition entry.
+ * editor keeps its composition entry. Saves commit in submission order; a failed
+ * save rejects its caller without blocking later saves.
* @param next - resolved selection accepted by an entry point.
* @returns fulfillment after the optional profile write settles.
*/
diff --git a/docs/subsystems/credentials.md b/docs/subsystems/credentials.md
index decd5b088d..eb708f95f9 100644
--- a/docs/subsystems/credentials.md
+++ b/docs/subsystems/credentials.md
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
*/
abstract getPlatformSession(): Promise
+
+/**
+ * Read existing login identity without creating a device or returning credentials.
+ * @returns optional device/account identifiers and the provider's OS version string.
+ */
+abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
```
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
diff --git a/docs/subsystems/credentials.zh.md b/docs/subsystems/credentials.zh.md
index b649f3c1ff..9a6ce9f3fa 100644
--- a/docs/subsystems/credentials.zh.md
+++ b/docs/subsystems/credentials.zh.md
@@ -356,6 +356,12 @@ abstract rejectToken(token: string): Promise
* @returns a Host-only snapshot, or null while signed out or when the credential changed during the read.
*/
abstract getPlatformSession(): Promise
+
+/**
+ * Read existing login identity without creating a device or returning credentials.
+ * @returns optional device/account identifiers and the provider's OS version string.
+ */
+abstract getDeviceIdentity(): Promise<{ deviceId?: string; userId?: AccountUserId; osVersion: string }>
```
Source: [`packages/credentials/deepseek-account/src/index.ts`](../../packages/credentials/deepseek-account/src/index.ts)
diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml
index 7368d1d4cf..a1d22cc698 100644
--- a/docs/subsystems/llm-streaming.i18n.yaml
+++ b/docs/subsystems/llm-streaming.i18n.yaml
@@ -42,8 +42,8 @@
en: 8b6e6b65ccea943f
zh: bce1edd6386e37b1
/llm-streaming/official-deepseek-request-extensions:
- en: fcddc08940bcbeff
- zh: cbde6a8fe00d402f
+ en: b19f291f094157ed
+ zh: 682f13a833e8bb52
/llm-streaming/service-and-provider-contracts:
en: 8dc79d4cc82824c2
zh: 3ebabd3a117ad717
diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md
index aeebd3011d..a8c7b47bd7 100644
--- a/docs/subsystems/llm-streaming.md
+++ b/docs/subsystems/llm-streaming.md
@@ -774,7 +774,7 @@ interface LlmCallConfigAdapterDefaults {
## Official DeepSeek request extensions
-`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. Preparation, collision, and acceptance failures use `REQUEST_EXTENSION` and fail the model request.
+`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. Preparation, collision, and acceptance failures use `REQUEST_EXTENSION` and fail the model request. A merged body that fails to serialize is sent without extension fields; acceptance is skipped and the provider plugin logs the omitted field names.
The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_session_log`](../../packages/session/session-log-deepseek/README.md) as a lossless incremental canonical-log suffix and [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. These fields remain outside model messages and are absent from the pi-ai adapter path.
diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md
index c7e5473284..89148283d9 100644
--- a/docs/subsystems/llm-streaming.zh.md
+++ b/docs/subsystems/llm-streaming.zh.md
@@ -780,7 +780,7 @@ interface LlmCallConfigAdapterDefaults {
## DeepSeek 官方请求扩展
-`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。准备、冲突与接受失败会使用 `REQUEST_EXTENSION`,并使模型请求失败。
+`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。准备、冲突与接受失败会使用 `REQUEST_EXTENSION`,并使模型请求失败。合并后的正文无法序列化时,请求不带扩展字段发出,跳过接受,并由提供方插件记录被省略的字段名。
[协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_session_log`](../../packages/session/session-log-deepseek/README.zh.md) 注册为无损增量权威日志后缀,并将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。这些字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。
diff --git a/docs/subsystems/otel.i18n.yaml b/docs/subsystems/otel.i18n.yaml
new file mode 100644
index 0000000000..7547678647
--- /dev/null
+++ b/docs/subsystems/otel.i18n.yaml
@@ -0,0 +1,7 @@
+# Bilingual-pair consistency record for otel.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/subsystems/otel.md
+/otel-reporting:
+ en: adf67758d37c46f2
+ zh: 9e41b63531fa875a
diff --git a/docs/subsystems/otel.md b/docs/subsystems/otel.md
new file mode 100644
index 0000000000..bcba92f51b
--- /dev/null
+++ b/docs/subsystems/otel.md
@@ -0,0 +1,44 @@
+# OTel reporting
+
+English | [中文](otel.zh.md)
+
+The [OTel plugin](../../packages/telemetry/otel/README.md) registers `ctx.otel`, a shared factory for independent ordinary-event and Session-log channels. It owns SDK transport and batching implementations. Business consumers inject the service, supply explicit transport/resource/scope options, and own each returned channel's shutdown.
+
+UI feedback reaches the Session feedback services through their existing Remote APIs. Feedback recording authorizes the Session adapter's canonical-prefix capture and redaction before it reports through an OTel channel. Ordinary product consumers submit selected fields through the product adapter. The shared service has no automatic identity or capture policy, and channels never share queues or requests.
+
+The [product adapter](../../packages/host/product-telemetry-otel/README.md) and [Session adapter](../../packages/session/session-telemetry-otel/README.md) retain their deployment configuration and disposal deadlines. Consumers must not reuse a channel after shutdown. Mounting only the OTel service allocates no provider or transport.
+
+
+
+
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.otel` — `OTel`
+
+Shared transport provider. Mounting creates no queue, identity, or network connection.
+
+```ts cordis-catalog
+/**
+ * Create an independent ordinary-event channel with count-based batching.
+ * The injected consumer must drain it during its fiber disposal.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel; no state is shared with other channels.
+ */
+createEventReporter(options: EventLogOptions): EventLogReporter
+
+/**
+ * Create an independent byte-bounded Session-log channel.
+ * Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel, preserving complete accepted events within the request byte ceiling.
+ */
+createSessionLogReporter(options: SessionLogOptions): SessionLogReporter
+```
+
+Source: [`packages/telemetry/otel/src/index.ts`](../../packages/telemetry/otel/src/index.ts)
+
diff --git a/docs/subsystems/otel.zh.md b/docs/subsystems/otel.zh.md
new file mode 100644
index 0000000000..dbe7f3cb9e
--- /dev/null
+++ b/docs/subsystems/otel.zh.md
@@ -0,0 +1,44 @@
+# OTel 上报
+
+[English](otel.md) | 中文
+
+[OTel 插件](../../packages/telemetry/otel/README.zh.md) 注册 `ctx.otel`,作为独立普通事件通道和 Session 日志通道的共享工厂。它持有 SDK 传输与聚合实现。业务调用方注入服务,显式提供传输、resource 和 scope 选项,并负责返回通道的关闭。
+
+UI 反馈通过现有 Remote API 到达 Session 反馈服务。记录反馈后,Session 适配器才获得规范日志前缀的捕获授权,完成脱敏后通过 OTel 通道上报。普通产品调用方通过产品适配器提交选定字段。共享服务没有自动身份或捕获策略,通道之间不共享队列或请求。
+
+[产品适配器](../../packages/host/product-telemetry-otel/README.zh.md) 和 [Session 适配器](../../packages/session/session-telemetry-otel/README.zh.md) 保留各自部署配置及销毁期限。通道关闭后调用方不得复用。只挂载 OTel 服务不会分配 provider 或 transport。
+
+
+
+
+
+## Cordis API
+
+Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+
+### `ctx.otel` — `OTel`
+
+Shared transport provider. Mounting creates no queue, identity, or network connection.
+
+```ts cordis-catalog
+/**
+ * Create an independent ordinary-event channel with count-based batching.
+ * The injected consumer must drain it during its fiber disposal.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel; no state is shared with other channels.
+ */
+createEventReporter(options: EventLogOptions): EventLogReporter
+
+/**
+ * Create an independent byte-bounded Session-log channel.
+ * Authorization and redaction precede reporting; the consumer owns shutdown and its outer deadline.
+ * @param options - transport, scope, resource, queue, and diagnostic settings selected by the consumer.
+ * @returns the caller-owned channel, preserving complete accepted events within the request byte ceiling.
+ */
+createSessionLogReporter(options: SessionLogOptions): SessionLogReporter
+```
+
+Source: [`packages/telemetry/otel/src/index.ts`](../../packages/telemetry/otel/src/index.ts)
+
diff --git a/docs/subsystems/permission-presets.i18n.yaml b/docs/subsystems/permission-presets.i18n.yaml
index 08abc08e1f..c1e89222ed 100644
--- a/docs/subsystems/permission-presets.i18n.yaml
+++ b/docs/subsystems/permission-presets.i18n.yaml
@@ -9,11 +9,11 @@
en: a89ee42910e0c4c9
zh: 5207c0de66e6e358
/permission-presets/fixed-current-session-auto-registration:
- en: d6b272474101109d
- zh: a48dcec7a0e0ddb5
+ en: c51707eb55b7ee05
+ zh: e62fea09c3c2324b
/permission-presets/current-preset-and-the-derived-custom:
- en: 1f32a35df085c708
- zh: d7c635ea6df5f56d
+ en: 43a7b661b747091f
+ zh: ee6b4f1ddb8cf6f2
/permission-presets/switching-and-the-permission-preset-event:
en: 71924a3f5f717704
zh: 0a7ecbbf8d801a32
diff --git a/docs/subsystems/permission-presets.md b/docs/subsystems/permission-presets.md
index d843fab949..564b304997 100644
--- a/docs/subsystems/permission-presets.md
+++ b/docs/subsystems/permission-presets.md
@@ -46,13 +46,13 @@ The service requires a confining `ctx.shell` executor and `ctx.approval`, and mi
## Fixed current-session Auto registration
-The Auto integration calls `registerAuto(admit)` for its effect lifetime. This service fixes the `auto` identity and its `danger-full-access` plus `never` bundle; the shipped client locale dictionaries own Auto's label and description, while configured preset presentation remains Host-owned. Callers cannot publish another preset through a generic contribution API. Auto appears after configured presets, never enters the `permission.defaultPreset` settings schema, and disappears when the effect is disposed. The synchronous `admit` callback runs before Auto selection mutates the Session and before a stored Auto Session publishes, so a missing or closing integration does not rewrite the durable identity.
+The Auto integration calls `registerAuto(admit)` for its effect lifetime. This service fixes the `auto` identity and its `danger-full-access` plus `ask` bundle, and a recorded Auto selection also matches the `never` policy that delegated children pin; the shipped client locale dictionaries own Auto's label and description, while configured preset presentation remains Host-owned. Callers cannot publish another preset through a generic contribution API. Auto appears after configured presets, never enters the `permission.defaultPreset` settings schema, and disappears when the effect is disposed. The synchronous `admit` callback runs before Auto selection mutates the Session and before a stored Auto Session publishes, so a missing or closing integration does not rewrite the durable identity.
Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification. Process consumers subscribe before calling `catalog()`, then re-read the complete selectable catalog after each notification. The `permissions` Session projection contains only `currentValue`, so catalog changes append no Session event, publish no Session projection frame, and leave the Session sequence unchanged.
## Current preset and the derived `custom`
-`current(session)` derives the effective preset from the required `permissions` projection. The unit folds the session's sandbox mode, approval policy, and recorded selection; values absent within that state fall back to the executor's configured mode and the approval service config, then `ask`. A missing projection key fails explicitly. The service prefers a still-matching selection, then the first matching configured entry, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload.
+`current(session)` derives the effective preset from the required `permissions` projection. The unit folds the session's sandbox mode, approval policy, and recorded selection; values absent within that state fall back to the executor's configured mode and the approval service config, then `ask`. A missing projection key fails explicitly. The service prefers a still-matching selection, including a recorded Auto selection under the `never` approval policy, then the first matching configured entry, and otherwise returns `CUSTOM_PRESET` (`'custom'`). `custom` is derived-only: clients may display it as the current value, but it is never a switch target or an event payload.
`names` lists configured presets in declaration order followed by Auto while its integration is live. `catalog()` returns those selectable entries as one process-level snapshot. `optionOf(name)` builds an available entry (its label falls back to the key) or the derived `custom` presentation, and throws for any other name. Clients join the catalog with the Session projection; `custom` may label the current value but never becomes a catalog entry.
@@ -105,7 +105,8 @@ registerAuto(admit: () => void): () => Promise
/**
* Resolve the preset matching the effective knob values. A still-matching
- * last selection wins shared-bundle ties; otherwise the first configured
+ * last selection wins shared-bundle ties, and a still-selected Auto also
+ * matches the `never` approval policy; otherwise the first configured
* match wins. Returns
* {@link CUSTOM_PRESET} when no available preset matches.
* @param session - the session whose knob state is read.
diff --git a/docs/subsystems/permission-presets.zh.md b/docs/subsystems/permission-presets.zh.md
index f310363cdd..9cc7bfe611 100644
--- a/docs/subsystems/permission-presets.zh.md
+++ b/docs/subsystems/permission-presets.zh.md
@@ -46,13 +46,13 @@ interface Config {
## 固定的当前会话 Auto 注册
-Auto integration 会在自身 effect 生命周期内调用 `registerAuto(admit)`。本服务固定 `auto` 身份以及 `danger-full-access` 加 `never` 的组合;shipped 客户端的 locale 字典拥有 Auto 的 label 与 description,而配置预设的展示信息仍归 Host 所有。调用方不能通过通用 contribution API 发布其他预设。Auto 排列在配置预设之后,绝不会进入 `permission.defaultPreset` 设置 schema,并在 effect dispose 时消失。同步 `admit` 回调会在 Auto 选择修改 Session 前,以及存储的 Auto Session 发布前运行,因此 integration 缺失或正在关闭时不会改写持久身份。
+Auto integration 会在自身 effect 生命周期内调用 `registerAuto(admit)`。本服务固定 `auto` 身份以及 `danger-full-access` 加 `ask` 的组合,已记录的 Auto 选择也匹配委派子会话固定的 `never` 策略;shipped 客户端的 locale 字典拥有 Auto 的 label 与 description,而配置预设的展示信息仍归 Host 所有。调用方不能通过通用 contribution API 发布其他预设。Auto 排列在配置预设之后,绝不会进入 `permission.defaultPreset` 设置 schema,并在 effect dispose 时消失。同步 `admit` 回调会在 Auto 选择修改 Session 前,以及存储的 Auto Session 发布前运行,因此 integration 缺失或正在关闭时不会改写持久身份。
注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知。进程级消费方先订阅,再调用 `catalog()`;每次收到通知后重新读取完整的可选目录。`permissions` Session 投影只包含 `currentValue`,因此目录变化不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。
## 当前预设与派生的 `custom`
-`current(session)` 从必需的 `permissions` 投影派生实际生效的预设。该单元折叠会话的沙箱模式、审批策略和已记录选择;状态内部的缺失值回退到执行器配置的模式与审批服务配置,最后回退到 `ask`。投影 key 缺失时会显式失败。服务优先取仍然匹配的选择,其次取第一个匹配的配置条目,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。
+`current(session)` 从必需的 `permissions` 投影派生实际生效的预设。该单元折叠会话的沙箱模式、审批策略和已记录选择;状态内部的缺失值回退到执行器配置的模式与审批服务配置,最后回退到 `ask`。投影 key 缺失时会显式失败。服务优先取仍然匹配的选择(包括 `never` 审批策略下已记录的 Auto 选择),其次取第一个匹配的配置条目,否则返回 `CUSTOM_PRESET`(`'custom'`)。`custom` 只是派生值:客户端可以把它显示为当前值,但它绝不是切换目标,也绝不出现在事件 payload 中。
`names` 先按声明顺序列出配置预设,再在 Auto integration 存活时列出 Auto。`catalog()` 把这些可选条目作为一份进程级快照返回。`optionOf(name)` 为可用条目(label 回退为该 key)或派生的 `custom` 展示构建选项,传入其他任何名称都会抛出异常。客户端把目录与 Session 投影合并;`custom` 可以标记当前值,但绝不会成为目录条目。
@@ -105,7 +105,8 @@ registerAuto(admit: () => void): () => Promise
/**
* Resolve the preset matching the effective knob values. A still-matching
- * last selection wins shared-bundle ties; otherwise the first configured
+ * last selection wins shared-bundle ties, and a still-selected Auto also
+ * matches the `never` approval policy; otherwise the first configured
* match wins. Returns
* {@link CUSTOM_PRESET} when no available preset matches.
* @param session - the session whose knob state is read.
diff --git a/docs/subsystems/product-telemetry.i18n.yaml b/docs/subsystems/product-telemetry.i18n.yaml
index bd22924083..28b4e64b14 100644
--- a/docs/subsystems/product-telemetry.i18n.yaml
+++ b/docs/subsystems/product-telemetry.i18n.yaml
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/product-telemetry.md
/product-telemetry:
- en: e1a8950c3b4743ed
- zh: 3fff7e01a8f271e1
+ en: 04e9a32dfbe69355
+ zh: 0e6c78274dd97466
/product-telemetry/record-types:
en: bf5eadedbc896f5a
zh: 158fce1609a85d6d
diff --git a/docs/subsystems/product-telemetry.md b/docs/subsystems/product-telemetry.md
index d86540d5a2..00c70a45c8 100644
--- a/docs/subsystems/product-telemetry.md
+++ b/docs/subsystems/product-telemetry.md
@@ -2,7 +2,7 @@
English | [中文](product-telemetry.zh.md)
-The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. No Session data or identifiers are collected automatically.
+The [product telemetry plugin](../../packages/host/product-telemetry-otel/README.md) sends explicitly selected analytics events through OTLP/HTTP. Its `productTelemetry` service owns submission only; product consumers own when an event occurs and which fields are approved. The exporter collects no Session data or identifiers automatically. The [Desktop analytics consumer](../../packages/client/product-analytics/README.md) selects interaction and live compaction events, adds available login identity, and follows the Desktop launch-time collection switch. Ordinary Web clients do not collect product events.
`ProductTelemetryRecord` requires an event name, string body, and Unix-millisecond timestamp. Attributes accept scalar strings, numbers, booleans, and one-level objects of those values (`ProductTelemetryScalar`). Optional severity uses OTel severity numbers and defaults to INFO. Observation time is assigned when the record is enqueued.
@@ -11,24 +11,13 @@ Enqueue is synchronous and does not acknowledge delivery. The SDK owns batching
## Record types
```ts type-equiv
-/** Scalar values accepted by the collector's Arrow attributes map. */
-type ProductTelemetryScalar = string | number | boolean
+/** Scalar values accepted in product attributes. */
+type ProductTelemetryScalar = OTelEventScalar
```
```ts type-equiv
-/** Explicitly selected analytics fields; object values may contain scalars only. */
-interface ProductTelemetryRecord {
- /** Product/DA-owned event name. */
- eventName: string
- /** Human-readable summary; never a prompt, response, credential, or file contents. */
- body: string
- /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
- timestamp: number
- /** OTel severity; omitted values use INFO. */
- severityNumber?: SeverityNumber
- /** Business fields selected by the caller; no automatic device or account identity. */
- attributes?: Record>
-}
+/** Caller-selected ordinary analytics record. */
+type ProductTelemetryRecord = OTelEventRecord
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
@@ -41,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+### `ctx.productAnalytics` — `ProductAnalytics`
+
+Authenticated event intake; disabled instances do not inspect identity or accept new events.
+
+```ts cordis-catalog
+/**
+ * Read the collection policy.
+ * @returns whether this Host currently accepts Desktop analytics.
+ */
+@Remote enabled(): boolean
+
+/**
+ * Stream the effective policy initially and after live configuration edits.
+ * @param signal - subscriber lifetime.
+ * @returns current policy values until cancellation or service disposal.
+ */
+@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable
+
+/**
+ * Submit selected Desktop fields; missing identity is omitted and never generated.
+ * @param event - typed product event without message contents or credentials.
+ * @returns after local submission; no delivery or warehouse acknowledgement.
+ */
+@Remote async report(event: ProductEvent): Promise
+```
+
+Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
+
### `ctx.productTelemetry` — `ProductTelemetry`
diff --git a/docs/subsystems/product-telemetry.zh.md b/docs/subsystems/product-telemetry.zh.md
index 52ad119363..5b04bf6fdd 100644
--- a/docs/subsystems/product-telemetry.zh.md
+++ b/docs/subsystems/product-telemetry.zh.md
@@ -2,7 +2,7 @@
[English](product-telemetry.md) | 中文
-[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。不自动采集 Session 数据或标识。
+[产品埋点插件](../../packages/host/product-telemetry-otel/README.zh.md) 通过 OTLP/HTTP 发送明确选定的分析事件。`productTelemetry` 服务仅负责提交;产品消费方决定事件发生时机与获准采集的字段。导出器不自动采集 Session 数据或标识。[桌面埋点消费方](../../packages/client/product-analytics/README.zh.md)选择交互与实时压缩事件,补充可用的登录身份,并遵守桌面启动时的采集开关。普通 Web 客户端不采集产品事件。
`ProductTelemetryRecord` 要求事件名称、字符串 body 和 Unix 毫秒时间戳。属性接受字符串、数字、布尔标量,以及这些值组成的单层对象(`ProductTelemetryScalar`)。可选的严重程度使用 OTel 严重程度数字,默认为 INFO。记录入队时填写观测时间。
@@ -11,24 +11,13 @@
## 记录类型
```ts type-equiv
-/** Scalar values accepted by the collector's Arrow attributes map. */
-type ProductTelemetryScalar = string | number | boolean
+/** Scalar values accepted in product attributes. */
+type ProductTelemetryScalar = OTelEventScalar
```
```ts type-equiv
-/** Explicitly selected analytics fields; object values may contain scalars only. */
-interface ProductTelemetryRecord {
- /** Product/DA-owned event name. */
- eventName: string
- /** Human-readable summary; never a prompt, response, credential, or file contents. */
- body: string
- /** Event occurrence time in Unix milliseconds. Observation time is assigned on enqueue. */
- timestamp: number
- /** OTel severity; omitted values use INFO. */
- severityNumber?: SeverityNumber
- /** Business fields selected by the caller; no automatic device or account identity. */
- attributes?: Record>
-}
+/** Caller-selected ordinary analytics record. */
+type ProductTelemetryRecord = OTelEventRecord
```
Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/host/product-telemetry-otel/src/index.ts)
@@ -41,6 +30,36 @@ Source: [`packages/host/product-telemetry-otel/src/index.ts`](../../packages/hos
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
+
+
+### `ctx.productAnalytics` — `ProductAnalytics`
+
+Authenticated event intake; disabled instances do not inspect identity or accept new events.
+
+```ts cordis-catalog
+/**
+ * Read the collection policy.
+ * @returns whether this Host currently accepts Desktop analytics.
+ */
+@Remote enabled(): boolean
+
+/**
+ * Stream the effective policy initially and after live configuration edits.
+ * @param signal - subscriber lifetime.
+ * @returns current policy values until cancellation or service disposal.
+ */
+@Remote({ mode: 'stream' }) async *watchPolicy(signal: AbortSignal): AsyncIterable
+
+/**
+ * Submit selected Desktop fields; missing identity is omitted and never generated.
+ * @param event - typed product event without message contents or credentials.
+ * @returns after local submission; no delivery or warehouse acknowledgement.
+ */
+@Remote async report(event: ProductEvent): Promise
+```
+
+Source: [`packages/client/product-analytics/src/index.ts`](../../packages/client/product-analytics/src/index.ts)
+
### `ctx.productTelemetry` — `ProductTelemetry`
diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml
index d6d12d5a95..614eb31d1e 100644
--- a/docs/subsystems/schedule.i18n.yaml
+++ b/docs/subsystems/schedule.i18n.yaml
@@ -9,8 +9,8 @@
en: 41cd197b5c74bfa3
zh: 18ed11259036d726
/host-wide-schedule/absolute-time-input:
- en: e730fda52ba2854f
- zh: 01a6c682e327a133
+ en: 8186d070ab412545
+ zh: 968b752df9e03b68
/host-wide-schedule/daily-wall-clock-input:
en: 2d249edc3dfccb8a
zh: 33fecda132b03b84
@@ -27,8 +27,8 @@
en: 0656173de50ee132
zh: 54176540371d0f28
/host-wide-schedule/web-catalog:
- en: 39f24636dd788034
- zh: ba3ff541fee5854a
+ en: 9955c4857340103a
+ zh: edfa0491c8a08db7
/host-wide-schedule/timing-edits:
en: ce5d2095a3ad2e82
zh: 167b3715f8eaffc1
diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md
index 0784c0b92a..4d8f4a1c0e 100644
--- a/docs/subsystems/schedule.md
+++ b/docs/subsystems/schedule.md
@@ -116,7 +116,7 @@ interface LocalAtInput {
type AtInput = string | LocalAtInput
```
-The shipped Web bundle mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context.
+The shipped Web composition carries no `time-context` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts time-context, which samples the browser's IANA zone for every prompt. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context.
Schedule rejects invalid offsets and zones, offset-free strings, non-future targets, and local times inside daylight-saving gaps. A daylight-saving overlap chooses its first, earlier instant. Successful creation stores only canonical UTC `scheduledAt`, so replay never depends on ambient time-zone state.
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again.
-The Web bundle mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
+The shipped Web composition carries no `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle`, enabled from the Plugins page, inserts and mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
## Timing edits
diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md
index d1a726fa97..eff76ade44 100644
--- a/docs/subsystems/schedule.zh.md
+++ b/docs/subsystems/schedule.zh.md
@@ -116,7 +116,7 @@ interface LocalAtInput {
type AtInput = string | LocalAtInput
```
-发布的 Web bundle 会挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。
+随发行版交付的 Web 组合不含 `time-context` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入并挂载 time-context,它为每条提示词采样浏览器的 IANA 时区。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。
Schedule 会拒绝无效偏移量与时区、不带偏移量的字符串、非未来目标,以及落在夏令时缺口内的本地时间。遇到夏令时重叠时,会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,因此回放绝不依赖环境时区状态。
@@ -339,7 +339,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。
-Web bundle 将 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
+随发行版交付的 Web 组合不含 `ui-schedule` 行;在插件管理页启用可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 会插入它并与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“发送记录”将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
## 修改时间
diff --git a/docs/subsystems/session-telemetry.i18n.yaml b/docs/subsystems/session-telemetry.i18n.yaml
index b2c2f42fb9..553e00308c 100644
--- a/docs/subsystems/session-telemetry.i18n.yaml
+++ b/docs/subsystems/session-telemetry.i18n.yaml
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-telemetry.md
/sessiontelemetrybackend:
- en: d400c8668cd76829
- zh: 67689676c19daa7f
+ en: b82e4db22a4d58aa
+ zh: 439181fa2c27542d
/sessiontelemetrybackend/the-logical-record:
en: 9695489076496a76
zh: 457c54698bc38c2f
diff --git a/docs/subsystems/session-telemetry.md b/docs/subsystems/session-telemetry.md
index 18fe935e5e..4b99fc8ff9 100644
--- a/docs/subsystems/session-telemetry.md
+++ b/docs/subsystems/session-telemetry.md
@@ -2,7 +2,7 @@
English | [中文](session-telemetry.zh.md)
-Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.sessionTelemetry`) own complete canonical-event capture, the `session-telemetry/record` redaction waterfall, the handoff cursor, and the minimal backend contract; the Service Provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture and cursor contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md).
+The [capture service](../../packages/session/session-telemetry/README.md) owns canonical-event capture, redaction, and handoff cursors. The [feedback backend](../../packages/session/session-telemetry-otel/README.md) authorizes Session prefixes and owns their independent queue, per-request scheduling, and 4,000,000-byte ceiling. No reporting data reaches model requests.
Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -29,6 +29,8 @@ type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
* identity so they can never be mistaken for ledger rows.
*/
interface SessionTelemetryRecord {
+ /** Canonical envelope without data; body carries the separately redacted payload. Absent for operational records. */
+ sourceEvent?: { sessionId: SessionId; envelope: Omit }
/** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
channel: 'ledger' | 'ops'
/** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
diff --git a/docs/subsystems/session-telemetry.zh.md b/docs/subsystems/session-telemetry.zh.md
index 1b3807d2e3..00480938d9 100644
--- a/docs/subsystems/session-telemetry.zh.md
+++ b/docs/subsystems/session-telemetry.zh.md
@@ -2,7 +2,7 @@
[English](session-telemetry.md) | 中文
-对外的会话上报拆分为一项[能力 seam](../capability-seams.zh.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.sessionTelemetry`)拥有完整的权威事件捕获、`session-telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service Provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md)中定案;捕获与游标约定见 [Service Definition README](../../packages/session/session-telemetry/README.zh.md)。
+[捕获服务](../../packages/session/session-telemetry/README.zh.md) 负责规范事件捕获、脱敏和交接游标。[反馈后端](../../packages/session/session-telemetry-otel/README.zh.md) 授权 Session 前缀,并负责独立队列、逐请求调度及 4,000,000 字节上限。上报数据不会进入模型请求。
源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts)
@@ -29,6 +29,8 @@ type SessionTelemetrySeverity = 'info' | 'warn' | 'error'
* identity so they can never be mistaken for ledger rows.
*/
interface SessionTelemetryRecord {
+ /** Canonical envelope without data; body carries the separately redacted payload. Absent for operational records. */
+ sourceEvent?: { sessionId: SessionId; envelope: Omit }
/** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */
channel: 'ledger' | 'ops'
/** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */
diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md
index bc2abb0098..415201d159 100644
--- a/docs/subsystems/session.md
+++ b/docs/subsystems/session.md
@@ -820,9 +820,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise
/**
- * Select one Session-local model after explicitly resuming the Session.
+ * Select one Session-local model after explicitly resuming the Session; save the default in the background.
* @param request - Session identity and requested model selection.
- * @returns the normalized selection installed for the Session.
+ * @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index 6b2f859d6b..e52c08e819 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -824,9 +824,9 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise
/**
- * Select one Session-local model after explicitly resuming the Session.
+ * Select one Session-local model after explicitly resuming the Session; save the default in the background.
* @param request - Session identity and requested model selection.
- * @returns the normalized selection installed for the Session.
+ * @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
@Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise
diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md
index 348cda6573..4d03629399 100644
--- a/docs/subsystems/tools.md
+++ b/docs/subsystems/tools.md
@@ -403,14 +403,15 @@ Each interception waterfall returns a typed **Decision** (the idiom shared with
* model-facing reason and optional structured error identity; `cancel` selects
* the canonical cancellation result without presenting a policy denial; `ask`
* runs only after an approval service returns `allowed-once` and otherwise
- * denies. Input rewriting is excluded because arguments are already logged and
+ * denies; its `reason` is the audited approval reason and its optional
+ * `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and
* presented.
*/
type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string; info?: ToolErrorInfo }
| { kind: 'cancel' }
- | { kind: 'ask'; reason?: string }
+ | { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } }
```
```ts type-equiv
diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md
index ae928e573f..9aa6bf9656 100644
--- a/docs/subsystems/tools.zh.md
+++ b/docs/subsystems/tools.zh.md
@@ -403,14 +403,15 @@ interface ToolErrorInfo {
* model-facing reason and optional structured error identity; `cancel` selects
* the canonical cancellation result without presenting a policy denial; `ask`
* runs only after an approval service returns `allowed-once` and otherwise
- * denies. Input rewriting is excluded because arguments are already logged and
+ * denies; its `reason` is the audited approval reason and its optional
+ * `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and
* presented.
*/
type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string; info?: ToolErrorInfo }
| { kind: 'cancel' }
- | { kind: 'ask'; reason?: string }
+ | { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } }
```
```ts type-equiv
diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml
index f4673fc8b6..da20febdb7 100644
--- a/docs/subsystems/web-client.i18n.yaml
+++ b/docs/subsystems/web-client.i18n.yaml
@@ -24,8 +24,8 @@
en: c063711f8247c240
zh: 5e54758bbaf1670c
/web-client-architecture/conversation-and-presentation:
- en: a5fac31f9a3e6e97
- zh: 01d7c4e286921360
+ en: 9e0d6a3dfdbc4c12
+ zh: 0eea67d515ec56ef
/web-client-architecture/data-paths:
en: a254278432416473
zh: 567c2d3c6a20b304
diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md
index f07094d649..1f57008daf 100644
--- a/docs/subsystems/web-client.md
+++ b/docs/subsystems/web-client.md
@@ -55,7 +55,7 @@ This pairing is not a second source of business truth. Host controllers decide d
## Conversation and presentation
-Web and desktop share the [developer-tool preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, new-session preset selection, changed-file cards, and the builtin HTML preview policy without changing Session records.
+Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, new-session preset selection, changed-file cards, and the builtin HTML preview policy without changing Session records.
`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects.
diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md
index 5a0069abb4..9bb2420704 100644
--- a/docs/subsystems/web-client.zh.md
+++ b/docs/subsystems/web-client.zh.md
@@ -55,7 +55,7 @@ Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trus
## Conversation 与 presentation
-Web 和桌面端共享[开发者工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、新会话预设选择、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。
+Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、新会话预设选择、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。
`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。
diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml
index e7ec59d39a..094d83b58f 100644
--- a/docs/tool-catalog.i18n.yaml
+++ b/docs/tool-catalog.i18n.yaml
@@ -54,8 +54,8 @@
en: e713a4a11a15d13c
zh: e713a4a11a15d13c
/tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question:
- en: 3d5e09e59e7d5274
- zh: 624c14da73524315
+ en: f42b7f17ad59577a
+ zh: 7e1eee22ff35d4b1
/tool-schema-catalog/deepseek-ai-dsh-tools:
en: 76fbbf0fe38aef10
zh: 76fbbf0fe38aef10
@@ -66,26 +66,26 @@
en: b500b530845e26a9
zh: b500b530845e26a9
/tool-schema-catalog/deepseek-ai-dsh-plan-mode/exit-plan-mode:
- en: a4bf2ed3f9f55ff1
- zh: ce7b136d3ad3946a
+ en: b410483c2bd0c1a1
+ zh: a07ceea6abf1dda3
/tool-schema-catalog/deepseek-ai-dsh-tool-bash:
en: 408ff0a5189bd7ec
zh: 408ff0a5189bd7ec
/tool-schema-catalog/deepseek-ai-dsh-tool-bash/bash:
- en: 70add265cf4cfda5
- zh: 47c2e23d50557d10
+ en: 67ee2b8d4997bd1b
+ zh: 82d1fb17b619c364
/tool-schema-catalog/deepseek-ai-dsh-tool-present:
en: f656575e6df094f6
zh: f656575e6df094f6
/tool-schema-catalog/deepseek-ai-dsh-tool-present/present:
- en: 4af0d859e1d39dc5
- zh: 780e75b1b42a6410
+ en: 24ef5efe4c0edf50
+ zh: a2914d345bd8eefa
/tool-schema-catalog/deepseek-ai-dsh-tool-pwsh:
en: b222fc05f3f8ee4a
zh: b222fc05f3f8ee4a
/tool-schema-catalog/deepseek-ai-dsh-tool-pwsh/pwsh:
- en: 775abe9511776e1d
- zh: 76ae33dab0dd399a
+ en: 9d7055b9fb8ba9de
+ zh: 0647025c36e80237
/tool-schema-catalog/deepseek-ai-dsh-tool-cordis:
en: 76c7c62f9029b6f5
zh: 76c7c62f9029b6f5
@@ -123,8 +123,8 @@
en: 94bfc5b03c63baef
zh: 339338a0e74696c3
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/read-image:
- en: 4e82f36d958c9c1c
- zh: 1f87182c327bec10
+ en: f6f7a1140066ce04
+ zh: d528e01a0939795a
/tool-schema-catalog/deepseek-ai-dsh-tool-fs/write:
en: f80700c5d57e8d87
zh: fb7cccbba0d8d486
@@ -132,11 +132,11 @@
en: 703fea005ce84c50
zh: 703fea005ce84c50
/tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/glob:
- en: 418d627507ddd693
- zh: 85f14b433c93efbe
+ en: 3ba734ba7852cdd5
+ zh: c6ce31ff2c195520
/tool-schema-catalog/deepseek-ai-dsh-tool-fs-search/grep:
- en: 224288aaf3fbd214
- zh: 9e39dadfaa3768e7
+ en: 6e1f7da62cc9a35b
+ zh: ee7f3081887f8067
/tool-schema-catalog/deepseek-ai-dsh-tool-terminal:
en: 77501c1b592b6a88
zh: 77501c1b592b6a88
@@ -162,29 +162,29 @@
en: 74fff3741e7c13de
zh: 74fff3741e7c13de
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/create-goal:
- en: b22408124a0a6f34
- zh: 7b3bf9ceb5d7f1dc
+ en: 9ae877cdf8a641a3
+ zh: f83c3c09e80e4190
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/get-goal:
- en: 73ab2b24d4de682d
- zh: 82c42a427774c67d
+ en: 0b517f13dfde11fe
+ zh: b7ee7482e8ecde56
/tool-schema-catalog/deepseek-ai-dsh-tool-goal/update-goal:
- en: a5ef842aa3d1109e
- zh: 8f0a9135944e6f1d
+ en: e6b92fac5aad585c
+ zh: 6b3cdd87b059a98f
/tool-schema-catalog/deepseek-ai-dsh-schedule:
en: 573da0cf49b1eed6
zh: 573da0cf49b1eed6
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-create:
- en: 687b784279240ed2
- zh: a23fa1b08c15642b
+ en: 0a10b5db8919d316
+ zh: 175d680a29f59b63
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-delete:
- en: 3b5a084ce0d87d13
- zh: c1356d4b062f0e63
+ en: cff096695f06e298
+ zh: 3d0063a1e529377f
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-list:
- en: 4422ae2e2a4ca897
- zh: 56431645118d6ca0
+ en: d8543c8503d0947e
+ zh: 205e6c813ad2b37c
/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-update:
- en: a86e8751a34e9f06
- zh: 92a32fd4fc3657b4
+ en: 3d5e2517b510238b
+ zh: d6e5c4c4465c9645
/tool-schema-catalog/deepseek-ai-dsh-tool-lsp:
en: 32982123b1f0e5bc
zh: 32982123b1f0e5bc
@@ -201,8 +201,8 @@
en: 927a5ac0974b7642
zh: 927a5ac0974b7642
/tool-schema-catalog/deepseek-ai-dsh-tool-skill/skill:
- en: cd716dc11308817d
- zh: 08b4ed76101642a2
+ en: 9b159d5831855afb
+ zh: 275ee82af0e0f59a
/tool-schema-catalog/deepseek-ai-dsh-tool-session-query:
en: eac1dff4b6d9e195
zh: eac1dff4b6d9e195
@@ -228,32 +228,32 @@
en: c89084b31ffccbce
zh: d7b3b7000f2d2be9
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent:
- en: df17e5091f9cdfc9
- zh: 3b31fe9df04242f2
+ en: 55cbd29d70bcf3d7
+ zh: 886a1b57446bd5e1
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control:
en: ab82e130c3413f04
zh: ab82e130c3413f04
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/interrupt-agent:
- en: 3eb5ac28342e354f
- zh: 91ca5628d8e333f0
+ en: 0cec3f2520050839
+ zh: 06735f086cea84f1
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/list-agents:
- en: e688153e9ba4a684
- zh: 56c36cb0140c9202
+ en: ae22e8543d123c39
+ zh: 4943a5c5980fc2d3
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control/send-message:
- en: b4d8ffa51c3c9cce
- zh: 32d5281b2dec0d81
+ en: 2ebdc450faef6446
+ zh: 7e3cff35fedc6a32
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs:
en: 9be673b1604d0114
zh: 9be673b1604d0114
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-kill:
- en: 39e53ddd7ea31c2d
- zh: 16640c2952c263b6
+ en: 14294cc1988ae768
+ zh: 80a9152bdc02723f
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-list:
en: 0c37fb1fed979d72
zh: 5ea9d07f5a4a8a6c
/tool-schema-catalog/deepseek-ai-dsh-tool-jobs/job-output:
- en: 2a1d3887688abd05
- zh: c1cafbabccc2e021
+ en: f927cca09b283e21
+ zh: 2acd9b36737f17c2
/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team:
en: 6b1106f417b50db6
zh: 6b1106f417b50db6
@@ -288,14 +288,14 @@
en: dea62d26fb18ff68
zh: dea62d26fb18ff68
/tool-schema-catalog/deepseek-ai-dsh-tool-todo/todo-write:
- en: 3e63adceedda7304
- zh: bc20a7b69ce2cddd
+ en: 8c64fe5f2e58a890
+ zh: e3c780fc842aeb43
/tool-schema-catalog/deepseek-ai-dsh-tool-workflow:
en: bb38a9d29f46563c
zh: bb38a9d29f46563c
/tool-schema-catalog/deepseek-ai-dsh-tool-workflow/workflow:
- en: 4ca5c5f9ebc188e5
- zh: 98bbc45e441019af
+ en: fdd667cfe21481e8
+ zh: 88864d3b958398a4
/tool-schema-catalog/deepseek-ai-dsh-tool-workspace-dependencies:
en: 96bffc40b80df4b8
zh: 96bffc40b80df4b8
@@ -309,5 +309,5 @@
en: 8673f203aea9bdf2
zh: 4e17783ffdf34c83
/tool-schema-catalog/deepseek-ai-dsh-tool-web/web-search:
- en: 096d6a19c83af773
- zh: fa5213caf9401216
+ en: cc1ad38bf0006710
+ zh: 324eacb827a09e93
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index d92c8d583f..d2c16e3006 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -449,7 +449,7 @@ Source: [`packages/experimental/browser-use-stagehand-native/src/index.ts`](../p
### `ask_user_question`
-Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.
+Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding.
```json
{
@@ -571,7 +571,7 @@ Owned by the tool registry as a reserved transport outside filterable capability
### `exit_plan_mode`
-Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
+Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.
```json
{
@@ -598,7 +598,7 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr
### `bash`
-Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
+Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell; pass `workdir` instead of using `cd`. Managed `$DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way.
```json
{
@@ -642,7 +642,7 @@ The bash tool is the model-facing consumer of the bash executor seam. With a job
### `present`
-Declare selected existing files accessible through the Session filesystem as final deliverables. Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
+Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.
```json
{
@@ -650,6 +650,7 @@ Declare selected existing files accessible through the Session filesystem as fin
"properties": {
"files": {
"type": "array",
+ "description": "Usually the 1-2 most important deliverables; at most 4 per call.",
"items": {
"type": "object",
"additionalProperties": false,
@@ -685,7 +686,7 @@ Deliveries belong to the calling Session; Web ui-deliverables supplies source-fi
### `pwsh`
-Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
+Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process; pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Managed `$env:DSH_*` variables expose current harness environment facts. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]`, a policy denial: do not retry another way.
```json
{
@@ -958,7 +959,7 @@ Edit an existing UTF-8 text file by replacing literal text.
},
"old_string": {
"type": "string",
- "description": "Literal text to replace. Must match exactly."
+ "description": "Literal text to replace."
},
"new_string": {
"type": "string",
@@ -1010,7 +1011,7 @@ Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts
### `read_image`
-Read a PNG/JPEG/WebP/GIF file and return the image itself. A path without a file extension is accepted; the format is detected from the file content, so normalized attachment paths can be passed directly without copying or renaming. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.
+Read a PNG/JPEG/WebP/GIF file and return the image itself. Large images are downscaled automatically; do not install image libraries or create thumbnails to inspect an image.
```json
{
@@ -1063,7 +1064,7 @@ The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-observation-p
### `glob`
-Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result instead returns 100 paths sampled across top-level entries, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.
+Find files, not directories, whose paths match a glob pattern, including hidden and ignored files. Returns up to 100 paths in modification-time order; a larger result is sampled across top-level entries and reports where the complete list was saved.
```json
{
@@ -1088,7 +1089,7 @@ Source: [`packages/fs/tool-fs-search/src/index.ts`](../packages/fs/tool-fs-searc
### `grep`
-Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.
+Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns up to 250 matches; a larger result reports where the complete match list was saved.
```json
{
@@ -1288,7 +1289,7 @@ The six terminal tools are opt-in and complement one-shot shell/filesystem tools
### `create_goal`
-Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say "create a goal". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.
+Create a persisted goal that keeps this session working across automatic continuation rounds. Use it when the direct human request is a long-running objective, even if the user did not say "goal"; not for single-turn work.
```json
{
@@ -1313,7 +1314,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `get_goal`
-Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.
+Read the current session goal, including the id and revision that update_goal requires.
```json
{
@@ -1326,7 +1327,7 @@ Source: [`packages/goal/tool-goal/src/index.ts`](../packages/goal/tool-goal/src/
### `update_goal`
-Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.
+Update the current goal.
```json
{
@@ -1342,7 +1343,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct
},
"action": {
"type": "string",
- "description": "edit | pause | resume | complete | blocked",
+ "description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.",
"enum": [
"edit",
"pause",
@@ -1361,7 +1362,7 @@ Update the exact current goal revision. edit, pause, and resume require a direct
},
"blocked_reason": {
"type": "string",
- "description": "Concrete blocking condition; required only with action blocked."
+ "description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress."
}
},
"required": [
@@ -1382,7 +1383,7 @@ create, edit, pause, and resume require direct-human root authority; complete an
### `schedule_create`
-Create one reminder in the current session. Supply a non-empty prompt, a title, and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, safe-integer every_seconds of at least 60, daily as {time: "23:00:00", time_zone: "Asia/Shanghai"}, weekly as {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} with Monday 1 through Sunday 7, or cron as {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} with the five fields minute hour day-of-month month day-of-week. Every creation requires a title of at most 120 characters, non-empty after trimming; it names the task on its card, its detail heading, and in the task lists. Daily, weekly, and cron reminders retain that local time and zone; missing wall-clock times skip the date and repeated times use only the earlier instant. A cron day-of-month and day-of-week pair matches when either field matches once both are restricted. Fixed-rate targets stay creation-aligned until an interval edit establishes a new anchor. All four recurring kinds batch one latest occurrence per overdue rule. The Host restores this session when a reminder is due. After downtime, each recurring reminder delivers its latest missed occurrence once. Delivery can repeat after a crash.
+Create a reminder in the current session that delivers prompt when it becomes due. Supply exactly one timing parameter: after_seconds, at, every_seconds, daily, weekly, or cron. Local times that do not exist in the zone are skipped; repeated local times fire once, at the earlier instant. After downtime, a recurring reminder delivers only its latest missed occurrence. Delivery can repeat after a crash.
```json
{
@@ -1394,19 +1395,19 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"title": {
"type": "string",
- "description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists."
+ "description": "Task name of at most 120 characters, shown on the task card and in task lists."
},
"after_seconds": {
"type": "number",
- "description": "Positive safe-integer delay in seconds."
+ "description": "Delay in whole seconds."
},
"every_seconds": {
"type": "number",
- "description": "Fixed-rate safe-integer interval in seconds, at least 60."
+ "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
- "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
+ "description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1425,7 +1426,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"weekly": {
"type": "object",
- "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
+ "description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1438,7 +1439,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"weekdays": {
"type": "array",
- "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
+ "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1452,12 +1453,12 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
},
"cron": {
"type": "object",
- "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
+ "description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
- "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
+ "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1495,7 +1496,7 @@ Create one reminder in the current session. Supply a non-empty prompt, a title,
]
}
],
- "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
+ "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1509,7 +1510,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_delete`
-Delete one retained reminder in the current session by its exact id, whether active or inactive. Unknown or already-deleted ids return deleted false. Deletion does not retract a queued message.
+Delete a reminder in the current session, active or inactive. Deletion does not retract a reminder message that is already queued.
```json
{
@@ -1517,7 +1518,7 @@ Delete one retained reminder in the current session by its exact id, whether act
"properties": {
"id": {
"type": "string",
- "description": "Exact schedule id."
+ "description": "Schedule id returned by schedule_list."
}
},
"required": [
@@ -1530,7 +1531,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_list`
-List every active reminder in the current session, including its exact id, title, UTC target, scheduled or overdue state, and host delivery mode. The returned order is not significant.
+List the active reminders in the current session.
```json
{
@@ -1543,7 +1544,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul
### `schedule_update`
-Change one reminder in the current session in place, keeping its id and its saved delivery records: address it by the exact id schedule_list returned, then supply a new title or prompt, or exactly one new selector from at, every_seconds, daily, weekly, or cron in the same forms schedule_create accepts. An omitted field keeps its stored value. After is not updatable: create a new reminder for a relative delay. The Host compares the record it finds for that id with the stored one, so a concurrent edit returns schedule_conflict instead of overwriting it; an inactive or unknown reminder returns updated false. Editing an every_seconds interval anchors the new fixed rate at the accepted save time; a name or instruction change alone keeps the committed target.
+Change a reminder in place, keeping its id. Supply a new title, prompt, or at most one timing parameter; omitted fields keep their stored values. To change a relative delay, create a new reminder.
```json
{
@@ -1551,23 +1552,23 @@ Change one reminder in the current session in place, keeping its id and its save
"properties": {
"id": {
"type": "string",
- "description": "Exact schedule id that schedule_list returned for one reminder."
+ "description": "Schedule id returned by schedule_list."
},
"title": {
"type": "string",
- "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name."
+ "description": "New task name of at most 120 characters."
},
"prompt": {
"type": "string",
- "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction."
+ "description": "New reminder content."
},
"every_seconds": {
"type": "number",
- "description": "Fixed-rate safe-integer interval in seconds, at least 60."
+ "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
- "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
+ "description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1586,7 +1587,7 @@ Change one reminder in the current session in place, keeping its id and its save
},
"weekly": {
"type": "object",
- "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
+ "description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1599,7 +1600,7 @@ Change one reminder in the current session in place, keeping its id and its save
},
"weekdays": {
"type": "array",
- "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
+ "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1613,12 +1614,12 @@ Change one reminder in the current session in place, keeping its id and its save
},
"cron": {
"type": "object",
- "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
+ "description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
- "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
+ "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1656,7 +1657,7 @@ Change one reminder in the current session in place, keeping its id and its save
]
}
],
- "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
+ "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1754,7 +1755,7 @@ A fixed foreground workflow starts one fresh structured child per round; the mod
### `skill`
-Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.
+Load the full instructions for a skill. Call it before acting on a task that names or clearly matches a skill in the session skill catalog.
```json
{
@@ -2036,7 +2037,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag
### `subagent`
-Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`.
+Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This call waits for the result by default.
```json
{
@@ -2052,7 +2053,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
},
"run_in_background": {
"type": "boolean",
- "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
+ "description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false."
}
},
"required": [
@@ -2072,7 +2073,7 @@ The registered delegation name is the load-time `toolName` config (default `suba
### `interrupt_agent`
-Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.
+Ask a subagent to stop its current work. This call returns without waiting for it to stop. You can continue a direct child's conversation later with send_message. Subagents it started will keep running.
```json
{
@@ -2080,7 +2081,7 @@ Request cancellation of a background agent's current turn by its agent id. The t
"properties": {
"agent_id": {
"type": "string",
- "description": "The agent id of the running agent to interrupt."
+ "description": "The id of an agent created under you: your direct child or a deeper descendant."
}
},
"required": [
@@ -2093,7 +2094,7 @@ Source: [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/sub
### `list_agents`
-List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now; inactive means no turn is executing, whether the child is loaded or must be resumed. inactive does not describe task completion, success, failure, or waiting for other agents. A `send_message` steers a running child at its nearest step boundary or starts or resumes a turn for an inactive child, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics only in `descendants` scope. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.
+List subagents you started, with their ids, labels, and status. running means it is working; inactive means it is not currently working. You will be notified when a subagent finishes; there is no need to keep checking its status. Use send_message to continue the conversation.
```json
{
@@ -2101,7 +2102,7 @@ List your continuable background subagents by durable id and label. Use it to re
"properties": {
"scope": {
"type": "string",
- "description": "children (default) lists direct children only; descendants walks the complete tree below you.",
+ "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.",
"enum": [
"children",
"descendants"
@@ -2115,7 +2116,7 @@ Source: [`packages/subagent/tool-subagent-control/src/list-agents.ts`](../packag
### `send_message`
-Send a message to a direct continuable child by its agent id. If you are a resident continuable child, you may also target your direct parent. If the target is still working, the message steers its nearest step; if it is inactive, the message starts or resumes a turn. This call returns no answer from the agent — only confirmation that the message was delivered. A failure means the message was NOT delivered.
+Send a message to an agent. A working agent receives it at its next step; an idle agent starts a new turn with it. Returns delivery confirmation, not the agent's answer.
```json
{
@@ -2147,7 +2148,7 @@ The globally named control tools over continuable background subagents: provider
### `job_kill`
-Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.
+Request cancellation of a running background job.
```json
{
@@ -2185,7 +2186,7 @@ Source: [`packages/jobs/tool-jobs/src/index.ts`](../packages/jobs/tool-jobs/src/
### `job_output`
-Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.
+Read a background job: output since the previous read for stream jobs, or the result of a finished final-output job.
```json
{
@@ -2197,11 +2198,11 @@ Read a background job. Stream jobs return only output since the previous read; f
},
"wait": {
"type": "boolean",
- "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
+ "description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false."
},
"timeout_ms": {
"type": "number",
- "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
+ "description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration."
}
},
"required": [
@@ -2510,7 +2511,7 @@ All nine tools are scoped to implicit Team Leads and durable teammates. The ship
### `todo_write`
-Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).
+Record and update a task list to plan multi-step work and show progress; skip it for trivial single-step tasks. Add one todo per concrete step before you start. While work remains, keep the todos being worked on `in_progress`, several only when work runs in parallel. Mark each todo `completed` as soon as it is done.
```json
{
@@ -2562,17 +2563,13 @@ todo_write is session-owned state; UIs render the latest todo/write event as a c
Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.
-The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.
-
Script-body hooks:
-- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.
-- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.
+- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides.
+- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. A stage throw drops that ITEM to `null` and skips its remaining stages.
- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.
- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.
-Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.
-
-Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground by default: this call returns when the whole script finishes. Set `run_in_background: true` for a long run: the call returns a job id immediately, the run keeps orchestrating in the background, and its return value arrives with the job's completion notice (check on it with `job_output`, stop it with `job_kill`).
+Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) end the whole script instead of producing `null`. The script has no filesystem, network, timer, or Node.js APIs; the agents do the work.
```json
{
@@ -2580,11 +2577,11 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
"properties": {
"script": {
"type": "string",
- "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."
+ "description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return `; the JSON-serializable value is this tool's result."
},
"meta": {
"type": "object",
- "description": "The workflow identity block (plain JSON — never code).",
+ "description": "The workflow identity as plain JSON, not code.",
"additionalProperties": true,
"properties": {
"name": {
@@ -2697,7 +2694,7 @@ Source: [`packages/web/tool-web/src/index.ts`](../packages/web/tool-web/src/inde
### `web_search`
-Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.
+Search the web for current information. Returns an optional summary answer and a list of source URLs.
```json
{
@@ -2705,7 +2702,7 @@ Search the web for current information. Provide 1–4 queries in the required qu
"properties": {
"queries": {
"type": "array",
- "description": "Required search queries; accepts 1–4 items and merges their results.",
+ "description": "1–4 search queries; their results are merged.",
"items": {
"type": "string"
}
diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md
index 9c05b6f477..1b3ad1897e 100644
--- a/docs/tool-catalog.zh.md
+++ b/docs/tool-catalog.zh.md
@@ -453,7 +453,7 @@
### `ask_user_question`
-继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。发送一个或多个问题,每个问题都带一个稳定 id,该 id 会在答案中原样返回。
+继续操作前,如果需要确认、选择或缺失的信息,请向用户提出简明问题。
```json
{
@@ -575,7 +575,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `exit_plan_mode`
-仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。发送**完整的** Markdown 计划,以一个为计划命名的 # 标题开头。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。
+仅在规划模式下使用。提交计划供用户评审,并在获批后退出规划模式。用户可以批准(从你的下一步骤起执行计划),也可以要求继续规划;其反馈会通过工具结果返回,请修改后再次提交。
```json
{
@@ -602,7 +602,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `bash`
-执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
+执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行;请传入 `workdir`,不要使用 `cd`。托管的 `$DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
@@ -646,7 +646,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
### `present`
-选择 Session 文件系统可访问的已有文件,声明为最终交付物。当用户需要独立的文件交付物时使用 present,尤其是 Office 文档、电子表格和演示文稿。如果最终回复已经足够展示结果,优先在回复中展示;创建或编辑文件本身不要求调用 present。通常选择最重要的 1 至 2 项交付物,任务需要时可以更多,但一次 present 调用最多 4 个文件。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
+将已有文件声明为交付给用户的最终交付物。当用户需要独立文件时使用,尤其是 Office 文档、电子表格和演示文稿;如果最终回复已经足够,优先使用最终回复。用户打开的是当前文件;不复制其内容。
```json
{
@@ -654,6 +654,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
"properties": {
"files": {
"type": "array",
+ "description": "Usually the 1-2 most important deliverables; at most 4 per call.",
"items": {
"type": "object",
"additionalProperties": false,
@@ -689,7 +690,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job
### `pwsh`
-执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
+执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行;请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。托管的 `$env:DSH_*` 变量公开当前 harness 环境信息。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under mode]`,这是策略拒绝:请勿换一种方式重试。
```json
{
@@ -964,7 +965,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
},
"old_string": {
"type": "string",
- "description": "Literal text to replace. Must match exactly."
+ "description": "Literal text to replace."
},
"new_string": {
"type": "string",
@@ -1016,7 +1017,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `read_image`
-读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。无扩展名的路径同样被接受;格式按文件内容检测,因此规范化附件路径可以直接传入,无需复制或重命名。Harness 会在下一次模型请求前校验并缩小受支持的大图,因此仅为查看图片时应直接使用此工具,无需安装图片库或创建缩略图。可以用小批次并发读取彼此独立的文件。要求当前模型接受图像输入。
+读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。大图会自动缩小;不要为了查看图片而安装图片库或创建缩略图。
```json
{
@@ -1069,7 +1070,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `glob`
-查找路径匹配 glob 模式的文件。只返回匹配的文件路径,绝不返回目录;包括隐藏文件和被忽略的文件,但排除 VCS 元数据目录。最多按修改时间顺序返回 100 条路径;如果结果更多,则改为返回从顶层条目中抽样的 100 条路径,说明已抽样,并报告完整排序列表的保存位置。该工具不枚举目录条目。
+查找路径匹配 glob 模式的文件(不含目录),包括隐藏文件和被忽略的文件。最多按修改时间顺序返回 100 条路径;更大的结果会从顶层条目中抽样,并报告完整列表的保存位置。
```json
{
@@ -1094,7 +1095,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费
### `grep`
-使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。前 250 条匹配会直接返回;结果达到上限时会报告完整匹配列表的保存位置。如需周边上下文,请对匹配的文件使用 read。
+使用 ripgrep 正则表达式搜索文件内容。返回带行号的匹配行,并按文件分组。最多返回 250 条匹配;更大的结果会报告完整匹配列表的保存位置。
```json
{
@@ -1294,7 +1295,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `create_goal`
-当当前直接人类请求是需要跨自主 Goal Round 持续推进的长期目标时,创建一个持久化的同会话完成目标。即使用户没有明确说「创建目标」,你也可以推断其意图。不要用于简单的单轮工作。执行时会拒绝非人类权限和 subagent 权限。
+创建一个持久化目标,使当前会话跨自动延续 Round 持续工作。当直接人类请求是长期目标时使用,即使用户没有说「目标」;不要用于单轮工作。
```json
{
@@ -1319,7 +1320,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `get_goal`
-读取当前的同会话目标,包括确切的 id/revision、目标、阶段、已完成的延续 Round 数、Round 上限、存在时的阻塞原因,以及是否已准备下一次延续。更新目标前请先调用此工具。
+读取当前会话目标,包括 update_goal 所需的 id 和 revision。
```json
{
@@ -1332,7 +1333,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
### `update_goal`
-更新确切的当前目标 revision。edit、pause 和 resume 要求直接的顶层人类请求。在自动延续当前目标期间,也允许 complete 和 blocked。在达到配置的最小 Round 数之前会拒绝 blocked;模型仍须判断相同条件是否在这些 Round 中持续存在,并在 blocked_reason 中予以说明。
+更新当前目标。
```json
{
@@ -1348,7 +1349,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
},
"action": {
"type": "string",
- "description": "edit | pause | resume | complete | blocked",
+ "description": "edit, pause, and resume require a direct top-level human request. complete and blocked are also allowed during an automatic continuation of this goal; blocked is rejected before the configured minimum round count.",
"enum": [
"edit",
"pause",
@@ -1367,7 +1368,7 @@ glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn
},
"blocked_reason": {
"type": "string",
- "description": "Concrete blocking condition; required only with action blocked."
+ "description": "Required only with action blocked: the concrete condition that persisted across rounds and blocks progress."
}
},
"required": [
@@ -1388,7 +1389,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_create`
-在当前会话中创建一条提醒。请提供非空 prompt、标题,以及恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;不小于 60 的安全整数 every_seconds;形如 {time: "23:00:00", time_zone: "Asia/Shanghai"} 的 daily;形如 {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} 的 weekly,其中周一为 1、周日为 7;或形如 {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} 的 cron,其五个字段为 minute hour day-of-month month day-of-week。每次创建都必须提供标题,最多 120 个字符且去除首尾空白后非空;该标题用于任务卡片、详情标题和任务列表。每日、每周与 cron 提醒保留指定的本地时间与时区;不存在的钟表时间会跳过该日期,重复时刻只取较早实例。cron 的 day-of-month 与 day-of-week 都受限时,匹配任一字段即算匹配。固定速率提醒在间隔编辑确立新起点前始终与创建时刻对齐。四类重复提醒都会将每条逾期规则的最新一个发生时点合并为一批投递。宿主会在提醒到期时恢复此会话。停机后,每条重复提醒只投递最新错过的一次。崩溃后可能重复投递。
+在当前会话中创建一条提醒,到期时投递 prompt。请恰好提供一个时间参数:after_seconds、at、every_seconds、daily、weekly 或 cron。时区中不存在的本地时间会被跳过;重复出现的本地时间只在较早的时刻触发一次。停机后,重复提醒只投递最近错过的一次。崩溃后可能重复投递。
```json
{
@@ -1400,19 +1401,19 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"title": {
"type": "string",
- "description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists."
+ "description": "Task name of at most 120 characters, shown on the task card and in task lists."
},
"after_seconds": {
"type": "number",
- "description": "Positive safe-integer delay in seconds."
+ "description": "Delay in whole seconds."
},
"every_seconds": {
"type": "number",
- "description": "Fixed-rate safe-integer interval in seconds, at least 60."
+ "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
- "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
+ "description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1431,7 +1432,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekly": {
"type": "object",
- "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
+ "description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1444,7 +1445,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekdays": {
"type": "array",
- "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
+ "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1458,12 +1459,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"cron": {
"type": "object",
- "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
+ "description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
- "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
+ "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1501,7 +1502,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
]
}
],
- "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
+ "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1515,7 +1516,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_delete`
-使用确切 id 删除当前会话中保留的一条提醒,活动或未运行的提醒均可删除。未知或已经删除的 id 会返回 deleted false。删除不会撤回已经入队的消息。
+删除当前会话中的一条提醒,活动或已结束的均可。删除不会撤回已经入队的提醒消息。
```json
{
@@ -1523,7 +1524,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
"properties": {
"id": {
"type": "string",
- "description": "Exact schedule id."
+ "description": "Schedule id returned by schedule_list."
}
},
"required": [
@@ -1536,7 +1537,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_list`
-列出当前会话中的所有活动提醒,包括确切 id、标题、UTC 目标、scheduled 或 overdue 状态,以及 host 交付模式。返回顺序无关紧要。
+列出当前会话中的活动提醒。
```json
{
@@ -1553,7 +1554,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
### `schedule_update`
-在当前会话中原地修改一条提醒,保留其 id 与已保存的投递记录:用 `schedule_list` 返回的精确 id 定位,然后给出新的 title 或 prompt,或从 at、every_seconds、daily、weekly、cron 中给出恰好一个新选择器(形式与 `schedule_create` 相同)。未提供的字段保持原值。`after` 不支持更新;需要相对延迟时请新建一条提醒。宿主会把它为该 id 读到的记录与已存储记录比对,因此并发编辑返回 `schedule_conflict`,而不是覆盖已存储的修改;已结束或不存在的提醒返回 `updated: false`。修改 `every_seconds` 间隔会把新的固定速率锚定在接受保存的时刻;只改名称或指令则保留已提交的目标。
+原地修改一条提醒并保留其 id。提供新的 title、prompt,或至多一个时间参数;未提供的字段保持原值。需要相对延迟时请新建一条提醒。
```json
{
@@ -1561,23 +1562,23 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
"properties": {
"id": {
"type": "string",
- "description": "Exact schedule id that schedule_list returned for one reminder."
+ "description": "Schedule id returned by schedule_list."
},
"title": {
"type": "string",
- "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name."
+ "description": "New task name of at most 120 characters."
},
"prompt": {
"type": "string",
- "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction."
+ "description": "New reminder content."
},
"every_seconds": {
"type": "number",
- "description": "Fixed-rate safe-integer interval in seconds, at least 60."
+ "description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
},
"daily": {
"type": "object",
- "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.",
+ "description": "Every day at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1596,7 +1597,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekly": {
"type": "object",
- "description": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.",
+ "description": "On the given weekdays at a local time.",
"additionalProperties": false,
"properties": {
"time": {
@@ -1609,7 +1610,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"weekdays": {
"type": "array",
- "description": "Non-empty ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
+ "description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
"items": {
"type": "integer"
}
@@ -1623,12 +1624,12 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
},
"cron": {
"type": "object",
- "description": "Five-field Vixie cron expression evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.",
+ "description": "Five-field Vixie cron expression in a time zone.",
"additionalProperties": false,
"properties": {
"expression": {
"type": "string",
- "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"."
+ "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
},
"time_zone": {
"type": "string",
@@ -1666,7 +1667,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
]
}
],
- "description": "Absolute target as strict offset RFC 3339 or local date/time with an explicit IANA zone."
+ "description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
}
},
"required": [
@@ -1764,7 +1765,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `skill`
-加载可用 skill(技能)的完整说明。在执行点名某项 skill 或与其明确匹配的任务前,请使用会话 skill 目录中的确切名称调用此工具。
+加载某项 skill(技能)的完整说明。在执行点名某项 skill 或与会话 skill 目录中某项 skill 明确匹配的任务前,请调用此工具。
```json
{
@@ -2046,7 +2047,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `subagent`
-将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。
+将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此调用默认等待结果。
```json
{
@@ -2062,7 +2063,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
},
"run_in_background": {
"type": "boolean",
- "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill."
+ "description": "Run as a background job and return its id (collect with job_output, stop with job_kill). Defaults to false."
}
},
"required": [
@@ -2082,7 +2083,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `interrupt_agent`
-根据 agent id 请求取消后台 agent 的当前轮次。目标可以是你的直接子级,也可以是在你下方创建的更深层 agent。只有当前轮次会停止:已经排队发给该 agent 的消息会一直搁置到后续的 send_message;它启动的 agent 会继续运行;该 agent 本身仍可接受后续操作。停止请求被接受后,此调用立即返回,因此目标可能还会短暂运行;中断一个已经完成的 agent 是可接受的空操作。
+请 subagent 停止当前工作。此调用不等待其停止即返回。之后可以用 send_message 继续与直接子级的对话。它启动的 subagent 会继续运行。
```json
{
@@ -2090,7 +2091,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
"properties": {
"agent_id": {
"type": "string",
- "description": "The agent id of the running agent to interrupt."
+ "description": "The id of an agent created under you: your direct child or a deeper descendant."
}
},
"required": [
@@ -2103,7 +2104,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `list_agents`
-按持久 id 和标签列出你的可继续后台 subagent。用它回忆你启动过哪些 subagent,而不是轮询完成情况——subagent 完成时你会被告知。状态来自实时注册表:running 表示 agent 此刻正在工作;inactive 表示没有轮次在执行,包括已加载和需要恢复的 child。inactive 不表示任务完成、成功、失败或等待其他 agent。`send_message` 会在运行中 child 的最近 step 边界 steer 消息,或为 inactive child 启动或恢复轮次,且无论处于哪种状态,直接子级都仍可作为 `send_message` 的目标。该快照并非投递承诺;`send_message` 会执行权威检查,仍可能失败。无法读取的子级仅在 `descendants` 作用域中作为诊断信息报告。`descendants` 作用域会按稳定的前序顺序遍历你下方的整棵树,并为每个条目标注其持久的直接父会话 id 和深度。只有深度为 1 的条目可以使用 `send_message`;更深的条目只能作为 `interrupt_agent` 的候选目标。
+列出你启动的 subagent 及其 id、标签和状态。running 表示正在工作;inactive 表示当前未在工作。subagent 完成时你会收到通知,无需反复查看状态。使用 send_message 继续对话。
```json
{
@@ -2111,7 +2112,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
"properties": {
"scope": {
"type": "string",
- "description": "children (default) lists direct children only; descendants walks the complete tree below you.",
+ "description": "children (default) lists direct children, which accept send_message in any status. descendants lists the whole tree below you with each entry's parent session id and depth; entries deeper than 1 accept only interrupt_agent.",
"enum": [
"children",
"descendants"
@@ -2125,7 +2126,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `send_message`
-根据 agent id 向直接可继续 child 发送消息。如果你是驻留的可继续 child,也可以把自己的直接 parent 作为目标。如果目标仍在工作,消息会 steer 其最近的 step;如果目标处于 inactive,消息会启动或恢复一个轮次。此调用不会返回该 agent 的答案,只会确认消息已投递。调用失败表示消息**未**投递。
+向某个 agent 发送消息。工作中的 agent 会在下一个 step 收到消息;空闲的 agent 会以该消息开始新一轮。返回投递确认,而不是该 agent 的答案。
```json
{
@@ -2157,7 +2158,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `job_kill`
-根据 job id 请求取消正在运行的后台任务。此调用立即返回;任务的工作真正停止后,会以 killed 状态结算。
+请求取消正在运行的后台任务。
```json
{
@@ -2195,7 +2196,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `job_output`
-读取后台任务。流式任务只返回自上次读取以来的输出;最终输出任务会在结算后返回结果。每个响应都以 `[status: ...]` 结尾。读取默认不阻塞;设置 `wait: true` 后,最长等待到配置的上限。
+读取后台任务:流式任务返回自上次读取以来的输出,已完成的最终输出任务返回其结果。
```json
{
@@ -2207,11 +2208,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
},
"wait": {
"type": "boolean",
- "description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
+ "description": "Block until the job finishes or the timeout expires; a timed-out wait leaves the job running. Defaults to false."
},
"timeout_ms": {
"type": "number",
- "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
+ "description": "Max wait in milliseconds with wait: true. Defaults to and is capped by configuration."
}
},
"required": [
@@ -2521,7 +2522,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `todo_write`
-记录并更新当前工作的结构化任务列表。每次调用都要发送**完整列表**,它会**替换**之前的列表,不支持局部更新或逐项编辑。请用它规划多步骤工作并展示进度:开始前为每个具体步骤添加一项 todo。将当前正在处理的每项 todo 标记为 `in_progress`;确实并行运行时(例如并发 subagent 或后台命令)可同时标记多项,顺序工作则标记 1 项。只要工作尚未完成,就应至少有一项任务为 `in_progress`。某项 todo 完成后立即标记为 `completed`,不要批量标记完成;只有全部工作完成后,才可以没有 `in_progress` 项。简单的单步骤任务无需使用列表。状态:`pending`(未开始)、`in_progress`(正在处理)、`completed`(已完成)。
+记录并更新任务列表,用于规划多步骤工作并展示进度;简单的单步骤任务无需使用。开始前为每个具体步骤添加一项 todo。只要工作尚未完成,就将正在处理的 todo 标记为 `in_progress`,仅在工作并行运行时同时标记多项。某项 todo 完成后立即标记为 `completed`。
```json
{
@@ -2573,18 +2574,14 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
运行用于大规模编排 subagent 的 JavaScript 工作流脚本。当工作会分散到许多相互独立的部分时,请使用此工具,例如审查大量文件、执行迁移、开展多角度研究或对发现进行对抗式验证;此时应将编排写成脚本,而不是逐轮委派。
-工作流的身份通过 `meta` 参数以 JSON 形式传入:必填的 `name`(简短 kebab-case)和 `description` 字符串,以及可选的 `whenToUse` 字符串和 `phases` 数组(`{title, detail?, provider?, model?}`)。`script` 参数只能是纯 JavaScript **函数体**,不能是 TypeScript,也不能包含 `export const meta` 语句;meta 是参数而非代码。脚本支持顶层 await;请以 `return ` 结尾,该值必须可以 JSON 序列化,并作为此工具的结果。
-
脚本函数体提供以下钩子:
-- `agent(prompt, opts?): Promise`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,不支持 pattern/format/数值边界,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项,两者可单独提供。其他任何选项(`effort`/`isolation`/`agentType`)都会明确报错。
-- `pipeline(items, ...stages): Promise`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。普通的阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。
+- `agent(prompt, opts?): Promise`:运行一个 subagent 直至完成。不提供 `opts.schema` 时,解析为子级最终文本;提供 `opts.schema` 时,它必须是以对象为根、且**只能**使用 type/properties/required/additionalProperties/items/enum/const/oneOf 的 JSON Schema,此时解析为通过校验的对象。子级失败时解析为 `null`,可使用 `.filter(Boolean)` 过滤。其他选项包括 `label`(显示名称)、`phase`(进度组),以及相互独立的 `provider`/`model` LLM(大语言模型)目标覆盖项。
+- `pipeline(items, ...stages): Promise`:让每个条目分别经过各阶段,阶段之间**没有**屏障;多阶段工作优先使用它。每个阶段接收 `(prev, item, index)`。阶段异常会将该**条目**变为 `null`,并跳过它的剩余阶段。
- `parallel(thunks): Promise`:并发运行零参数函数并等待**全部**完成。它会形成屏障,仅当某个阶段确实需要汇总全部先前结果时使用。抛出异常的 thunk 解析为 `null`。
- `phase(title)`:开始一个进度阶段;`log(message)`:说明进度;`args`:工具调用的 `args` 输入,原样提供。
-如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),抛出的错误**总会**终止脚本,绝不会退化为单个条目的 `null`。
-
-约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行默认在前台执行:整个脚本完成后,调用才会返回。长时间运行请设置 `run_in_background: true`:调用会立刻返回任务 id,运行在后台继续编排,其返回值随任务的完成播报送达(用 `job_output` 查看进展,用 `job_kill` 停止)。
+如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),整个脚本会终止,而不会产生 `null`。脚本没有文件系统、网络、定时器或 Node.js API;具体工作由 agent 完成。
```json
{
@@ -2592,11 +2589,11 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"properties": {
"script": {
"type": "string",
- "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."
+ "description": "The plain JavaScript body, not TypeScript and without an `export const meta` statement; top-level await is allowed. End with `return `; the JSON-serializable value is this tool's result."
},
"meta": {
"type": "object",
- "description": "The workflow identity block (plain JSON — never code).",
+ "description": "The workflow identity as plain JSON, not code.",
"additionalProperties": true,
"properties": {
"name": {
@@ -2709,7 +2706,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
### `web_search`
-在 Web 上搜索最新信息。在必填的 `queries` 数组中提供 1–4 个查询。返回可选的摘要答案和来源 URL 列表。
+在 Web 上搜索最新信息。返回可选的摘要答案和来源 URL 列表。
```json
{
@@ -2717,7 +2714,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"properties": {
"queries": {
"type": "array",
- "description": "Required search queries; accepts 1–4 items and merges their results.",
+ "description": "1–4 search queries; their results are merged.",
"items": {
"type": "string"
}
diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml
index 0e59fd82b2..1eac40d8fb 100644
--- a/docs/user/guide/python-sdk.i18n.yaml
+++ b/docs/user/guide/python-sdk.i18n.yaml
@@ -24,5 +24,5 @@
en: c63ca1e3a99e6fe1
zh: 2ceddea3f30d92d3
/get-started-with-the-python-sdk/understand-the-minimal-profile:
- en: a741bfb098e18d7b
- zh: 318c82836bdc19bd
+ en: 5eaacc1bc52853c4
+ zh: 5b94bfc79aac04d8
diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md
index a14d656171..837e97aa27 100644
--- a/docs/user/guide/python-sdk.md
+++ b/docs/user/guide/python-sdk.md
@@ -177,7 +177,7 @@ Pass `patches=("/absolute/path/to/editor.patch.yml",)` when constructing `DeepSe
| 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 filesystem tools, settings, managed credentials, OTel telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads complete unaccepted log suffixes with DeepSeek requests by default; set `session-log-deepseek.enabled: false` in a profile patch to disable it. 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 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, OTel telemetry, Web tools, subagents, local instruction discovery, and compaction are absent. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) uploads unaccepted log events with DeepSeek requests by default, at most `maxBytes` (8 MiB) per request; set `session-log-deepseek.enabled: false` in a profile patch to disable it. 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 0846ed95ef..d7742bec58 100644
--- a/docs/user/guide/python-sdk.zh.md
+++ b/docs/user/guide/python-sdk.zh.md
@@ -177,7 +177,7 @@ dsh plugin --profile sdk-minimal add file:C:/work/my-plugin-bundle
| 运行时上下文与 compaction | 不存在 |
| 会话持久化 | `/sessions` 下的未压缩 JSONL |
-该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传完整的未接受日志后缀;在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。
+该 profile 的唯一组合包会在空根之上插入完整配置树,且不包含 `dsh-base`,因此基础 profile 以后新增的工具不会隐式出现。它包含 SDK 协议、一个由环境配置的 DeepSeek 适配器、本地执行与持久化;文件系统工具、settings、托管凭据、OTel 遥测、Web 工具、subagent、本地指令发现和 compaction 均不存在。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随 DeepSeek 请求上传未接受的日志事件,每次请求最多 `maxBytes`(8 MiB);在 profile patch 中设置 `session-log-deepseek.enabled: false` 可将其关闭。它固定使用 `danger-full-access`,因此按平台选择的持久 shell 可以修改运行时可见的任何路径;应使用一次性 checkout 或容器。
已安装 wheel 仍会打包完整 `web` profile 与前端产物。如果 Python SDK 部署还需要浏览器应用,请针对显式 `DSH_HOME` 运行 `dsh web`;`web` 是独立 CLI 应用,不能为 Python SDK client 提供服务。
diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml
index a6033b0b82..1a1a1c08f9 100644
--- a/docs/user/guide/schedule.i18n.yaml
+++ b/docs/user/guide/schedule.i18n.yaml
@@ -12,8 +12,8 @@
en: b3b2a6e0ce02bdee
zh: 2ddb168de79fd19c
/schedule-reminders/create-reminders:
- en: 70ae56895847c453
- zh: 19b38057c7462123
+ en: 68763ffeedaeea3c
+ zh: 739af012a337d448
/schedule-reminders/inspect-and-delete-tasks:
en: a44fc1aabc2ab957
zh: 31f444d4c7fd5c7a
diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md
index b0058b0cba..97db8089b0 100644
--- a/docs/user/guide/schedule.md
+++ b/docs/user/guide/schedule.md
@@ -17,7 +17,7 @@ Create reminders in a conversation, then inspect active and inactive tasks and e
## Create reminders
-The shipped Web profile mounts Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders.
+The shipped Web profile does not include Schedule. Open Plugins and enable Automation tasks in the Official group; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` then adds Schedule with the clock context that gives the model the current time and the browser's zone. Configure a model provider before asking it to create reminders.
Ask the model to create, list, edit, or delete reminders. It uses `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` (update changes one reminder in place and keeps its id and saved delivery records); the Automation tasks page's New action opens a New Session for a creation instead, with the request already written in its composer. Supported choices are a one-time delay in positive whole seconds, an absolute date and time, a fixed interval of at least one minute, a daily local time with an IANA time zone, a weekly local time with an IANA time zone and ISO weekdays from Monday 1 through Sunday 7, or a five-field cron expression with an explicit IANA time zone, stored in canonical form.
diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md
index 60829d82ae..fcfbfc1082 100644
--- a/docs/user/guide/schedule.zh.md
+++ b/docs/user/guide/schedule.zh.md
@@ -17,7 +17,7 @@
## 创建提醒
-发布的 Web profile 会挂载 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。
+发布的 Web profile 不含 Schedule。打开插件管理页,在“官方”分组中启用“自动化任务”;可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 随即加入 Schedule 及其时钟上下文,后者把当前时间和浏览器时区交给模型。在请求模型创建提醒前,先配置模型 provider。
向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少一分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。
diff --git a/package.json b/package.json
index 8ea0f03ef2..4317f7dc94 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-root",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"license": "MIT",
"private": true,
"type": "module",
@@ -181,8 +181,8 @@
"gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts",
"verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check",
"gen-module-graph": "tsx scripts/gen-module-graph.ts",
- "gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
- "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
+ "gen-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts",
+ "verify-scoped-events": "node --max-old-space-size=4096 --import tsx/esm scripts/gen-scoped-events.ts --check",
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts",
"doc-sync": "tsx scripts/run-gates.ts doc-sync",
diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml
index d091e6d74c..f9879d6dcd 100644
--- a/packages/README.i18n.yaml
+++ b/packages/README.i18n.yaml
@@ -15,8 +15,8 @@
en: ffc55792d6e922e1
zh: 83bd759638857e2d
/packages/package-groups:
- en: 2818a70fa6bbb1b6
- zh: 800b34102e82c192
+ en: d865abbfa65d1d4f
+ zh: ea079b795b51dd55
/packages/release-expectations:
en: 1ab880da3642bde1
zh: 6e168322f026a9cf
diff --git a/packages/README.md b/packages/README.md
index 54c4cdc758..cfb5ea6c74 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -34,6 +34,7 @@ Every package lives in exactly one group; new packages join existing groups, and
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle |
| [`schedule/`](schedule/README.md) | Host-owned scheduled follow-ups |
| [`feedback/`](feedback/README.md) | Human feedback capture and command |
+| [`telemetry/`](telemetry/README.md) | Shared Cordis OTel reporting channels |
| [`identity/`](identity/README.md) | Shared anonymous identity |
| [`llm/`](llm/README.md) | LLM capability family: abstract service + provider adapters |
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider |
diff --git a/packages/README.zh.md b/packages/README.zh.md
index 9ea4426082..2edb62d8cd 100644
--- a/packages/README.zh.md
+++ b/packages/README.zh.md
@@ -34,6 +34,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会
| [`goal/`](goal/README.zh.md) | 同会话 goal 的持久化与生命周期 |
| [`schedule/`](schedule/README.zh.md) | Host 拥有的定时后续操作 |
| [`feedback/`](feedback/README.zh.md) | 人类反馈的采集与命令 |
+| [`telemetry/`](telemetry/README.zh.md) | 共享 Cordis OTel 上报通道 |
| [`identity/`](identity/README.zh.md) | 共享匿名身份 |
| [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 |
| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition + 本地进程树提供方 |
diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json
index 3886cbade7..039aeca6e0 100644
--- a/packages/acp/acp/package.json
+++ b/packages/acp/acp/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-acp",
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json
index e40902707a..0d6ca4a08f 100644
--- a/packages/api/account-controller/package.json
+++ b/packages/api/account-controller/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-account-controller",
"description": "Expose safe account operations over authenticated Remote",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json
index ba7cf84dfe..ee277b989c 100644
--- a/packages/api/gateway/package.json
+++ b/packages/api/gateway/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-gateway",
"description": "Typert Remote Host dispatcher and Client API endpoint",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json
index 8901819eb9..e9644eb115 100644
--- a/packages/api/job-controller/package.json
+++ b/packages/api/job-controller/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-job-controller",
"description": "Job Remote observation stream and the reference-counted client job-output service",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml
index 16ee438803..93d585f2c4 100644
--- a/packages/api/remotes/README.i18n.yaml
+++ b/packages/api/remotes/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 1c32778606f3027a
zh: db7994fdb50333c8
/deepseek-ai-dsh-api-remotes:
- en: b5349178e12f23fe
- zh: dfdb4de9d99fbc7b
+ en: f0e152bd9c846ec4
+ zh: dba8ab5fa1181276
/deepseek-ai-dsh-api-remotes/summary:
en: 3c54c157bb823fd0
zh: 0db6778e94ae671c
diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md
index 78a8d8fc06..20d818b659 100644
--- a/packages/api/remotes/README.md
+++ b/packages/api/remotes/README.md
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
+
## Summary
Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns the forwarded-event selection and registers its application event source with API Gateway; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries.
diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md
index cd09dbb7a3..b3bda48bac 100644
--- a/packages/api/remotes/README.zh.md
+++ b/packages/api/remotes/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
+
## 概述
为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。
diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json
index 54ec56c0fa..09b64bebe7 100644
--- a/packages/api/remotes/package.json
+++ b/packages/api/remotes/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-remotes",
"description": "Remote BFF assembly for application-selected Host capabilities",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
@@ -94,6 +94,7 @@
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-office-to-pdf": "workspace:*",
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
- "@deepseek-ai/dsh-deepseek-account": "workspace:*"
+ "@deepseek-ai/dsh-deepseek-account": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
}
}
diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts
index 7262b843a7..535d7a5243 100644
--- a/packages/api/remotes/src/client/index.ts
+++ b/packages/api/remotes/src/client/index.ts
@@ -1,6 +1,8 @@
/** Platform-neutral assembly of generated Host Remote contributions. */
import type { Context } from '@deepseek-ai/cordis'
+import productAnalyticsRemote from '@deepseek-ai/dsh-client-product-analytics/remote'
+export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
import agentPresetsRemote from '@deepseek-ai/dsh-agent-preset-registry/remote'
import commandsRemote from '@deepseek-ai/dsh-commands/remote'
import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote'
@@ -175,7 +177,8 @@ export async function apply(ctx: Context): Promise<() => Promise> {
const disposers: Array<() => Promise> = []
try {
for (const contribution of [
- agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
+ productAnalyticsRemote, agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote,
+ goalsRemote, llmRemote, dynamicRemote, scheduleRemote,
pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote,
fileUploadsRemote, sessionReferencesRemote,
permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote,
diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json
index 2e74f687da..279c07025f 100644
--- a/packages/api/remotes/tsconfig.client.json
+++ b/packages/api/remotes/tsconfig.client.json
@@ -11,6 +11,7 @@
"src/types.ts"
],
"references": [
+ { "path": "../../client/product-analytics/tsconfig.client.json" },
{
"path": "../../../vendor/cordis"
},
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 82df796f3f..5878d2f71b 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 82789442228fc62a
zh: f1d511ad838187de
/session-controller:
- en: fb78499ee63f41b9
- zh: 6fff916b1fa26314
+ en: c7a4c58ea6d3437b
+ zh: 45c2b472a074bb1b
/session-controller/summary:
en: 4914824c347fdab5
zh: 684a7b7fa970c39f
@@ -21,8 +21,8 @@
en: 41bdda1ac32df558
zh: aa5d0e325924066b
/session-controller/session-media-references:
- en: c7e6bbb57a4894b7
- zh: f66d79776a7e33ec
+ en: 266ab2b1e2fec542
+ zh: b3450ae28a1e0baf
/session-controller/configuration:
en: 5c4c4224074e38a8
zh: e0b0edac65c88ff4
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 476eb9bb1a..be3f9a0bf5 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -6,6 +6,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its launch-time switch. Web usage is excluded.
+
## Summary
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, human background-job kill, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
@@ -80,6 +82,8 @@ References keep local Session data, scoped Contexts, and history streams alive,
GUI model selection requires the exact provider/model pair in the available catalog; unavailable choices reject with `session/model-unavailable`. Prompt admission retains the saved route without a catalog availability gate, so request execution reports missing credentials or unavailable models. `initializeDefaultModel()` saves the first available account model after login when no other provider has a configured API key; credential checks use configured references independently of model availability. A provider with no available models rejects initialization with `session/provider-models-unavailable`. Availability never substitutes another model or rewrites a Session selection.
+A successful `selectModel` response acknowledges the Session-local selection without waiting for the default profile setting to save. Default saves run in the background in submission order; a failure logs a warning and leaves the Session selection intact. New Sessions read the last successfully saved default.
+
-----
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 91e563c3a8..de40067e39 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -6,6 +6,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其启动时开关,不包含 Web 使用情况。
+
## 概述
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、人工后台任务终止、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
@@ -80,6 +82,8 @@ Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以
GUI 模型选择要求确切提供方/模型对出现在可用目录中;不可用的选择以 `session/model-unavailable` 拒绝。提示词准入保留已保存的路由,不按目录可用性阻断发送,由请求执行报告凭据缺失或模型不可用。`initializeDefaultModel()` 在账号登录后、其他提供方均未配置 API key 时,将第一个可用账号模型保存为默认模型;凭据检查使用已配置的引用,不依赖模型是否可用。提供方没有可用模型时,初始化以 `session/provider-models-unavailable` 拒绝。可用性变化不会替换模型或改写会话选择。
+`selectModel` 成功返回表示会话级模型选择已生效,不等待默认 profile 设置保存。默认设置在后台按提交顺序保存;保存失败会记录警告,并保留会话选择。新会话读取最近一次成功保存的默认值。
+
-----
diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json
index c62ba655e8..7b1808168d 100644
--- a/packages/api/session-controller/package.json
+++ b/packages/api/session-controller/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-session-controller",
"description": "Session Remote commands, cold reads, and live control transport",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts
index 8f016ab711..076188c1e0 100644
--- a/packages/api/session-controller/src/client/contract/sessions.ts
+++ b/packages/api/session-controller/src/client/contract/sessions.ts
@@ -125,11 +125,16 @@ export interface ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
- * inherited durable title before resolving.
+ * inherited durable title before resolving. `onCreated` observes the catalogued child before that optional rename.
* @returns the child session id.
* @throws when the fork fails, or when a requested child-title rename fails after creation.
*/
- fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise
+ fork(opts: {
+ sessionId: SessionId
+ atSeq?: number
+ increaseTitle?: boolean
+ onCreated?: (childId: SessionId) => void
+ }): Promise
/**
* Borrow an already-retained Agent-scoped Context without extending its lifetime.
* @param id - session id.
diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts
index 48d8f6a533..8166b6f32d 100644
--- a/packages/api/session-controller/src/client/sessions/service.ts
+++ b/packages/api/session-controller/src/client/sessions/service.ts
@@ -452,6 +452,7 @@ export class ClientSessions implements ISessions {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
+ onCreated?: (childId: SessionId) => void
}): Promise {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
@@ -463,6 +464,7 @@ export class ClientSessions implements ISessions {
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
const childId = result.value.sessionId
+ opts.onCreated?.(childId)
if (sourceTitle !== undefined) {
const renamed = await this.manager.rename(childId, increasedForkTitle(sourceTitle))
if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`)
diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts
index 89300147ab..7e900b92ae 100644
--- a/packages/api/session-controller/src/commands.ts
+++ b/packages/api/session-controller/src/commands.ts
@@ -144,9 +144,9 @@ export class SessionCommandController {
}
/**
- * Validate and install one Session-local model selection.
+ * Validate and install one Session-local model selection; save the default in the background.
* @param request - Session identity and requested model selection.
- * @returns the normalized selection installed for the Session.
+ * @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
async selectModel(request: SessionSelectModelRequest): Promise {
const agent = await this.resolveAgent(request.sessionId)
@@ -168,13 +168,11 @@ export class SessionCommandController {
: { reasoningEffort: resolved.reasoningEffort }),
}
this.agents.selectForNextRequest(agent, selected)
- try {
- await this.ctx.agentDefaultModel.saveSelection(selected)
- } catch (error) {
+ void this.ctx.agentDefaultModel.saveSelection(selected).catch((error: unknown) => {
this.ctx.logger.warn(
`session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`,
)
- }
+ })
return { selected: { ...selected } }
} catch (error) {
if (remoteErrorOf(error) !== undefined) throw error
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index 86907feb76..3fe15221fb 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -275,9 +275,9 @@ export class SessionController extends TypertRemoteService {
}
/**
- * Select one Session-local model after explicitly resuming the Session.
+ * Select one Session-local model after explicitly resuming the Session; save the default in the background.
* @param request - Session identity and requested model selection.
- * @returns the normalized selection installed for the Session.
+ * @returns the normalized selection installed for the Session, without waiting for default persistence.
*/
@Remote('selectModel')
selectModel(request: SessionSelectModelRequest): Promise {
diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts
index 0a213ca360..e852460e93 100644
--- a/packages/api/session-controller/tests/session-models.host.spec.ts
+++ b/packages/api/session-controller/tests/session-models.host.spec.ts
@@ -5,7 +5,7 @@
* boundary for a running selection change.
*/
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, vi, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -617,8 +617,40 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
+ it('accepts consecutive model switches while default persistence is blocked', async () => {
+ const { ctx, sessionId } = await harness()
+ const release = Promise.withResolvers()
+ const saves: Promise[] = []
+ const operations: Promise[] = []
+ onTestFinished(async () => {
+ release.resolve(undefined)
+ await Promise.allSettled([...operations, ...saves])
+ await ctx.fiber.dispose()
+ })
+ const remote = createSessionTestRemote(ctx, {
+ defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
+ saveDefaultModelSelection: () => {
+ saves.push(release.promise)
+ return release.promise
+ },
+ cwd: '/tmp',
+ })
+
+ for (const model of ['deepseek-reasoner', 'deepseek-chat']) {
+ let returned = false
+ const operation = remote.selectModel({ sessionId, provider: 'deepseek-official', model })
+ .then((result) => { expectValue(result); returned = true })
+ operations.push(operation)
+ await expect.poll(() => returned).toBe(true)
+ expect(currentSelection(ctx, sessionId)).toMatchObject({ provider: 'deepseek-official', model })
+ }
+ expect(saves).toHaveLength(2)
+ })
+
it('saves an accepted selection as the default and survives a storage failure', async () => {
const { ctx, sessionId } = await harness()
+ const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+ onTestFinished(() => { warn.mockRestore() })
const saved: unknown[] = []
let reject = false
const remote = createSessionTestRemote(ctx, {
@@ -650,6 +682,9 @@ describe('Web session model selection', () => {
expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
expect(currentSelection(ctx, sessionId))
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' })
+ await expect.poll(() => warn.mock.calls).toContainEqual([
+ 'session-controller: model selection changed for the Session but the default was not saved: Error: read-only document',
+ ])
await ctx.fiber.dispose()
})
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index ef3012c393..e25339bc93 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -983,8 +983,10 @@ describe('fork', () => {
b.mock.remote.session.fork.mockResolvedValue(ok({ sessionId: sid('child') }))
b.mock.remote.session.rename.mockResolvedValue(err(new RemoteError('session/title-invalid', 'rejected', { sessionId: sid('child') })))
- await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true }))
+ const onCreated = vi.fn()
+ await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true, onCreated }))
.rejects.toThrow('fork child rename failed: session/title-invalid: rejected')
+ expect(onCreated).toHaveBeenCalledExactlyOnceWith('child')
expect(b.svc.list.getSnapshot().byId[sid('child')]).toBeDefined()
expect(b.svc.binding(sid('child'))).toBeUndefined()
expect(b.svc.retainInfo(sid('child')).getSnapshot().referenceCount).toBe(0)
diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json
index d386f39841..140320b162 100644
--- a/packages/api/settings-controller/package.json
+++ b/packages/api/settings-controller/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-settings-controller",
"description": "Remote owner for the configuration surfaces over the settings-domain seams",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json
index 757e0dba78..99fb5b4757 100644
--- a/packages/api/terminal-controller/package.json
+++ b/packages/api/terminal-controller/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-terminal-controller",
"description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json
index 7e0e1194f3..df1549e04c 100644
--- a/packages/api/workspace-controller/package.json
+++ b/packages/api/workspace-controller/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-workspace-controller",
"description": "Workspace Remote commands and reconnect-safe state transport",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml
index 1a68691ebe..e76b5b1e0f 100644
--- a/packages/api/workspace-files/README.i18n.yaml
+++ b/packages/api/workspace-files/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 93ca9e832fbfd632
zh: 69412adba259c6f8
/deepseek-ai-dsh-api-workspace-files/use-this-package/file-read-and-directory-checks:
- en: 4db14c7ce7bc3390
- zh: fa97e79fb2c07ce2
+ en: aa85c7754557a22f
+ zh: c576d06749fbe0a5
/deepseek-ai-dsh-api-workspace-files/use-this-package/the-change-feed:
en: 8bd2d820a512d461
zh: 0614ddd842ae7526
diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md
index 0345887e58..3511616eea 100644
--- a/packages/api/workspace-files/README.md
+++ b/packages/api/workspace-files/README.md
@@ -51,7 +51,7 @@ Pass the required options object as `{}` for a complete file under `maxFileBytes
### File-read and directory checks
-File reads and directory listing first use `lstat` to reject a missing path, a final symlink, or the wrong file kind. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. Directory listing and watching require the resolved directory to remain inside the workspace root. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty read or listing path is a `gateway/bad-request`.
+Every operation first uses `lstat` to reject a missing path or the wrong file kind; the file-reading operations also refuse a final symlink, even one pointing back inside the workspace. `list` instead follows a final link — a directory symlink on any platform, including a Windows junction — and requires it to resolve to a directory inside the workspace root, so a linked directory lists like its target; `changes` confines the watched directory the same way. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty read or listing path is a `gateway/bad-request`.
### The change feed
diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md
index 572b83ec37..d11740b8e7 100644
--- a/packages/api/workspace-files/README.zh.md
+++ b/packages/api/workspace-files/README.zh.md
@@ -51,7 +51,7 @@ kind: "package-reference"
### 文件读取与目录检查
-文件读取和目录列举先通过 `lstat` 拒绝不存在的路径、末端符号链接或错误的文件类型。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。目录列举和监听要求解析后的目录仍位于工作区内。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。读取或列举的空路径是 `gateway/bad-request`。
+每项操作都先通过 `lstat` 拒绝不存在的路径或错误的文件类型;读取文件的操作还拒绝末端符号链接,包括指回工作区内的链接。`list` 改为跟随末端链接——任何平台上的目录符号链接,包括 Windows 目录联接——要求它解析为工作区内的目录,因此被链接的目录与其目标一样可列举;`changes` 也以同样方式把被监听的目录限制在工作区内。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。读取或列举的空路径是 `gateway/bad-request`。
### 变更流
diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json
index 9471e8f116..b5035d87c7 100644
--- a/packages/api/workspace-files/package.json
+++ b/packages/api/workspace-files/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-api-workspace-files",
"description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts
index c5d1542593..1c1c652f1e 100644
--- a/packages/api/workspace-files/src/index.ts
+++ b/packages/api/workspace-files/src/index.ts
@@ -302,7 +302,10 @@ export class WorkspaceFiles extends TypertRemoteService {
@Remote
async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise {
const { root, workspaceRoot, entry } = await this.inspect(workspaceFileScope, path, signal)
- if (entry.type !== 'directory') {
+ // A final link — a Windows junction or a symlink — is listed through the
+ // directory it resolves to, matching the child type `listDir` reports for
+ // that entry; `read` keeps its own no-follow gate on the final component.
+ if (entry.type !== 'directory' && entry.type !== 'symlink') {
throw new RemoteError(
'workspace-file/not-directory',
`"${path}" is a ${entry.type}`,
@@ -310,6 +313,12 @@ export class WorkspaceFiles extends TypertRemoteService {
)
}
const target = await this.confine(root, workspaceRoot, path, signal)
+ if (entry.type === 'symlink') {
+ const info = await this.ctx.fs.stat(target, signal)
+ if (info?.type !== 'directory') {
+ throw new RemoteError('workspace-file/not-directory', `"${path}" does not resolve to a directory`, { path, kind: 'symlink' })
+ }
+ }
const children = await this.ctx.fs.listDir(target, signal)
return {
path: workspacePathOf(this.ctx.fs.fileUrl(root), this.ctx.fs.fileUrl(target)),
diff --git a/packages/api/workspace-files/tests/list.spec.ts b/packages/api/workspace-files/tests/list.spec.ts
index 6cadad6ce3..e2e603dfe4 100644
--- a/packages/api/workspace-files/tests/list.spec.ts
+++ b/packages/api/workspace-files/tests/list.spec.ts
@@ -1,6 +1,6 @@
/** The `list` endpoint: the same containment gates as `read`, plus the entry cap. */
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
-import { mkdir, symlink, writeFile } from 'node:fs/promises'
+import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { failureOf, openWorkspace, signal, type Harness } from './harness.ts'
@@ -20,6 +20,10 @@ afterEach(async () => {
const endpoint = (caps?: { maxEntries?: number }): ReturnType => harness.endpoint(caps)
+/** Create a link to a directory: a Windows junction where junctions exist, a directory symlink elsewhere. */
+const directoryLink = (target: string, link: string): Promise =>
+ symlink(target, link, process.platform === 'win32' ? 'junction' : 'dir')
+
describe('workspaceFiles.list — the happy path', () => {
it('lists the root as the empty workspace path, with types and file sizes', async () => {
await mkdir(join(workspace, 'src'))
@@ -64,6 +68,15 @@ describe('workspaceFiles.list — the happy path', () => {
{ name: 'to-file', type: 'file', size: 1 },
])
})
+
+ it('lists a directory link whose target is inside the workspace', async () => {
+ await mkdir(join(workspace, 'real-dir'))
+ await writeFile(join(workspace, 'real-dir', 'child.txt'), 'x', 'utf8')
+ await directoryLink(join(workspace, 'real-dir'), join(workspace, 'dir-link'))
+ const listing = await endpoint().list(harness.scope, 'dir-link', signal())
+ expect(listing.path).toBe('real-dir')
+ expect(listing.entries).toEqual([{ name: 'child.txt', type: 'file', size: 1 }])
+ })
})
describe('workspaceFiles.list — the entry cap', () => {
@@ -93,11 +106,19 @@ describe('workspaceFiles.list — gates', () => {
expect(failure.code).toBe('workspace-file/outside-workspace')
})
- it('rejects a symlinked directory before following it, wherever it points', async () => {
- await symlink(outside, join(workspace, 'escape'))
+ it('rejects a directory link that resolves outside the workspace', async () => {
+ await directoryLink(outside, join(workspace, 'escape'))
const failure = await failureOf(endpoint().list(harness.scope, 'escape', signal()))
+ expect(failure.code).toBe('workspace-file/outside-workspace')
+ })
+
+ it('rejects a directory link whose target is gone', async () => {
+ await mkdir(join(workspace, 'vanishing'))
+ await directoryLink(join(workspace, 'vanishing'), join(workspace, 'dangling'))
+ await rm(join(workspace, 'vanishing'), { recursive: true })
+ const failure = await failureOf(endpoint().list(harness.scope, 'dangling', signal()))
expect(failure.code).toBe('workspace-file/not-directory')
- expect(failure.details).toMatchObject({ kind: 'symlink' })
+ expect(failure.details).toMatchObject({ path: 'dangling', kind: 'symlink' })
})
it('rejects a file, which has no children to list', async () => {
diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json
index 7a22b072f9..f2a040cf8e 100644
--- a/packages/attachment/attachment-local/package.json
+++ b/packages/attachment/attachment-local/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-attachment-local",
"description": "Private content-addressed DSH_HOME attachment storage",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json
index 48fec0776e..d2e31eed59 100644
--- a/packages/attachment/attachment/package.json
+++ b/packages/attachment/attachment/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-attachment",
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml
index 6c0d9fcddc..bd29896a32 100644
--- a/packages/boot/app-boot/README.i18n.yaml
+++ b/packages/boot/app-boot/README.i18n.yaml
@@ -24,8 +24,8 @@
en: 1099528bde0e2aee
zh: b68772bdb9bb7856
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
- en: faa66e7b3b641fa1
- zh: c54b742abb967726
+ en: d50b0fdaf62353dd
+ zh: 0fccb6968602e5de
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
en: 646a0ccdd7881ad1
zh: e3696acb1573dd7b
diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md
index c32f8e4dd9..aae8f44cc6 100644
--- a/packages/boot/app-boot/README.md
+++ b/packages/boot/app-boot/README.md
@@ -47,11 +47,11 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data.
-A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile ` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures print a diagnostic and skip that bundle without changing its selection. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
+A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles with its own `cordis.patch.yml`. A bundle's `dsh.bundle.patch` names one patch file or an ordered list of files; `bundlePatchFiles` validates the declaration and `bundlePatchPaths` resolves it to absolute paths; the layer concatenates their patch lists in that order. The YAML composition enables or disables HMR. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile ` creates a custom profile at a new non-shipped name from one shipped template, while `dsh plugin` initializes a base-backed profile and manages its installed bundles. Bundle resolution, manifest, and patch-loading failures skip that bundle without changing its selection; the loaded profile lists each skip in `skippedBundles`, and the launcher prints them once per start with `reportSkippedBundles`. Remaining bundles keep their order; profile and user-patch errors still fail startup. Skipping a bundle does not guarantee that the remaining composition can provide the required services. Application-owned npm projects, such as Electron's reserved Desktop profile, use `loadProfileDirectory` to load an already initialized directory without exposing it through CLI profile lookup.
Before a profile imports a plugin, DSH checks its `peerDependencies` on `@deepseek-ai/dsh` and `@deepseek-ai/dsh-*` against the single runtime version returned by `getDshRuntimeVersion()`. Every declared range must match; prereleases participate in range matching. Source-workspace `workspace:^`, `workspace:~`, and `workspace:*` refer to that same runtime. Missing DSH peers impose no constraint; invalid ranges are incompatible. These checks use peer declarations, not `engines.dsh`, and are not a sandbox against malicious package code.
-Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
+Admission happens only at composition boundaries DSH owns, and a denied row is denied in the launcher's own copy of the composition: no profile patch layer, dependency manifest, or bundle list changes. `prepareProfilePatches` composes over the launcher's empty profile root and runs while the root Include is mounted and on every profile recomposition, so a denied plugin never imports its module. `prepareProfileEntries` does the same for a preset's rows. A denied ordinary row becomes a detached `disabled: true` row; a native group stays mounted and its denied children do not load; a native Include that reaches a denied plugin is omitted whole, because its file is never rewritten. A row denied by policy keeps its configured `disabled` value in the profile, and every refusal is reported with the package, versions, and risk. A bundle is not a row, so `loadProfileDirectory` checks each bundle's own DSH peers when it loads the profile's bundle layers, at startup and on every recomposition; an incompatible bundle without an exemption is skipped like an unreadable one and listed in `skippedBundles`. These boundaries do not cover plugins another embedder mounts through its own `ctx.plugin` call. Two edits made directly to files during a session are judged only at the next recomposition or startup: a running plugin's own `package.json` peer declaration, and an entry-list file the Loader reads itself, such as the launcher's root config or a nested `cordis:include` file. `--dump-config` reports the configured composition, so a denied plugin row still appears there while a denied bundle contributes no rows, and `--dump-config-schema` imports each composed module to read its schema; run it only against a profile whose plugins you already trust.
Exact-version exemptions live in the profile's own `compatibility.json`, not in its `package.json`, so an exemption write never touches the dependency manifest, the bundle list, or Cordis patch files. It maps exact `package-name@version` keys to lists of exact DSH runtime versions; neither a plugin upgrade nor a DSH upgrade inherits permission. A missing file means no exemptions. A malformed one never stops the profile from starting: records the reader accepts still apply, each rejected record is reported on stderr beside the plugin denials, and the file is then treated as read-only, so a grant or revocation refuses and asks the user to repair it by hand instead of overwriting their content. [Plugin Manager](../plugin-manager/README.md#version-compatibility-and-exemptions) owns the grant, revocation, and risk confirmation each change requires.
@@ -194,7 +194,6 @@ Boot itself changes no request prefix. `addHarnessSourceSection` places its sour
-
These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog.
- **Runtime resolution depends on Node internals** — supported Node versions require the native builtin-access addon and executable compatibility coverage. Only built Harness-owned Workers receive the runtime resolution bootstrap; third-party Workers and custom `vm` linkers keep native resolution.
diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md
index 0bbdab2a5d..9cd18a99fa 100644
--- a/packages/boot/app-boot/README.zh.md
+++ b/packages/boot/app-boot/README.zh.md
@@ -47,11 +47,11 @@ const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHO
Profile 与组合包的声明类型从 [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.zh.md) 导入。App-boot 将 `DshPackageManifest` 适配为包身份可选的 `ProfileManifest`,因为本地 profile 无需发布版本。App-boot 负责 profile 加载、JSON 校验和解析后的运行时数据。
-profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile --from-default-profile ` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会输出诊断并跳过该组合包,不改变其选择状态。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
+profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/`,由可安装组合包和自身 `cordis.patch.yml` 组成。组合包的 `dsh.bundle.patch` 指定一个 patch 文件或一个有序的文件列表;`bundlePatchFiles` 校验该声明,`bundlePatchPaths` 把它解析为绝对路径;该层按此顺序拼接各文件的 patch 列表。YAML 组合决定是否启用 HMR。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立组合包,其他模板保留 base 加模式的组合包栈。`dsh --profile --from-default-profile ` 从一个随附模板,在新的非内置名称处创建自定义 profile;`dsh plugin` 则初始化以 base 为基础的 profile,并管理其中安装的组合包。组合包解析、manifest 读取或 patch 加载失败时会跳过该组合包,不改变其选择状态;加载结果在 `skippedBundles` 中列出每个被跳过的组合包,启动器每次启动时通过 `reportSkippedBundles` 输出一次。其余组合包保持原顺序;profile 和用户 patch 错误仍会导致启动失败。跳过组合包不保证剩余组合能够提供所需服务。由应用持有的 npm 项目(例如 Electron 保留的 Desktop profile)通过 `loadProfileDirectory` 加载已经初始化的目录,而不会将它暴露给 CLI profile 查找。
profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-ai/dsh` 和 `@deepseek-ai/dsh-*` 的依赖,与 `getDshRuntimeVersion()` 返回的唯一运行时版本比较。每个声明的版本范围都必须匹配;预发布版本参与范围匹配。源码工作区的 `workspace:^`、`workspace:~` 和 `workspace:*` 指向同一个运行时。未声明 DSH peer 时不施加版本约束;无效范围视为不兼容。这些检查使用 peer 声明,而不是 `engines.dsh`,也不是防范恶意包代码的沙箱。
-检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
+检查只发生在 DSH 自己持有的组合入口,改写的是启动器自己的那份组合:profile 的 patch 层、依赖清单与组合包列表都不会改变。`prepareProfilePatches` 在启动器的空 profile 根之上组合,并在挂载根 Include 时以及每次 profile 重新组合时执行,因此被拒绝的插件永远不会导入其模块;`prepareProfileEntries` 对 preset 行做同样的事。被拒绝的普通行会变成游离的 `disabled: true` 行;原生 group 保持挂载,其被拒绝的子行不会加载;若某个原生 Include 会到达被拒绝的插件,则整体省略该 Include,因为它的文件不会被改写。被策略拒绝的行在 profile 中保留其配置的 `disabled` 值,每次拒绝都会报告包名、版本与风险。组合包本身不是行,因此 `loadProfileDirectory` 在启动和每次重新组合加载 profile 的组合包层时,检查每个组合包自己声明的 DSH peer;没有豁免的不兼容组合包会像无法读取的组合包一样被跳过,并列入 `skippedBundles`。这些入口不覆盖其他嵌入方通过自己的 `ctx.plugin` 调用挂载的插件。会话中直接改文件的两类编辑只在下一次重新组合或启动时才被判定:正在运行的插件自己的 `package.json` peer 声明,以及 Loader 自己读取的入口清单文件(如启动器的根配置或嵌套的 `cordis:include` 文件)。`--dump-config` 报告的是配置出的组合,因此被拒绝的插件行仍会出现在其中,而被拒绝的组合包不提供任何行;`--dump-config-schema` 会导入每个组合模块以读取其 schema,请只对已经信任其插件的 profile 运行。
精确版本豁免保存在 profile 自己的 `compatibility.json` 中,而不是 `package.json`,因此写豁免不会触及依赖清单、组合包列表或 Cordis patch 文件。它把精确的 `package-name@version` 键映射到精确 DSH 运行时版本列表;插件升级和 DSH 升级都不继承授权。文件缺失表示没有豁免。文件损坏绝不会阻止 profile 启动:读取器接受的记录仍然生效,每条被拒绝的记录会与插件拒绝信息一起输出到 stderr,此后该文件被视为只读,因此授予或撤销会拒绝执行并要求用户手工修复,而不是覆盖用户的内容。[插件管理器](../plugin-manager/README.zh.md#version-compatibility-and-exemptions)负责每次变更所需的授权、撤销与风险确认。
@@ -194,7 +194,6 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
-
这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。
- **运行时解析依赖 Node 内部机制**——受支持的 Node 版本需要 native builtin access addon 和可执行兼容验证。只有构建后的 Harness 自有 Worker 接收 runtime resolution bootstrap;第三方 Worker 与自定义 `vm` linker 保持原生解析。
diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json
index c334fa93b3..b4113ee163 100644
--- a/packages/boot/app-boot/package.json
+++ b/packages/boot/app-boot/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-app-boot",
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/boot/app-boot/src/config-schema/index.ts b/packages/boot/app-boot/src/config-schema/index.ts
index e58b7dd15a..44bb553249 100644
--- a/packages/boot/app-boot/src/config-schema/index.ts
+++ b/packages/boot/app-boot/src/config-schema/index.ts
@@ -1,33 +1,30 @@
/** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import { composeEntries, createRuntimeResolution, readProfileManifest, skippedProfileBundles, type Profile } from '../profile.ts'
+import { composeEntries, createRuntimeResolution, type Profile } from '../profile.ts'
import { collectConfigSchemas } from './collect.ts'
import type { ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts'
export type { ConfigSchemaDump, NativeConfigSchema } from './types.ts'
/**
* Generate JSON Schema for a prepared profile's ordered patch layers without mounting plugins or evaluating expressions.
- * Reads the profile manifest; imports, Config getters, and lazy builders execute trusted code. Native validators and
+ * Imports, Config getters, and lazy builders execute trusted code. Native validators and
* transform callbacks are not executed. Calls must not overlap another profile-resolution interception; collection
* releases its interception on success or rejection, while Node retains imported modules. Supplied layers are not mutated.
* Profile preparation, layer selection, process streams, and exit policy belong to the caller.
- * @param binName - the diagnostic prefix on thrown manifest errors.
* @param profile - prepared on-disk profile whose directory anchors root module and include resolution.
* @param layers - already parsed patch lists in application order, including caller-selected home and argv overlays.
* @param installAnchor - package manifest anchoring the installation's runtime dependencies.
* @returns a JSON Schema document with declaration references, partial results, and diagnostics under `x-cordis`.
- * @throws when profile metadata, composition, or runtime resolution cannot be prepared.
+ * @throws when composition or runtime resolution cannot be prepared.
*/
export async function generateConfigSchema(
- binName: string,
profile: Profile,
layers: readonly PatchOptions[][],
installAnchor: string,
): Promise {
const diagnostics: ConfigSchemaDiagnostic[] = []
- const manifest = readProfileManifest(binName, profile.dir)
- for (const packageName of skippedProfileBundles(profile, manifest)) {
+ for (const { packageName } of profile.skippedBundles) {
diagnostics.push({
level: 'error',
message: `Selected profile bundle ${JSON.stringify(packageName)} could not be loaded; repair or remove its bundle selection.`,
diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts
index 00515b4ad2..188cb33460 100644
--- a/packages/boot/app-boot/src/index.ts
+++ b/packages/boot/app-boot/src/index.ts
@@ -68,11 +68,13 @@ export {
PROFILE_TEMPLATES,
PROFILES_DIR,
readProfileManifest,
+ reportSkippedBundles,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
type Profile,
type ProfileLayer,
+ type SkippedBundle,
type ProfileManifest,
type LinkedRoot,
type RuntimeResolutionOptions,
diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts
index 7211328e04..e17e125a75 100644
--- a/packages/boot/app-boot/src/profile.ts
+++ b/packages/boot/app-boot/src/profile.ts
@@ -98,6 +98,27 @@ export interface Profile {
patchPath: string
/** The profile's own patches; empty when the file is absent. */
patches: PatchOptions[]
+ /** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */
+ skippedBundles: SkippedBundle[]
+}
+
+/** A selected bundle the profile could not load, or whose own DSH peers the profile does not exempt. */
+export interface SkippedBundle {
+ /** The bundle's package name from `dsh.profile.bundles`. */
+ packageName: string
+ /** The resolution, manifest, compatibility, or patch-loading failure. */
+ reason: string
+}
+
+/**
+ * Print each skipped bundle once; loading never prints, so launchers call this once per start.
+ * @param binName - the diagnostic prefix.
+ * @param profile - the loaded profile.
+ */
+export function reportSkippedBundles(binName: string, profile: Pick): void {
+ for (const { packageName, reason } of profile.skippedBundles) {
+ process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${reason}\n`)
+ }
}
/** One package the runtime resolution supplies at the interception layer. */
@@ -193,6 +214,7 @@ export const OPTIONAL_BUNDLES: readonly string[] = [
'@deepseek-ai/dsh-experimental-agent-team-profile',
'@deepseek-ai/dsh-experimental-voice-input-bundle',
'@deepseek-ai/dsh-experimental-auto-review',
+ '@deepseek-ai/dsh-experimental-schedule-bundle',
]
const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer:
@@ -413,7 +435,7 @@ export async function createRuntimeResolution(
const profilesDir = join(home, PROFILES_DIR)
const manifest = readOptionalProfileManifest(profile)
const { packageNames, packageDirs, declarers, versions } = collectInstallationScopePackages(
- installAnchor, skippedProfileBundles(profile, manifest),
+ installAnchor, new Set(profile?.skippedBundles.map(skipped => skipped.packageName)),
)
const profileDeclarers = new Map()
const profileVersions = new Map()
@@ -452,18 +474,6 @@ function readOptionalProfileManifest(profile: Profile | undefined): ProfileManif
}
}
-/**
- * Identify selected bundles that did not produce a loaded layer.
- * @param profile - loaded profile, when present.
- * @param manifest - its parsed manifest, when present.
- * @returns selected bundle names missing from the loaded layers, for resolution and diagnostics.
- */
-export function skippedProfileBundles(profile: Profile | undefined, manifest: ProfileManifest | undefined): ReadonlySet {
- const selected = manifest?.dsh?.profile?.bundles ?? []
- const loaded = new Set(profile?.layers.map(layer => layer.packageName))
- return new Set(selected.filter(name => !loaded.has(name)))
-}
-
/** Return installed direct dependencies that Node resolves before profile fallback. */
function installedProfilePackageNames(profile: Profile, manifest: ProfileManifest | undefined): string[] {
if (manifest === undefined) return []
@@ -634,8 +644,8 @@ export function resolveBundleDir(
* Load an already initialized profile directory without resolving it through
* the shared Harness home. This is used by application-owned profiles whose
* package project and lifecycle belong to that application.
- * Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are reported
- * on stderr and skipped without changing the manifest.
+ * Unreadable bundles, and bundles whose own dsh peers the profile does not exempt, are skipped
+ * without changing the manifest and listed in `skippedBundles`; nothing is printed.
* @param binName - the diagnostic prefix on thrown errors.
* @param dir - absolute profile package directory.
* @param installAnchor - absolute path of the owning dsh app's package.json.
@@ -651,6 +661,7 @@ export function loadProfileDirectory(
const manifest = readProfileManifest(binName, dir)
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers: ProfileLayer[] = []
+ const skippedBundles: SkippedBundle[] = []
const exemptions = bundles.length === 0 ? {} : readProfileVersionExemptions(dir)
for (const packageName of bundles) {
try {
@@ -667,20 +678,20 @@ export function loadProfileDirectory(
const patches = patchPaths.flatMap(patchPath => loadOverlayPatches(binName, patchPath))
layers.push({ packageName, packageDir, patchPaths, patches })
} catch (error) {
- process.stderr.write(`${binName}: skipping profile bundle ${JSON.stringify(packageName)}: ${String(error)}\n`)
+ skippedBundles.push({ packageName, reason: String(error) })
}
}
const patchPath = join(dir, PROFILE_PATCH_FILENAME)
const patches = options.userLayer !== false && existsSync(patchPath)
? loadOverlayPatches(binName, patchPath)
: []
- return { name: basename(dir), dir, layers, patchPath, patches }
+ return { name: basename(dir), dir, layers, patchPath, patches, skippedBundles }
}
/**
* Load a profile: resolve every `dsh.profile.bundles` entry to its patch
* layer and parse the profile's own patch file. Unreadable or incompatible bundles
- * are reported on stderr and skipped; profile manifest and user patch errors still throw.
+ * are skipped and listed in `skippedBundles`; profile manifest and user patch errors still throw.
* @param binName - the diagnostic prefix on thrown errors.
* @param name - the profile name.
* @param installAnchor - absolute path of the dsh app's package.json (first resolution anchor).
diff --git a/packages/boot/app-boot/tests/config-schema.spec.ts b/packages/boot/app-boot/tests/config-schema.spec.ts
index 16574e4443..ed7ce470dc 100644
--- a/packages/boot/app-boot/tests/config-schema.spec.ts
+++ b/packages/boot/app-boot/tests/config-schema.spec.ts
@@ -36,7 +36,7 @@ const importModule = vi.fn(async (name: string): Promise => {
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'dsh-config-schema-'))
- profile = { name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [], layers: [] }
+ profile = { skippedBundles: [], name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), patches: [], layers: [] }
resolution = { profilesDir: dir, profileDir: dir, localPackageNames: [], entries: [], linkedRoots: [] }
const loader = ModuleLoader.fromInternal()
if (loader === undefined) throw new Error('test requires supported Node internals')
@@ -70,7 +70,7 @@ function validates(dump: ConfigSchemaDump, value: unknown, definition = 'entryLi
describe('generateConfigSchema', () => {
it('owns ordered composition, skipped-bundle diagnostics, and runtime resolution without mutating layers', async () => {
profile.layers = [{ packageName: 'loaded', packageDir: dir, patchPaths: [join(dir, 'bundle.yml')], patches: [] }]
- writeFileSync(join(dir, 'package.json'), JSON.stringify({ dsh: { profile: { bundles: ['loaded', 'missing'] } } }))
+ profile.skippedBundles = [{ packageName: 'missing', reason: 'Error: cannot resolve profile bundle "missing"' }]
modules.set('server', { Config: Schema.string() })
const layers: PatchOptions[][] = [
[{ insert: [{ ...row('cordis:group', []), id: 'group', group: true }] }],
@@ -80,7 +80,7 @@ describe('generateConfigSchema', () => {
const original = structuredClone(layers)
const installAnchor = join(dir, 'installation.json')
const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution').mockResolvedValue(resolution)
- const result = await generateConfigSchema('dsh', profile, layers, installAnchor)
+ const result = await generateConfigSchema(profile, layers, installAnchor)
expect(resolve).toHaveBeenCalledExactlyOnceWith({ installAnchor, profile })
expect(result['x-cordis'].entries.map(entry => [entry.path, entry.name])).toEqual([
['/0', 'cordis:group'], ['/0/config/0', 'server'],
@@ -94,17 +94,9 @@ describe('generateConfigSchema', () => {
expect(dispose).toHaveBeenCalledOnce()
})
- it('requires prepared profile metadata before creating an interception', async () => {
- const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution').mockResolvedValue(resolution)
- await expect(generateConfigSchema('dsh', profile, [], join(dir, 'installation.json'))).rejects.toThrow()
- expect(resolve).not.toHaveBeenCalled()
- expect(installRuntimeInterception).not.toHaveBeenCalled()
- })
-
it('propagates resolution setup failure without starting collection', async () => {
- writeFileSync(join(dir, 'package.json'), '{}')
vi.spyOn(profileOperations, 'createRuntimeResolution').mockRejectedValue(new Error('resolution setup failed'))
- await expect(generateConfigSchema('dsh', profile, [], join(dir, 'installation.json'))).rejects.toThrow('resolution setup failed')
+ await expect(generateConfigSchema(profile, [], join(dir, 'installation.json'))).rejects.toThrow('resolution setup failed')
expect(installRuntimeInterception).not.toHaveBeenCalled()
})
})
diff --git a/packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts b/packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts
index 624eb7ae98..fe3fafc63c 100644
--- a/packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts
+++ b/packages/boot/app-boot/tests/linked-resolution-matrix.spec.ts
@@ -470,7 +470,7 @@ class MatrixFixture {
}
async observe(mode: 'current' | 'native' = 'current'): Promise {
- const profile: Profile = { name: 'web', dir: this.profileDir, layers: [],
+ const profile: Profile = { skippedBundles: [], name: 'web', dir: this.profileDir, layers: [],
patchPath: join(this.profileDir, 'cordis.patch.yml'), patches: [] }
const resolution = mode === 'current'
? await createRuntimeResolution({ installAnchor: join(this.installDir, 'package.json'), profile, home: this.home }) : undefined
diff --git a/packages/boot/app-boot/tests/profile-resolution.spec.ts b/packages/boot/app-boot/tests/profile-resolution.spec.ts
index 517022c1ac..a36347661d 100644
--- a/packages/boot/app-boot/tests/profile-resolution.spec.ts
+++ b/packages/boot/app-boot/tests/profile-resolution.spec.ts
@@ -157,7 +157,7 @@ function fixture(name = '@deepseek-ai/dsh-core'): {
root,
installAnchor,
installed,
- profile: {
+ profile: { skippedBundles: [],
name: 'web',
dir: profileDir,
layers: [],
diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts
index 57b05d9aa5..2c0aebb8e5 100644
--- a/packages/boot/app-boot/tests/profile.spec.ts
+++ b/packages/boot/app-boot/tests/profile.spec.ts
@@ -25,6 +25,7 @@ import {
readProfileManifest,
readProfilePatches,
removeLinkProjections,
+ reportSkippedBundles,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
@@ -80,7 +81,7 @@ function stageProfile(home: string, name: string, bundleAnchor: string): Profile
const dir = resolveProfileDir(name, home)
mkdirSync(dir, { recursive: true })
const packageName = (JSON.parse(readFileSync(bundleAnchor, 'utf8')) as { name: string }).name
- return {
+ return { skippedBundles: [],
name,
dir,
layers: [{
@@ -319,9 +320,6 @@ describe('loadProfile', () => {
const home = tmp()
const dir = resolveProfileDir('demo', home)
initProfile(dir, ['multi', 'broken'])
- const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
- onTestFinished(() => { warn.mockRestore() })
-
const profile = loadProfile('t', 'demo', anchor, home)
expect(profile.layers.map(layer => ({ ...layer, patches: layer.patches.length }))).toEqual([{
packageName: 'multi',
@@ -333,9 +331,9 @@ describe('loadProfile', () => {
{ id: 'a', name: pathToFileURL(join(bundleDir, 'local.js')).href, config: { v: 2 } },
{ id: 'b', name: pathToFileURL(join(bundleDir, 'layers', 'local.js')).href },
])
- expect(warn).toHaveBeenCalledWith(expect.stringContaining(
- 'skipping profile bundle "broken": Error: dsh.bundle.patch must be a file path or a list of file paths',
- ))
+ expect(profile.skippedBundles).toEqual([
+ { packageName: 'broken', reason: 'Error: dsh.bundle.patch must be a file path or a list of file paths' },
+ ])
})
it('auto-initializes only shipped templates and fails loud otherwise', () => {
@@ -412,10 +410,14 @@ describe('loadProfile', () => {
onTestFinished(() => { warn.mockRestore() })
const profile = loadProfile('t', 'demo', anchor, home)
+ // Loading only records the skip; the launcher reports it once per start.
+ expect(warn).not.toHaveBeenCalled()
expect(profile.layers.map(layer => layer.packageName)).toEqual(['before', 'after'])
expect(composeEntries(profile.layers.map(layer => layer.patches)))
.toEqual([{ id: 'a', name: 'pkg-a', config: { value: 'after' } }])
- expect(warn).toHaveBeenCalledWith(expect.stringContaining('skipping profile bundle "broken":'))
+ expect(profile.skippedBundles.map(skipped => skipped.packageName)).toEqual(['broken'])
+ reportSkippedBundles('t', profile)
+ expect(warn.mock.calls).toEqual([[`t: skipping profile bundle "broken": ${profile.skippedBundles[0]?.reason}\n`]])
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(saved)
const resolution = await createRuntimeResolution({ installAnchor: anchor, profile, home })
const unavailable = failure === 'missing package' || failure === 'invalid manifest'
@@ -440,13 +442,11 @@ describe('loadProfile', () => {
}))
const dir = resolveProfileDir('demo', tmp())
initProfile(dir, ['guarded', 'kept'])
- const warn = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
- onTestFinished(() => { warn.mockRestore() })
-
- expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['kept'])
- expect(warn).toHaveBeenCalledWith(expect.stringContaining(
- `skipping profile bundle "guarded": Error: Plugin guarded@0.0.0 is incompatible with dsh ${getDshRuntimeVersion()}`,
- ))
+ const denied = loadProfileDirectory('dsh', dir, anchor)
+ expect(denied.layers.map(layer => layer.packageName)).toEqual(['kept'])
+ expect(denied.skippedBundles).toEqual([{ packageName: 'guarded', reason: expect.stringContaining(
+ `Error: Plugin guarded@0.0.0 is incompatible with dsh ${getDshRuntimeVersion()}`,
+ ) as string }])
writeFileSync(join(dir, PROFILE_COMPATIBILITY_FILENAME), JSON.stringify({ 'guarded@0.0.0': [getDshRuntimeVersion()] }))
expect(loadProfileDirectory('dsh', dir, anchor).layers.map(layer => layer.packageName)).toEqual(['guarded', 'kept'])
})
@@ -554,7 +554,7 @@ describe('createRuntimeResolution', () => {
mkdirSync(profileModules, { recursive: true })
const bundleLink = join(profileModules, 'selected-bundle')
symlinkSync(realBundle, bundleLink, 'junction')
- const profile: Profile = {
+ const profile: Profile = { skippedBundles: [],
name: 'symlinked',
dir,
layers: [{
@@ -604,7 +604,7 @@ describe('createRuntimeResolution', () => {
}))
writeFileSync(join(explicitOnly, 'package.json'), JSON.stringify({ name: 'explicit-only' }))
const dir = resolveProfileDir('explicit-roots', home)
- const profile: Profile = {
+ const profile: Profile = { skippedBundles: [],
name: 'explicit-roots',
dir,
layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({
diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json
index 68fd60efaf..cde209a30e 100644
--- a/packages/boot/cmdline/package.json
+++ b/packages/boot/cmdline/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-cmdline",
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json
index 0c15d83cda..177b03df26 100644
--- a/packages/boot/config-editor/package.json
+++ b/packages/boot/config-editor/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-config-editor",
"description": "Persist plugin configuration through profile patches and Loader reconciliation",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json
index 5e8323201a..74a1638898 100644
--- a/packages/boot/hmr/package.json
+++ b/packages/boot/hmr/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-hmr",
"description": "Coordinated module and profile configuration hot reload",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml
index bd34ee9e98..034b9efee0 100644
--- a/packages/boot/plugin-manager/README.i18n.yaml
+++ b/packages/boot/plugin-manager/README.i18n.yaml
@@ -24,8 +24,8 @@
en: d5643a06f300c30a
zh: 9348b6a7676a2849
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
- en: 0cbf4501defc4fe0
- zh: 8382481b15f97181
+ en: 1c737c375aeac163
+ zh: 009d7f139a749c79
/deepseek-ai-dsh-plugin-manager/further-exploration:
en: c6538243d8702a0d
zh: 04a82d923353c75f
diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md
index c047c71476..ca470bcd3a 100644
--- a/packages/boot/plugin-manager/README.md
+++ b/packages/boot/plugin-manager/README.md
@@ -89,7 +89,7 @@ The CLI exposes `dsh plugin --profile version-exemptions`, `allow-vers
Implementation internals — click to expand
-The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Dependency-only changes do not trigger configuration reloads.
+The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection.
diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md
index 1f99f67498..24f9351aa6 100644
--- a/packages/boot/plugin-manager/README.zh.md
+++ b/packages/boot/plugin-manager/README.zh.md
@@ -89,7 +89,7 @@ CLI 提供 `dsh plugin --profile version-exemptions`、`allow-version
实现细节——点击展开
-服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。仅依赖字段变化不会触发配置重载。
+服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json
index 37b99af2d8..e6057f3d6a 100644
--- a/packages/boot/plugin-manager/package.json
+++ b/packages/boot/plugin-manager/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-plugin-manager",
"description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts
index 8a5772c49e..2df35b7efc 100644
--- a/packages/boot/plugin-manager/src/operations.ts
+++ b/packages/boot/plugin-manager/src/operations.ts
@@ -13,7 +13,7 @@ import {
} from '@deepseek-ai/dsh-app-boot'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { parseInstallSpec } from './install-spec.ts'
-import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts'
+import { awaitTreeGone, leadsOwnGroup, treeAlive, type RunTree } from './run-tree.ts'
import { incompatiblePlugin } from './failure.ts'
import type { IncompatiblePlugin, PackageResult, Registry } from './types.ts'
export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
@@ -195,6 +195,50 @@ function optionalFile(path: string): string | undefined {
}
}
+/** Where an operation records the pnpm run it started, for a successor when the operation's own process ends first. */
+function runRecordPath(dir: string): string {
+ return join(dir, '.plugin-manager', 'run.json')
+}
+
+/** Record a started run; a successor that takes over the profile lock from an exited process waits for it. */
+async function recordRun(dir: string, tree: RunTree): Promise {
+ if (tree.pid === undefined) return
+ await writeFileAtomic(runRecordPath(dir), `${JSON.stringify(tree)}\n`, { mode: 0o600, dirMode: 0o700 })
+}
+
+/**
+ * Why this operation must not run: a record left by an operation whose process
+ * ended mid-run names a run that is still writing the profile. The profile
+ * lock is taken over once its holder exits, but its pnpm tree can outlive it.
+ * A recorded run that stopped, within a bounded wait, has its record removed.
+ * @param dir Profile directory.
+ * @returns The diagnostic, or undefined when no recorded run is active.
+ */
+async function activeRecordedRun(dir: string): Promise {
+ const path = runRecordPath(dir)
+ const text = optionalFile(path)
+ if (text === undefined) return undefined
+ let tree: RunTree | undefined
+ try {
+ const value: unknown = JSON.parse(text)
+ const { pid, grouped } = (typeof value === 'object' && value !== null ? value : {}) as { pid?: unknown; grouped?: unknown }
+ if (Number.isSafeInteger(pid) && (pid as number) > 0 && typeof grouped === 'boolean') tree = { pid: pid as number, grouped }
+ } catch (error) {
+ // Records are replaced atomically, so an unparsable one was written by something else and is reported below.
+ void error
+ }
+ if (tree === undefined) {
+ return `dsh: ${path} does not name a package run; delete it once no earlier package operation is still running in this profile\n`
+ }
+ await awaitTreeGone(tree)
+ if (treeAlive(tree)) {
+ return `dsh: process ${String(tree.pid)}, started by an earlier package operation whose own process ended, is still running in this profile; `
+ + `wait for it or stop it, then retry. If process ${String(tree.pid)} is not that package run, delete ${path}.\n`
+ }
+ await rm(path, { force: true })
+ return undefined
+}
+
/** pnpm can install plugins through any direct-dependency field. */
function directDependencies(manifest: ProfileManifest): Record {
const extra = manifest as ProfileManifest & { devDependencies?: Record; optionalDependencies?: Record }
@@ -241,15 +285,24 @@ export async function runProfilePnpm(
context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
): Promise {
const dir = context.dir ?? resolveProfileDir(context.profile, context.home)
- const before = readProfileManifest('dsh', dir)
- const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }))
- const beforeDependencies = directDependencies(before)
- const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]))
+ // Before any profile file is read: a recorded run that is still active may be rewriting them.
+ const active = await activeRecordedRun(dir)
const logRoot = join(dir, '.plugin-manager', 'logs')
await mkdir(logRoot, { recursive: true, mode: 0o700 })
const logDir = await mkdtemp(join(logRoot, 'operation-'))
const logPath = join(logDir, 'pnpm.log')
const log = await open(logPath, 'wx', 0o600)
+ if (active !== undefined) {
+ await log.write(active)
+ await log.close()
+ options.onOutput?.(active, 'stderr')
+ const bytes = Buffer.from(active)
+ return { exitCode: 1, output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString('utf8'), truncated: bytes.length > options.outputBytes, logPath }
+ }
+ const before = readProfileManifest('dsh', dir)
+ const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }))
+ const beforeDependencies = directDependencies(before)
+ const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]))
let output = Buffer.alloc(0)
let truncated = false
const append = (bytes: Buffer): void => {
@@ -309,6 +362,8 @@ export async function runProfilePnpm(
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
})
+ // Listened for before the record is written, so an exit during that write is not missed.
+ const exited = once(child.nodeChildProcess, 'exit').catch(() => undefined)
let writes = Promise.resolve()
/** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
const control = { settled: false, stalled: false }
@@ -359,11 +414,12 @@ export async function runProfilePnpm(
if (collectors.length > 0) armIdle()
let exitCode: number
try {
+ await recordRun(dir, { pid: child.pid, grouped })
// execa resolves its promise only once the piped stdio has ended, so the
// process's own exit — the run's completion — is read from the raw child. A
// spawn failure settles without one.
const settled = Promise.allSettled([child])
- await Promise.race([once(child.nodeChildProcess, 'exit').catch(() => undefined), settled])
+ await Promise.race([exited, settled])
control.settled = true
clearTimeout(idleTimer)
// A stalled run stops its whole tree first, so the caller's rollback and lock
@@ -448,10 +504,12 @@ export async function runProfilePnpm(
await restore()
const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined)
const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false']
- const repaired = await execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
+ const repairing = execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
})
+ await recordRun(dir, { pid: repairing.pid, grouped: false })
+ const repaired = await repairing
exitCode = 1
const restoration = repaired.exitCode === 0
? 'restored package.json, pnpm-lock.yaml, and node_modules'
@@ -467,6 +525,8 @@ export async function runProfilePnpm(
} finally {
control.settled = true
clearTimeout(idleTimer)
+ // This process saw the run end, so no successor has to wait for it.
+ await rm(runRecordPath(dir), { force: true })
await log.close()
}
return {
diff --git a/packages/boot/plugin-manager/src/run-tree.ts b/packages/boot/plugin-manager/src/run-tree.ts
index bce2f4d872..f02fe32817 100644
--- a/packages/boot/plugin-manager/src/run-tree.ts
+++ b/packages/boot/plugin-manager/src/run-tree.ts
@@ -43,6 +43,24 @@ function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): num
return platform !== 'win32' && grouped ? -pid : pid
}
+/**
+ * Whether any member of a run's tree is still alive.
+ * @param tree The run's process id and whether it leads its own group.
+ * @param internals Injectable process operations.
+ * @returns True while the probe finds the process or a group member.
+ */
+export function treeAlive(tree: RunTree, internals: RunTreeInternals = {}): boolean {
+ const pid = tree.pid
+ if (pid === undefined) return false
+ const platform = internals.platform ?? process.platform
+ const alive = internals.alive ?? ((target: number) => { process.kill(target, 0); return true })
+ try {
+ return alive(targetOf(pid, tree.grouped, platform))
+ } catch {
+ return false
+ }
+}
+
/**
* Wait until a terminated run's tree is gone, so the caller restores and
* unlocks the profile only after the scripts it started stopped writing.
@@ -51,19 +69,8 @@ function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): num
* @returns Fulfillment once no member remains, or the wait bound elapsed.
*/
export async function awaitTreeGone(tree: RunTree, internals: RunTreeInternals = {}): Promise {
- const pid = tree.pid
- if (pid === undefined) return
- const platform = internals.platform ?? process.platform
- const alive = internals.alive ?? ((target: number) => { process.kill(target, 0); return true })
- const remaining = (): boolean => {
- try {
- return alive(targetOf(pid, tree.grouped, platform))
- } catch {
- return false
- }
- }
const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS)
- while (remaining()) {
+ while (treeAlive(tree, internals)) {
if (Date.now() >= deadline) return
await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS))
}
diff --git a/packages/boot/plugin-manager/tests/operations-process.spec.ts b/packages/boot/plugin-manager/tests/operations-process.spec.ts
index 1b228cd4c7..e040b23558 100644
--- a/packages/boot/plugin-manager/tests/operations-process.spec.ts
+++ b/packages/boot/plugin-manager/tests/operations-process.spec.ts
@@ -1,5 +1,6 @@
/** The bounded drain and the tree stop are measured against real child processes. */
-import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { spawn } from 'node:child_process'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
@@ -139,3 +140,23 @@ describe.skipIf(process.platform === 'win32')('a run with descendants', () => {
expect(existsSync(lateFile)).toBe(false)
})
})
+
+it('waits for a run recorded by an exited operation before starting its own', async () => {
+ const { context, lateFile, options } = fixture(
+ // The run reports whether the recorded run had finished writing when it started.
+ 'console.log(require("node:fs").existsSync(process.env.DSH_LATE_FILE) ? "started after" : "started before")', '',
+ )
+ const orphan = spawn(process.execPath, ['-e', `setTimeout(() => { require('node:fs').writeFileSync(${JSON.stringify(lateFile)}, 'late') }, 500)`], {
+ stdio: 'ignore',
+ })
+ onTestFinished(async () => {
+ try { orphan.kill('SIGKILL') } catch { /* the recorded run already exited */ }
+ await awaitGone(orphan.pid as number)
+ })
+ const dir = join(context.home, 'profiles', 'test')
+ mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
+ writeFileSync(join(dir, '.plugin-manager', 'run.json'), JSON.stringify({ pid: orphan.pid, grouped: false }))
+ const outcome = await runProfilePnpm(context, ['list'], options)
+ expect(outcome).toMatchObject({ exitCode: 0, output: 'started after\n' })
+ expect(existsSync(join(dir, '.plugin-manager', 'run.json'))).toBe(false)
+})
diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts
index 763da12ea5..41009ec9ed 100644
--- a/packages/boot/plugin-manager/tests/operations.spec.ts
+++ b/packages/boot/plugin-manager/tests/operations.spec.ts
@@ -44,6 +44,18 @@ interface FakePnpm {
const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => Promise & FakeChild>() }))
vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) }))
+/** Whether the bounded wait for a recorded run returns at once; the wait itself is measured in run-tree.spec.ts. */
+const treeWait = vi.hoisted(() => ({ skip: false }))
+vi.mock('../src/run-tree.ts', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ awaitTreeGone: async (...args: Parameters) => {
+ if (!treeWait.skip) await actual.awaitTreeGone(...args)
+ },
+ }
+})
+
function fixture() {
const home = mkdtempSync(join(tmpdir(), 'manager-pnpm-'))
const dir = join(home, 'profiles', 'test')
@@ -73,7 +85,7 @@ function fixture() {
if (argv.includes('--frozen-lockfile') || argv.includes('--config.lockfile=false')) return result(pnpm.repairExit, '')
return result(pnpm.exitCode, pnpm.output, () => { pnpm.mutate(runDir) })
})
- onTestFinished(() => { command.run.mockReset(); rmSync(home, { recursive: true, force: true }) })
+ onTestFinished(() => { command.run.mockReset(); treeWait.skip = false; rmSync(home, { recursive: true, force: true }) })
return { home, dir, context: { home, profile: 'test', installAnchor, cwd: home }, pnpm }
}
@@ -581,6 +593,104 @@ it('initializes missing profiles under the same lock and reports initialization'
expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2)
})
+/** The record an operation leaves for the pnpm run it started. */
+function runRecord(dir: string): string {
+ return join(dir, '.plugin-manager', 'run.json')
+}
+
+/** A run that exits after `ms`, reading the profile's run record while it is still running. */
+function observingChild(dir: string, pid: number, ms = 100) {
+ const stdout = new PassThrough()
+ const stderr = new PassThrough()
+ const raw = new EventEmitter()
+ const observed: { record: string | undefined } = { record: undefined }
+ const done = new Promise((resolve) => {
+ setTimeout(() => {
+ observed.record = existsSync(runRecord(dir)) ? readFileSync(runRecord(dir), 'utf8') : undefined
+ stdout.end()
+ stderr.end()
+ raw.emit('exit', 0, null)
+ resolve({ exitCode: 0, failed: false, stdout: '', stderr: '' })
+ }, ms)
+ })
+ return { child: Object.assign(done, { stdout, stderr, nodeChildProcess: raw, pid }), observed }
+}
+
+it.each([8192, 30])('refuses to run while a run recorded by an exited operation is still active (output bound %i)', async (outputBytes) => {
+ const { dir, context } = fixture()
+ treeWait.skip = true
+ mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
+ const record = JSON.stringify({ pid: process.pid, grouped: false })
+ writeFileSync(runRecord(dir), record)
+ const messages: string[] = []
+ const outcome = await runProfilePnpm(context, ['add', './extra'], {
+ execution: 'service', outputBytes, onOutput: (text) => { messages.push(text) },
+ })
+ const diagnostic = readFileSync(outcome.logPath, 'utf8')
+ expect(diagnostic).toContain(`process ${String(process.pid)}, started by an earlier package operation whose own process ended, is still running`)
+ expect(diagnostic).toContain(`delete ${runRecord(dir)}`)
+ expect(messages.join('')).toBe(diagnostic)
+ expect(outcome).toMatchObject({ exitCode: 1, truncated: diagnostic.length > outputBytes })
+ expect(outcome.output).toBe(diagnostic.slice(-outputBytes))
+ expect(command.run).not.toHaveBeenCalled()
+ expect(readFileSync(runRecord(dir), 'utf8')).toBe(record)
+})
+
+it('removes the record of a recorded run that stopped and runs', async () => {
+ const { dir, context } = fixture()
+ const exited = 2_000_000_000
+ const kill = process.kill.bind(process)
+ vi.spyOn(process, 'kill').mockImplementation((target, signal) => {
+ if (target === exited) throw Object.assign(new Error('ESRCH: injected'), { code: 'ESRCH' })
+ return kill(target, signal)
+ })
+ onTestFinished(() => { vi.restoreAllMocks() })
+ mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
+ writeFileSync(runRecord(dir), JSON.stringify({ pid: exited, grouped: false }))
+ const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 8192 })
+ expect(outcome.exitCode).toBe(0)
+ expect(command.run).toHaveBeenCalledOnce()
+ expect(existsSync(runRecord(dir))).toBe(false)
+})
+
+it.each(['not json', 'null', '{"pid":0,"grouped":false}', '{"pid":12,"grouped":"no"}'])(
+ 'refuses to run beside a run record that names no run: %s', async (record) => {
+ const { dir, context } = fixture()
+ mkdirSync(join(dir, '.plugin-manager'), { recursive: true })
+ writeFileSync(runRecord(dir), record)
+ const outcome = await runProfilePnpm(context, ['add', './extra'], { execution: 'service', outputBytes: 8192 })
+ expect(outcome).toMatchObject({ exitCode: 1 })
+ expect(outcome.output).toBe(`dsh: ${runRecord(dir)} does not name a package run; delete it once no earlier package operation is still running in this profile\n`)
+ expect(command.run).not.toHaveBeenCalled()
+ },
+)
+
+it.each(['cli', 'service'] as const)('records a %s run while it runs and removes the record once it ends', async (execution) => {
+ const { dir, context } = fixture()
+ const { child, observed } = observingChild(dir, 4242)
+ command.run.mockImplementationOnce(() => child)
+ const outcome = await runProfilePnpm(context, ['list'], { execution, outputBytes: 8192 })
+ expect(outcome.exitCode).toBe(0)
+ expect(JSON.parse(observed.record ?? 'null')).toEqual({
+ pid: 4242, grouped: execution === 'service' && process.platform !== 'win32',
+ })
+ expect(existsSync(runRecord(dir))).toBe(false)
+})
+
+it('records the install that repairs a refused installation', async () => {
+ const { dir, context, pnpm } = fixture()
+ pnpm.mutate = (target) => { installGuarded(target, 'incompatible') }
+ const base = command.run.getMockImplementation() as (...call: unknown[]) => Promise & FakeChild
+ const repair = observingChild(dir, 4343)
+ command.run.mockImplementation((...call: unknown[]) => {
+ return (call[1] as readonly string[]).includes('--config.lockfile=false') ? repair.child : base(...call)
+ })
+ const outcome = await runProfilePnpm(context, ['add', 'incompatible'], { execution: 'service', outputBytes: 8192 })
+ expect(outcome.exitCode).toBe(1)
+ expect(JSON.parse(repair.observed.record ?? 'null')).toEqual({ pid: 4343, grouped: false })
+ expect(existsSync(runRecord(dir))).toBe(false)
+})
+
it('terminates a run that stopped printing and reports the silence bound', async () => {
const { context } = fixture()
command.run.mockImplementationOnce(() => silentChild('installing\n'))
diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json
index 3e4014122f..21883bff77 100644
--- a/packages/browser-use/browser-use/package.json
+++ b/packages/browser-use/browser-use/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-browser-use",
"description": "Exclusive named browser-use provider registration",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json
index adfac2438f..08246cecad 100644
--- a/packages/bundle/acp-app/package.json
+++ b/packages/bundle/acp-app/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-acp-app",
"description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml
index db6701a9b6..9c35ebf5c2 100644
--- a/packages/bundle/base/cordis.patch.yml
+++ b/packages/bundle/base/cordis.patch.yml
@@ -185,6 +185,9 @@
writeEveryEvents: 200
writeIntervalMs: 5000
+ - id: otel
+ name: '@deepseek-ai/dsh-otel'
+
# OTel releases a Session-log prefix only after explicit user feedback,
# regardless of model provider. Ordinary activity never triggers capture.
# DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty
@@ -192,22 +195,20 @@
# process out (the launchers patch the row disabled; config cannot disable
# a row). Exports carry the harness home's anonymous user id ($DSH_HOME/.anonymous-user-id,
# random UUID; delete the file to reset the identity) as the Resource's
- # user.id. The exporter/processor values normally bound the shutdown drain
- # to ~1s against an unreachable collector: exporter.timeoutMillis is both
- # the per-attempt socket timeout and the retry deadline (1s effectively
- # disables the SDK's 5-try backoff), while maxExportBatchSize == maxQueueSize
- # (both explicit) makes the drain a single batch. The SDK awaits
- # exporter.forceFlush() outside exportTimeoutMillis, so the backend's 3s
- # shutdownTimeoutMillis is the load-bearing outer bound when a transport
- # promise never settles. Every CLI exit path drains it by disposing the root
- # on SIGINT/SIGTERM.
+ # user.id. Byte-bounded batching may turn an authorized prefix into
+ # several HTTP requests. Requests run serially; each has its own 1s
+ # transport timeout and 1.5s watchdog. A watchdog does not release the
+ # active request slot. The outer 3s shutdown deadline stops queued sends;
+ # large prefixes or an unsettled transport may leave records unsent.
+ # Every CLI exit path drains by disposing the root on SIGINT/SIGTERM.
- id: session-telemetry-otel
name: '@deepseek-ai/dsh-session-telemetry-otel'
config:
mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'
shutdownTimeoutMillis: 3000
+ maxRequestBytes: 4000000
exporter:
- url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
+ url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://dsh-otel-collector.deepseeksvc.com/v1/logs'
compression: gzip
timeoutMillis: 1000
processor:
diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json
index a5aafcb656..51b37cf282 100644
--- a/packages/bundle/base/package.json
+++ b/packages/bundle/base/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-base",
"description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
@@ -125,7 +125,8 @@
"@deepseek-ai/dsh-authorization": "workspace:*",
"@deepseek-ai/dsh-deepseek-account-platform": "workspace:*",
"@deepseek-ai/dsh-config-editor": "workspace:*",
- "@deepseek-ai/dsh-settings": "workspace:*"
+ "@deepseek-ai/dsh-settings": "workspace:*",
+ "@deepseek-ai/dsh-otel": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json
index 2ef2d3abce..2ead4bf6f8 100644
--- a/packages/bundle/headless/package.json
+++ b/packages/bundle/headless/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-headless",
"description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json
index 2ae7c39679..9e564b7637 100644
--- a/packages/bundle/sdk-app/package.json
+++ b/packages/bundle/sdk-app/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sdk-app",
"description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json
index 0d59ef32d2..e5bb9b6e2c 100644
--- a/packages/bundle/sdk-minimal/package.json
+++ b/packages/bundle/sdk-minimal/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-sdk-minimal",
"description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml
index a894043050..161739e50d 100644
--- a/packages/bundle/web-app/README.i18n.yaml
+++ b/packages/bundle/web-app/README.i18n.yaml
@@ -6,8 +6,8 @@
en: af2e4e5296596e8f
zh: 70c0b5ac93209d00
/deepseek-ai-dsh-web-app:
- en: f4da95d498221fcd
- zh: 6cc704e7aa6f376d
+ en: 8523b283e794cf59
+ zh: 7764704f7e98f19b
/deepseek-ai-dsh-web-app/summary:
en: 0ebb6c3ca8c0a84e
zh: 4d85e02c769ddae0
@@ -21,8 +21,8 @@
en: b7ab01cfe9fed40f
zh: 23703ab7727c3ab5
/deepseek-ai-dsh-web-app/use-this-package/configuration:
- en: dc9845451198ddc8
- zh: 5e8de50c7e8a059a
+ en: a93760e9e451faa6
+ zh: 5dab82dd20aa952f
/deepseek-ai-dsh-web-app/use-this-package/lan-access-and-trusted-hosts:
en: 2522c806db384ddc
zh: 7e96f11194af9f9f
diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md
index 9fd7cda152..952ff9cafa 100644
--- a/packages/bundle/web-app/README.md
+++ b/packages/bundle/web-app/README.md
@@ -7,6 +7,8 @@ kind: "package-bundle"
English | [中文](README.zh.md)
+Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded.
+
## Summary
Run `dsh --profile web` to open an interactive browser GUI with chat, model and settings management, and session history. It uses the same model access, tools, and safety defaults as other dsh surfaces. Startup prints an authenticated URL and normally opens it in the default browser; SSH sessions and `--no-open` leave the URL for manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Choose this package for interactive browser work; use `dsh-headless` for one-shot command-line tasks.
@@ -51,7 +53,7 @@ Most users never set these; the command-line flags feed the four settings below
| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands |
| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network |
-The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc.
+The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. The shipped composition carries no `time-context`, `schedule`, or `ui-schedule` row; the optional experimental bundle `@deepseek-ai/dsh-experimental-schedule-bundle` inserts those three rows from the Plugins page.
### LAN access and trusted hosts
diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md
index e07e0556c1..a82057b09f 100644
--- a/packages/bundle/web-app/README.zh.md
+++ b/packages/bundle/web-app/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-bundle"
[English](README.md) | 中文
+桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。
+
## 概述
运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印带认证信息的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。
@@ -51,7 +53,7 @@ dsh --profile web --no-open --port 8080
| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 |
| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 |
-生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。
+生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 随发行版交付的组合不含 `time-context`、`schedule` 和 `ui-schedule` 行,可选实验性 bundle `@deepseek-ai/dsh-experimental-schedule-bundle` 可在插件管理页插入这三行。
### LAN 访问与可信主机
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index b0663c968a..0659d73674 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -42,6 +42,24 @@
# `dsh.client` rows are the browser roster the modules node half scans into
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
- insert:
+ - id: desktop-product-telemetry
+ name: '@deepseek-ai/dsh-host-product-telemetry-otel'
+ disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
+ config:
+ timeoutMillis: 1000
+ exportTimeoutMillis: 1500
+ shutdownTimeoutMillis: 2000
+ serviceName: deepseek-harness-desktop
+ serviceVersion: !!js process.env.DSH_CLIENT_VERSION
+ endpoint: !!js process.env.DSH_PRODUCT_ANALYTICS_OTLP_URL
+
+ - id: product-analytics
+ name: '@deepseek-ai/dsh-client-product-analytics'
+ disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'"
+ config:
+ enabled: true
+ appVersion: !!js process.env.DSH_CLIENT_VERSION
+
# Host-owned opt-in sampled when a new Web session receives its preset
# delegation tools. The Plugins page edits this settings namespace.
- id: subagent-model-selection-settings
@@ -113,17 +131,6 @@
- id: workspace-files
name: '@deepseek-ai/dsh-api-workspace-files'
- # Durable Host-wide reminders. The Schedule service owns the task store and
- # delivers each due occurrence as a follow-up in its original Session;
- # `time-context` supplies the current time, browser zone, and elapsed time
- # the model needs to resolve an unqualified date or time. The browser half
- # is the `ui-schedule` row below.
- - id: time-context
- name: '@deepseek-ai/dsh-time-context'
-
- - id: schedule
- name: '@deepseek-ai/dsh-schedule'
-
# Configuration-surface reads and writes over Typert Remote. Each method
# reports an actionable error when its settings-domain provider is absent.
- id: ui-settings-account
@@ -363,11 +370,6 @@
- id: ui-jobs
name: '@deepseek-ai/dsh-client-ui-jobs'
- # Task management and Session reminder catalog over the Host Schedule
- # service mounted above.
- - id: ui-schedule
- name: '@deepseek-ai/dsh-client-ui-schedule'
-
# Goal surface: GoalBar in the input dock over the goal session projection.
- id: ui-goal
name: '@deepseek-ai/dsh-client-ui-goal'
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 9274d264be..accaa7132e 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-web-app",
"description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
@@ -91,7 +91,6 @@
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-client-ui-reference": "workspace:*",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
- "@deepseek-ai/dsh-client-ui-schedule": "workspace:*",
"@deepseek-ai/dsh-client-ui-session": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:*",
@@ -138,7 +137,6 @@
"@deepseek-ai/dsh-persona": "workspace:*",
"@deepseek-ai/dsh-plan-mode": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
- "@deepseek-ai/dsh-schedule": "workspace:*",
"@deepseek-ai/dsh-session-log-export": "workspace:*",
"@deepseek-ai/dsh-session-reference": "workspace:*",
"@deepseek-ai/dsh-session-stats": "workspace:*",
@@ -147,7 +145,6 @@
"@deepseek-ai/dsh-subprocess": "workspace:*",
"@deepseek-ai/dsh-terminal": "workspace:*",
"@deepseek-ai/dsh-terminal-bash": "workspace:*",
- "@deepseek-ai/dsh-time-context": "workspace:*",
"@deepseek-ai/dsh-tool-ask-user": "workspace:*",
"@deepseek-ai/dsh-tool-bash": "workspace:*",
"@deepseek-ai/dsh-tool-bash-persistent": "workspace:*",
@@ -176,7 +173,9 @@
"@deepseek-ai/dsh-api-account-controller": "workspace:*",
"@deepseek-ai/dsh-client-ui-settings-account": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
- "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*"
+ "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*",
+ "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -196,6 +195,7 @@
"@deepseek-ai/dsh-shell-env": "workspace:*",
"@deepseek-ai/dsh-system-prompt": "workspace:*",
"@deepseek-ai/dsh-typert-registry": "workspace:*",
- "@deepseek-ai/libreoffice-kit": "^0.1.0"
+ "@deepseek-ai/libreoffice-kit": "^0.1.1",
+ "@deepseek-ai/dsh-otel": "workspace:*"
}
}
diff --git a/packages/bundle/web-app/tests/product-analytics.spec.ts b/packages/bundle/web-app/tests/product-analytics.spec.ts
new file mode 100644
index 0000000000..178d775d6f
--- /dev/null
+++ b/packages/bundle/web-app/tests/product-analytics.spec.ts
@@ -0,0 +1,80 @@
+import OTel from '@deepseek-ai/dsh-otel'
+/** Desktop-only collector policy and bounded shutdown against an unresponsive receiver. */
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { fileURLToPath } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import * as Telemetry from '@deepseek-ai/dsh-host-product-telemetry-otel'
+import Analytics from '@deepseek-ai/dsh-client-product-analytics'
+import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
+
+afterEach(() => { vi.unstubAllEnvs() })
+
+it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop launch: %s', async (profile) => {
+ const received = Promise.withResolvers()
+ const server = createServer((req) => { req.resume(); req.once('end', () => { received.resolve(undefined) }) })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ onTestFinished(async () => { const closed = once(server, 'close'); server.close(); server.closeAllConnections(); await closed })
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('missing collector address')
+ vi.stubEnv('DSH_CLIENT_VERSION', 'test-version')
+ vi.stubEnv('DSH_PRODUCT_ANALYTICS_OTLP_URL', `http://127.0.0.1:${address.port}/logs`)
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ ctx.provide('profileContext', {
+ name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json',
+ cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined,
+ })
+ const identity = vi.fn().mockResolvedValue(undefined)
+ ctx.provide('deepseekAccount', { getDeviceIdentity: identity } as never)
+ await ctx.plugin(OTel)
+ ctx.baseUrl = 'file:///'
+ await ctx.plugin(Loader).await()
+ ctx.loader.builtins.telemetry = Telemetry
+ ctx.loader.builtins.analytics = Analytics
+ const rows = loadOverlayPatches('analytics', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url)))
+ .flatMap(patch => patch.insert ?? []).filter(row => row.id === 'desktop-product-telemetry' || row.id === 'product-analytics')
+ const entries = rows.map(row => ({ ...row, name: row.id === 'desktop-product-telemetry' ? 'cordis:telemetry' : 'cordis:analytics' }))
+ await ctx.loader.root.update(entries)
+ await ctx.loader.await()
+ const entry = ctx.loader.resolve('desktop-product-telemetry')
+ expect(entry.disabled).toBe(profile !== 'desktop')
+ if (profile !== 'desktop') {
+ expect(ctx.get('productTelemetry')).toBeUndefined()
+ expect(ctx.get('productAnalytics')).toBeUndefined()
+ return
+ }
+ expect(rows[0]!.config).toMatchObject({ timeoutMillis: 1000, exportTimeoutMillis: 1500, shutdownTimeoutMillis: 2000 })
+ const analytics = ctx.productAnalytics
+ const analyticsFiber = ctx.loader.resolve('product-analytics').fiber
+ const telemetryFiber = entry.fiber
+ const lifetime = new AbortController()
+ onTestFinished(() => { lifetime.abort() })
+ const policy = analytics.watchPolicy(lifetime.signal)[Symbol.asyncIterator]()
+ expect(await policy.next()).toEqual({ value: true, done: false })
+ for (const enabled of [false, true]) {
+ const changed = policy.next()
+ await ctx.loader.root.update(entries.map(row => row.id === 'product-analytics'
+ ? { ...row, config: { ...row.config as Record, enabled } } : row))
+ await ctx.loader.await()
+ expect(await changed).toEqual({ value: enabled, done: false })
+ expect(ctx.loader.resolve('product-analytics').fiber === analyticsFiber).toBe(true)
+ expect(entry.fiber === telemetryFiber).toBe(true)
+ if (!enabled) {
+ await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
+ expect(identity).not.toHaveBeenCalled()
+ }
+ }
+ const emit = vi.spyOn(ctx.productTelemetry, 'emit')
+ await analytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
+ expect(emit).toHaveBeenCalledWith(expect.objectContaining({ attributes: { app_version: 'test-version' } }))
+ lifetime.abort()
+ await policy.return?.()
+ ctx.productTelemetry.emit({ eventName: 'desktop_upgrade_install_restart_click', body: 'upgrade', timestamp: Date.now() })
+ const disposal = entry.fiber!.dispose()
+ await received.promise
+ await disposal
+})
diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml
index 30722e5cf9..8e3015ad7c 100644
--- a/packages/client/README.i18n.yaml
+++ b/packages/client/README.i18n.yaml
@@ -16,7 +16,7 @@
zh: a8a3dceee2c9e943
/client-web-gui-browser-half/packages:
en: 2a964f7daf3586d5
- zh: 726fe18e86157978
+ zh: 2ca0adf9479a1ac5
/client-web-gui-browser-half/related-documentation:
en: 598d07b07449128f
zh: e8c40708ac0e603e
diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md
index d5c33dc99d..da9ee4d12b 100644
--- a/packages/client/README.zh.md
+++ b/packages/client/README.zh.md
@@ -77,7 +77,7 @@ kind: "package-group"
| [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
| [`ui-settings-shell/`](ui-settings-shell/README.zh.md) | 在插件页提供终端设置页 | — |
| [`ui-settings-agent-loop/`](ui-settings-agent-loop/README.zh.md) | 在插件页提供 Agent 循环设置页 | — |
-| [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供 Subagent 设置页 | — |
+| [`ui-settings-subagent/`](ui-settings-subagent/README.zh.md) | 在插件页提供子智能体设置页 | — |
| [`ui-settings-web-search/`](ui-settings-web-search/README.zh.md) | 在插件页提供网页搜索设置页 | — |
| [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — |
| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — |
diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json
index c119bf6858..44d0bf9d24 100644
--- a/packages/client/connection/package.json
+++ b/packages/client/connection/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-connection",
"description": "Authenticated RPC transport and generation lifecycle",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json
index 47ebdfed87..1d6539bfbd 100644
--- a/packages/client/file-upload/package.json
+++ b/packages/client/file-upload/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-file-upload",
"description": "Agent-scoped browser file upload, streaming intake, and staged receipt service",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json
index a5aec7bf18..92f9f22d92 100644
--- a/packages/client/hmr/package.json
+++ b/packages/client/hmr/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-hmr",
"description": "Web client graph synchronization and rebuilt-bundle reload transport",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json
index 8202d89593..df7b06db8a 100644
--- a/packages/client/locale/package.json
+++ b/packages/client/locale/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-locale",
"description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json
index dede099635..689ebfcc67 100644
--- a/packages/client/modules/package.json
+++ b/packages/client/modules/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-modules",
"description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/product-analytics/README.i18n.yaml b/packages/client/product-analytics/README.i18n.yaml
new file mode 100644
index 0000000000..0847f8f241
--- /dev/null
+++ b/packages/client/product-analytics/README.i18n.yaml
@@ -0,0 +1,34 @@
+# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/client/product-analytics/README.md
+/:
+ en: 385fffb5f6f81222
+ zh: ea1d23fccd3b2b84
+/deepseek-ai-dsh-client-product-analytics:
+ en: bfcd910889485da9
+ zh: 74e27b92b9fafc33
+/deepseek-ai-dsh-client-product-analytics/summary:
+ en: 4b48b9e5de3a0e97
+ zh: cc06154eef4c4f6a
+/deepseek-ai-dsh-client-product-analytics/table-of-contents:
+ en: 2fee9973f19f9401
+ zh: 8ab4b55413cb8408
+/deepseek-ai-dsh-client-product-analytics/use-this-package:
+ en: 6bd7bf60e279ba1c
+ zh: afd55f06585c6462
+/deepseek-ai-dsh-client-product-analytics/understand-the-implementation:
+ en: 416b7e925f0852c9
+ zh: c0b83bacf18e91b1
+/deepseek-ai-dsh-client-product-analytics/model-experience:
+ en: 291751b16b8a37e7
+ zh: 3b38008f4364ec41
+/deepseek-ai-dsh-client-product-analytics/model-experience/kv-cache-effect:
+ en: 1f5125831808e7a9
+ zh: 97566fdde332df39
+/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work:
+ en: a33ad31745fd5240
+ zh: 8b579cfc4c9b8155
+/deepseek-ai-dsh-client-product-analytics/known-limitations-and-deferred-work/dev-note:
+ en: 0a1253c6f7b5e8b9
+ zh: 9f56528562c7c914
diff --git a/packages/client/product-analytics/README.md b/packages/client/product-analytics/README.md
new file mode 100644
index 0000000000..307534adb4
--- /dev/null
+++ b/packages/client/product-analytics/README.md
@@ -0,0 +1,67 @@
+---
+description: "Configure Desktop product analytics, identity fields, and event timing without collecting Web usage."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-product-analytics
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Desktop reports selected interactions through the existing OTel product exporter by default, without a user-facing control. Ordinary Web clients never submit these events, and missing login identity is omitted.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+
+
+## Use this package
+
+Desktop mounts Analytics and its required Telemetry exporter. The `product-analytics` settings namespace owns the live `enabled` field, which defaults to `true`; configure it through the existing Cordis Config / settings mechanism. There is no user-facing control. Ordinary Web mounts neither service. Disabled collection reads no analytics identity and accepts no new events; the exporter remains mounted and may finish exporting records already queued. Session-feedback telemetry has its own policy.
+
+The renderer and Electron subscribe to the Host policy through the existing authenticated stream, including reconnection. Electron also reads the initial policy before native launch reporting. Welcome obtains the current policy over IPC. The Host checks its current volatile configuration again at event intake and after identity lookup. `DSH_PRODUCT_ANALYTICS_OTLP_URL` overrides the export destination for isolated collectors. The [exporter](../../host/product-telemetry-otel/README.md) owns batching, retry, and shutdown delivery.
+
+Common fields are `device_id`, `user_id`, `os_version`, and `app_version`. Device identity reuses the existing login record without generating one. The Host reads credential-free device, account, and OS fields through `deepseekAccount.getDeviceIdentity()`. Missing values are omitted; API keys, account tokens, prompts, and responses are never event fields.
+
+Electron forwards its build-inlined `DSH_CLIENT_VERSION` to the Host; analytics and the exporter reuse this client version. The exporter requires it. Desktop export, batch, and shutdown deadlines are 1000, 1500, and 2000 milliseconds, below the Host exit budget.
+
+The [event types](src/events.ts) own names and allowed fields. Authentication events cover the native welcome page only; workspace login after API-key entry is excluded. Views count visible page entry, including reshown native welcome windows; transient onboarding loading does not repeat the same page impression, and closing an onboarding popup reports `button_name=close`. Funded Continue uses `continue`. Message submission retains its original occurrence timestamp and captures `msg_type=default|queue|steer`, model, explicit effort, and `run_mode` before asynchronous command adjudication; only the ordinary message path reports before reference serialization, so handled and claimed commands are excluded. Later serialization, attachment, or send failures do not retract this attempt; queue execution does not count again. Attachment-only submissions follow the same rule. Plan takes precedence over an active goal. Capture and reporting failures cannot interrupt submission. Message submissions and model or effort switches omit `session_id` while the Session is blank. Model and plugin switches report only accepted changes. Fork events carry the created child ID and the source IDs, before the optional child-title update; failed creation emits nothing.
+
+Only `plugin_toggle` carries `plugin_type`: plugin rows use `plugin`, and packages use `bundle`. Installation reports `is_success`; cancellation uses `is_success=false` and `error_reason=user_cancelled`, and an absent recovery result uses `is_success=false` and `error_reason=unknown_result`. Duration is milliseconds from the install click through the terminal result, including validation, inspection, and internal registry retries. `input_value` retains registry package identifiers and plain versions only; Git inputs become `[git]`, other URLs `[url]`, and paths or unrecognized inputs `[path-or-other]`. Both click and result use this redacted value. Reopening a hidden install dialog counts another `plugin_add_button_click`. `is_builtin` means installation-supplied (`installed=false`), not whether a profile explicitly depends on the package. Explicit build-approval retry starts a new attempt. Restart-required is success. A temporary disconnect retains the pending operation; only an absent recovery result reports `unknown_result`.
+
+
+## Understand the implementation
+
+
+Implementation internals — click to expand
+
+Presentational components receive callbacks. Shared Client services resolve the optional Desktop analytics sender, which requires both the native preload marker and the synchronized Host policy. The sender retains its own RPC context when called by other plugins. Native actions use the authenticated Host API; its generated Typert validator accepts only the typed event fields. The Host enriches identity immediately before queueing. It observes live compaction events without replaying Session history. No runtime invariant companion is published because collection has no independent delivery acknowledgement to compare.
+
+
+
+
+## Model Experience
+
+None, as analytics adds no model context or Session events.
+
+#### KV Cache effect
+
+None; collection does not modify model requests.
+
+## Known Limitations and Deferred Work
+
+
+
+Delivery is best effort.
+
+- Disabling stops new collection; it does not clear the exporter queue.
+- Renderer failures are discarded, and the exporter has no durable outbox or warehouse acknowledgement.
+- Native startup reporting waits for Host authentication; a process that fails before Host readiness cannot report its launch.
+
+### Dev Note
+
+None.
diff --git a/packages/client/product-analytics/README.zh.md b/packages/client/product-analytics/README.zh.md
new file mode 100644
index 0000000000..1a87f9fd0c
--- /dev/null
+++ b/packages/client/product-analytics/README.zh.md
@@ -0,0 +1,67 @@
+---
+description: "配置桌面端产品埋点、身份字段和事件时机,不采集 Web 使用情况。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-product-analytics
+
+[English](README.md) | 中文
+
+## 概述
+
+桌面端默认通过现有 OTel 产品导出器上报指定交互,不提供用户操作入口。普通 Web 客户端不会提交这些事件,缺失的登录身份字段会省略。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [了解实现](#understand-the-implementation)
+- [模型体验](#model-experience)
+- [已知限制与后续工作](#known-limitations-and-deferred-work)
+
+
+## 使用本包
+
+桌面端同时装配 Analytics 与必需的 Telemetry 导出器。`product-analytics` settings 命名空间持有动态 `enabled` 字段,默认 `true`,通过现有 Cordis Config / settings 机制配置,暂不提供用户操作入口。普通 Web 不装配这两个服务。关闭后不读取埋点身份、不接收新事件;导出器仍保持挂载,已入队事件可以继续导出。会话反馈遥测采用独立策略。
+
+渲染端和 Electron 通过现有认证流订阅 Host 策略变化及重连后的值;Electron 在原生启动上报前还会读取初始策略。欢迎窗口通过 IPC 获取当前策略。Host 在接收事件及读取身份后都检查当前 volatile 配置。`DSH_PRODUCT_ANALYTICS_OTLP_URL` 可覆盖导出目的地,用于隔离的接收端。[导出器](../../host/product-telemetry-otel/README.zh.md)负责批量发送、重试和退出时的交付。
+
+公共字段为 `device_id`、`user_id`、`os_version` 和 `app_version`。设备身份复用现有登录记录,不会生成新标识。Host 通过 `deepseekAccount.getDeviceIdentity()` 读取不含凭据的设备、账户和操作系统字段。缺失值会省略;API key、账户令牌、提示词和模型回复都不是事件字段。
+
+Electron 将构建内联的 `DSH_CLIENT_VERSION` 传给 Host;埋点与导出器复用这一客户端版本,导出器要求该值存在。桌面导出、批次与关闭期限分别为 1000、1500 和 2000 毫秒,均短于 Host 退出预算。
+
+认证事件仅覆盖原生欢迎页;通过 API Key 进入工作区后再登录的场景不在采集范围内。[事件类型](src/events.ts)定义名称和允许的字段。页面曝光按实际进入可见页面计数,包括重新显示的原生欢迎窗口;onboarding 短暂进入加载状态不会重复计算同一页面曝光,关闭 onboarding 弹窗上报 `button_name=close`。有余额时的继续按钮使用 `continue`。消息提交保留最初发生时间,并在异步命令裁决前采集 `msg_type=default|queue|steer`、模型、显式思考强度和 `run_mode`;仅普通消息路径在引用序列化之前上报,已处理或认领的命令不计入。后续序列化、附件处理或发送失败不撤销此次计数,排队后的执行也不重复计数;纯附件提交遵循相同规则。计划模式优先于活跃目标。采集和上报异常不会中断提交。消息提交以及模型或思考强度切换在空白会话中均省略 `session_id`。模型与插件切换仅在变更被接受后上报。分叉事件携带已创建的子会话 ID 和来源 ID,在可选的子会话标题更新前上报;创建失败不产生事件。
+
+只有 `plugin_toggle` 携带 `plugin_type`:插件条目使用 `plugin`,插件包使用 `bundle`。安装通过 `is_success` 表达结果;取消使用 `is_success=false` 和 `error_reason=user_cancelled`,恢复查询确认无结果时使用 `is_success=false` 和 `error_reason=unknown_result`。耗时单位为毫秒,从点击安装开始,到最终结果结束,包含校验、检查输入和内部源重试。`input_value` 仅保留 registry 包标识和普通版本;Git 输入记为 `[git]`,其他 URL 记为 `[url]`,路径及无法识别的输入记为 `[path-or-other]`。点击和结果均使用脱敏后的值。重新打开已隐藏的安装弹窗会再次计为 `plugin_add_button_click`。`is_builtin` 表示由安装供应(`installed=false`),不表示 profile 是否显式依赖该包。显式批准构建后的重试开启新的一次尝试。需要重启视为成功。临时断线保留待定操作;只有恢复查询确认没有结果时才上报 `unknown_result`。
+
+
+## 了解实现
+
+
+实现细节——点击展开
+
+展示组件接收回调。共享 Client 服务读取可选的桌面埋点发送器,后者要求同时存在原生 preload 标识和 同步的 Host 采集策略。发送器在被其他插件调用时保留自身的 RPC 上下文。原生操作使用已认证的 Host API,其生成的 Typert 校验器只接受类型中声明的事件字段。Host 在入队前补充身份信息,并监听实时压缩事件,不重放会话历史。本包不发布运行时不变量伴随模块,因为采集没有可用于比较的独立交付确认。
+
+
+
+
+## 模型体验
+
+无;埋点不增加模型上下文或会话事件。
+
+#### KV 缓存影响
+
+无;采集不修改模型请求。
+
+## 已知限制与后续工作
+
+
+
+交付采用尽力而为的方式。
+
+- 关闭只停止新采集,不清空导出器队列。
+- 渲染进程的发送失败会被丢弃,导出器没有持久化待发队列或数据仓库确认。
+- 原生启动事件等待 Host 认证完成;若进程在 Host 就绪前失败,则无法上报启动事件。
+
+### 开发备注
+
+无。
diff --git a/packages/client/product-analytics/package.json b/packages/client/product-analytics/package.json
new file mode 100644
index 0000000000..b3e2fffa7d
--- /dev/null
+++ b/packages/client/product-analytics/package.json
@@ -0,0 +1,82 @@
+{
+ "name": "@deepseek-ai/dsh-client-product-analytics",
+ "description": "Desktop product event collection and authenticated Host reporting",
+ "version": "0.1.7-rc.2",
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+ "directory": "packages/client/product-analytics"
+ },
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./client": {
+ "types": "./lib/types/client/index.d.ts",
+ "default": "./lib/client.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json",
+ "./typert": {
+ "types": "./lib/typert.host.d.ts",
+ "default": "./lib/typert.host.js"
+ },
+ "./remote": {
+ "types": "./lib/typert.remote-client.d.ts",
+ "default": "./lib/typert.remote-client.js"
+ },
+ "./types": {
+ "types": "./lib/types/events.d.ts"
+ }
+ },
+ "dsh": {
+ "client": {
+ "inject": [
+ "@deepseek-ai/dsh-api-gateway"
+ ],
+ "platform": "web"
+ }
+ },
+ "scripts": {
+ "bundle": "tsdown",
+ "watch": "tsdown --watch"
+ },
+ "license": "MIT",
+ "peerDependencies": {
+ "@deepseek-ai/cordis": "workspace:~"
+ },
+ "devDependencies": {
+ "@deepseek-ai/cordis": "workspace:~",
+ "@deepseek-ai/dsh-api-gateway": "workspace:*",
+ "@deepseek-ai/dsh-compaction": "workspace:*",
+ "@deepseek-ai/dsh-llm": "workspace:*",
+ "@deepseek-ai/dsh-session": "workspace:*",
+ "@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*",
+ "@deepseek-ai/dsh-deepseek-account": "workspace:*",
+ "@deepseek-ai/dsh-host-webserver": "workspace:*",
+ "@deepseek-ai/dsh-settings": "workspace:*",
+ "@deepseek-ai/cordis-plugin-loader": "workspace:~",
+ "@deepseek-ai/dsh-otel": "workspace:*"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/client.js",
+ "lib/types/**/*.d.ts",
+ "lib/typert.host.js",
+ "lib/typert.host.d.ts",
+ "lib/typert.remote-client.js",
+ "lib/typert.remote-client.d.ts"
+ ],
+ "dependencies": {
+ "@deepseek-ai/dsh-typert-protocol": "workspace:*",
+ "@deepseek-ai/schemastery": "workspace:~",
+ "zod": "^4.4.3"
+ }
+}
diff --git a/packages/client/product-analytics/src/client/index.ts b/packages/client/product-analytics/src/client/index.ts
new file mode 100644
index 0000000000..dfa990bfe6
--- /dev/null
+++ b/packages/client/product-analytics/src/client/index.ts
@@ -0,0 +1,60 @@
+/** Desktop renderer analytics sender; browser applications have no collection capability. */
+import { Service, type Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-api-gateway/client'
+export type {} from '@deepseek-ai/dsh-client-product-analytics/remote'
+import type { ProductEvent, ProductEventMap } from '../events.ts'
+export type { ProductEvent, ProductEventMap, TrackProductEvent } from '../events.ts'
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ productAnalytics: DesktopAnalytics
+ }
+}
+
+class DesktopAnalytics extends Service {
+ private collecting = false
+ // RPC dispatch stays in the provider context when Cordis traces a consumer call.
+ private readonly submit: (event: ProductEvent) => Promise
+
+ constructor(ctx: Context) {
+ super(ctx, 'productAnalytics')
+ this.submit = async (event) => {
+ try { await ctx.remote.productAnalytics.report(event) } catch (_error) {
+ // Missing or disconnected Remote services must not interrupt the interaction.
+ }
+ }
+ if (!('dshDesktop' in globalThis)) return
+ const stream = ctx.remote.$stream({
+ name: 'product analytics policy', open: signal => ctx.remote.productAnalytics.watchPolicy(signal),
+ ended: () => new Error('product analytics policy stream ended'),
+ carrierFailed: () => { this.collecting = false },
+ })
+ ctx.effect(() => () => { this.collecting = false; return stream.dispose() })
+ void (async () => {
+ for await (const frame of stream) { this.collecting = frame.value; frame.accept() }
+ })().catch(() => { this.collecting = false })
+ }
+
+ /** Whether the synchronized Host configuration currently permits collection. */
+ get enabled(): boolean {
+ return this.collecting
+ }
+
+ /**
+ * Send an event without retaining it for reconnect or later enablement.
+ * @param name - event name.
+ * @param attributes - approved business fields.
+ * @param timestamp - occurrence time; defaults to the current time.
+ */
+ track(name: K, attributes: ProductEventMap[K], timestamp = Date.now()): void {
+ if (!this.enabled) return
+ const event = { eventName: name, attributes, timestamp } as ProductEvent
+ void this.submit(event)
+ }
+}
+
+/** Analytics consumes authenticated RPC and its reconnecting policy stream. */
+export const inject = ['remote', 'remote.productAnalytics']
+
+/** @param ctx - browser application context. */
+export function apply(ctx: Context): void { ctx.plugin(DesktopAnalytics) }
diff --git a/packages/client/product-analytics/src/events.ts b/packages/client/product-analytics/src/events.ts
new file mode 100644
index 0000000000..7fc64a6bcc
--- /dev/null
+++ b/packages/client/product-analytics/src/events.ts
@@ -0,0 +1,61 @@
+/** Desktop analytics fields selected by product event owners. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
+
+/** Event names and their selected product attributes. */
+export interface ProductEventMap {
+ desktop_app_launch: Record
+ auth_page_view: Record
+ auth_page_click: { button_name: 'sign_in' | 'api-key' }
+ api_key_save_click: Record
+ onboarding_page_view: { page_name: OnboardingPage }
+ onboarding_page_click: {
+ page_name: OnboardingPage
+ button_name: 'next' | 'back' | 'skip' | 'charge' | 'later' | 'continue'
+ selected_content?: 'office' | 'code' | 'code_office' | 'focus_result' | 'key_detail' | 'full_process'
+ }
+ onboarding_popup_view: { popup_name: 'skip_charge' | 'skip_setting' }
+ onboarding_popup_click: { popup_name: 'skip_charge' | 'skip_setting'; button_name: 'charge' | 'know' | 'enter' | 'setting' | 'close' }
+ desktop_upgrade_click: Record
+ desktop_upgrade_download_result: { is_success: boolean; error_reason?: string }
+ desktop_upgrade_install_restart_click: Record
+ send_button_click: {
+ session_id?: SessionId
+ model_name?: string
+ thinking_effort?: string
+ run_mode: 'plan' | 'goal' | 'default'
+ msg_type: 'default' | 'steer' | 'queue'
+ }
+ model_switch: { session_id?: SessionId; switch_from: string; switch_to: string }
+ thinking_level_switch: { session_id?: SessionId; switch_from: string; switch_to: string; model_name: string }
+ context_compression: { session_id: SessionId; trigger_type: 'auto' | 'manual' }
+ branch_session_click: {
+ session_id: SessionId
+ parent_session_id: SessionId
+ parent_message_id?: MessageId
+ click_position: 'footer' | 'sidebar'
+ }
+ sidebar_menu_click: { menu_name: 'plugin' | 'cron' }
+ plugin_toggle: { plugin_name: string; plugin_type: 'plugin' | 'bundle'; is_enabled: boolean; is_builtin: boolean }
+ plugin_add_button_click: Record
+ plugin_install_click: { input_value: string }
+ install_plugin_result: {
+ input_value: string
+ is_success: boolean
+ error_reason?: string
+ duration: number
+ plugin_name?: string
+ }
+ confirm_uninstall_plugin: { plugin_name: string }
+}
+
+/** Public page names independent of internal onboarding state names. */
+export type OnboardingPage = 'onboarding_welcome' | 'onboarding_recharge' | 'onboarding_use_case' | 'onboarding_process'
+
+/** Correlated event name and attributes for the authenticated analytics RPC. */
+export type ProductEvent = {
+ [K in keyof ProductEventMap]: { eventName: K; attributes: ProductEventMap[K]; timestamp: number }
+}[keyof ProductEventMap]
+
+/** Callback passed into presentation components; omitted when collection is disabled. */
+export type TrackProductEvent = (name: K, attributes: ProductEventMap[K]) => void
diff --git a/packages/client/product-analytics/src/index.ts b/packages/client/product-analytics/src/index.ts
new file mode 100644
index 0000000000..3eae234d57
--- /dev/null
+++ b/packages/client/product-analytics/src/index.ts
@@ -0,0 +1,101 @@
+/** Desktop-only analytics RPC and live compaction collection. */
+import { type Context, type Volatile } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import type {} from '@deepseek-ai/dsh-host-product-telemetry-otel'
+import type {} from '@deepseek-ai/dsh-deepseek-account'
+import type {} from '@deepseek-ai/dsh-settings'
+import type {} from '@deepseek-ai/cordis-plugin-loader'
+import type {} from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-compaction/types'
+import type { ProductEvent } from './events.ts'
+
+/** Application-owned collection policy; no user settings surface. */
+export interface Config {
+ /** Live application collection policy; ordinary Web does not mount this service. */
+ enabled: Volatile
+ /** Running Desktop release, absent when unavailable. */
+ appVersion?: string
+}
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ productAnalytics: ProductAnalytics
+ }
+}
+
+/** Authenticated event intake; disabled instances do not inspect identity or accept new events. */
+export default class ProductAnalytics extends TypertRemoteService {
+ static inject = ['deepseekAccount', 'productTelemetry']
+ static Config = z.object({ enabled: z.boolean().default(true).volatile(), appVersion: z.string() })
+ private active = true
+ private readonly listeners = new Set<() => void>()
+
+ constructor(ctx: Context, private readonly config: Config) {
+ super(ctx, 'productAnalytics')
+ ctx.effect(() => () => { this.active = false; for (const listener of this.listeners) listener() })
+ ctx.on('loader/volatile-update', () => { for (const listener of this.listeners) listener() })
+ ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
+ ctx.on('session/event', (session, event) => {
+ if (event.type !== 'compaction/start') return
+ void this.report({ eventName: 'context_compression', timestamp: Date.now(),
+ attributes: { session_id: session.id, trigger_type: event.data.turn === null ? 'manual' : 'auto' } })
+ })
+ }
+
+ /**
+ * Read the collection policy.
+ * @returns whether this Host currently accepts Desktop analytics.
+ */
+ @Remote
+ enabled(): boolean { return this.active && this.config.enabled.get() }
+
+ /**
+ * Stream the effective policy initially and after live configuration edits.
+ * @param signal - subscriber lifetime.
+ * @returns current policy values until cancellation or service disposal.
+ */
+ @Remote({ mode: 'stream' })
+ async *watchPolicy(signal: AbortSignal): AsyncIterable {
+ let update = Promise.withResolvers()
+ const changed = (): void => { update.resolve() }
+ this.listeners.add(changed)
+ signal.addEventListener('abort', changed, { once: true })
+ try {
+ while (this.active && !signal.aborted) {
+ yield this.enabled()
+ await update.promise
+ update = Promise.withResolvers()
+ }
+ } finally {
+ this.listeners.delete(changed)
+ signal.removeEventListener('abort', changed)
+ }
+ }
+
+ /**
+ * Submit selected Desktop fields; missing identity is omitted and never generated.
+ * @param event - typed product event without message contents or credentials.
+ * @returns after local submission; no delivery or warehouse acknowledgement.
+ */
+ @Remote
+ async report(event: ProductEvent): Promise {
+ if (!this.enabled()) return
+ try {
+ const identity = await this.ctx.deepseekAccount.getDeviceIdentity().catch(() => undefined)
+ if (!this.enabled()) return
+ this.ctx.productTelemetry.emit({
+ ...event, body: event.eventName,
+ attributes: {
+ ...event.attributes,
+ ...identity?.deviceId === undefined ? {} : { device_id: identity.deviceId },
+ ...identity?.userId === undefined ? {} : { user_id: identity.userId },
+ ...this.config.appVersion === undefined ? {} : { app_version: this.config.appVersion },
+ ...identity === undefined ? {} : { os_version: identity.osVersion },
+ },
+ })
+ } catch (error) {
+ this.ctx.logger.warn('Product analytics submission failed', error)
+ }
+ }
+}
diff --git a/packages/client/product-analytics/tests/collection.client.spec.ts b/packages/client/product-analytics/tests/collection.client.spec.ts
new file mode 100644
index 0000000000..bdf5e7e5c5
--- /dev/null
+++ b/packages/client/product-analytics/tests/collection.client.spec.ts
@@ -0,0 +1,121 @@
+import { Context, Service } from '@deepseek-ai/cordis'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
+import * as Analytics from '../src/client/index.ts'
+
+const cleanup: (() => Promise)[] = []
+afterEach(async () => {
+ for (const dispose of cleanup.splice(0).reverse()) await dispose()
+ vi.unstubAllGlobals()
+})
+async function setup(enabled = true) {
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ const report = vi.fn().mockResolvedValue({ ok: true, value: undefined })
+ const values = [enabled]
+ let wake: (() => void) | undefined
+ let disposed = false
+ let failure: Error | undefined
+ let options: RemoteStreamOptions | undefined
+ const stream = {
+ async *[Symbol.asyncIterator]() {
+ while (!disposed) {
+ if (failure !== undefined) throw failure
+ const value = values.shift()
+ if (value !== undefined) { yield { value, accept: () => {} }; continue }
+ await new Promise((resolve) => { wake = resolve })
+ }
+ },
+ dispose: async () => { disposed = true; wake?.() },
+ }
+ const remote = {
+ productAnalytics: { report, watchPolicy: vi.fn() },
+ $stream: (value: RemoteStreamOptions) => { options = value; return stream },
+ }
+ class TracedRemote extends Service {
+ constructor(ctx: Context) { super(ctx, 'remote') }
+ $stream = remote.$stream
+ }
+ await ctx.plugin(TracedRemote)
+ await ctx.plugin({ apply(child: Context) { child.provide('remote.productAnalytics', remote.productAnalytics as never) } })
+ await ctx.plugin(Analytics)
+ if ('dshDesktop' in globalThis) await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) })
+ return { ctx, report, options: () => options!,
+ async fail() { failure = new Error('terminal'); wake?.(); await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(false) }) },
+ async policy(enabled: boolean) {
+ values.push(enabled); wake?.()
+ await vi.waitFor(() => { expect(ctx.productAnalytics.enabled).toBe(enabled) })
+ } }
+}
+it('never collects in Web even when the Host advertises analytics', async () => {
+ const b = await setup()
+ expect(b.ctx.productAnalytics.enabled).toBe(false)
+ b.ctx.productAnalytics.track('desktop_app_launch', {})
+ expect(b.report).not.toHaveBeenCalled()
+})
+it('drops disabled events and has no backfill when enabled', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup(false)
+ b.ctx.productAnalytics.track('auth_page_view', {})
+ expect(b.report).not.toHaveBeenCalled()
+ await b.policy(true)
+ b.ctx.productAnalytics.track('auth_page_click', { button_name: 'sign_in' })
+ expect(b.report).toHaveBeenCalledExactlyOnceWith({ eventName: 'auth_page_click', timestamp: expect.any(Number) as number, attributes: { button_name: 'sign_in' } })
+})
+it('does not retry or propagate transport rejection', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.report.mockRejectedValueOnce(new Error('offline'))
+ b.ctx.productAnalytics.track('auth_page_view', {})
+ await Promise.resolve()
+ expect(b.report).toHaveBeenCalledTimes(1)
+})
+
+it('does not interrupt an action when Remote access fails synchronously', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.report.mockImplementationOnce(() => { throw new Error('Remote namespace detached') })
+ expect(() => { b.ctx.productAnalytics.track('auth_page_view', {}) }).not.toThrow()
+ await Promise.resolve()
+ expect(b.report).toHaveBeenCalledOnce()
+})
+
+it('keeps the supplied occurrence time and stops new events on policy changes', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.ctx.productAnalytics.track('auth_page_view', {}, 123)
+ expect(b.report).toHaveBeenCalledWith({ eventName: 'auth_page_view', attributes: {}, timestamp: 123 })
+ await b.policy(false)
+ b.ctx.productAnalytics.track('auth_page_view', {})
+ expect(b.report).toHaveBeenCalledOnce()
+})
+
+it('fails closed on stream loss and accepts a fresh policy after reconnection', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ b.options().open(new AbortController().signal)
+ expect(b.options().ended(true).message).toContain('policy stream ended')
+ b.options().carrierFailed?.(new Error('offline'))
+ expect(b.ctx.productAnalytics.enabled).toBe(false)
+ await b.policy(true)
+ await b.fail()
+})
+
+it('reports from a consumer without granting it the analytics Remote namespace', async () => {
+ vi.stubGlobal('dshDesktop', {})
+ const b = await setup()
+ let click!: () => void
+ await b.ctx.plugin({
+ inject: ['remote'],
+ apply(ctx: Context) {
+ click = () => {
+ expect(ctx.get('productAnalytics')?.enabled).toBe(true)
+ ctx.get('productAnalytics')?.track('sidebar_menu_click', { menu_name: 'plugin' })
+ }
+ },
+ })
+ click()
+ expect(b.report).toHaveBeenCalledExactlyOnceWith({
+ eventName: 'sidebar_menu_click', timestamp: expect.any(Number) as number, attributes: { menu_name: 'plugin' },
+ })
+})
diff --git a/packages/client/product-analytics/tests/collection.host.spec.ts b/packages/client/product-analytics/tests/collection.host.spec.ts
new file mode 100644
index 0000000000..c7878a8940
--- /dev/null
+++ b/packages/client/product-analytics/tests/collection.host.spec.ts
@@ -0,0 +1,161 @@
+import OTel from '@deepseek-ai/dsh-otel'
+import { Context } from '@deepseek-ai/cordis'
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { afterEach, expect, it, vi } from 'vitest'
+import ProductTelemetry, { Config as TelemetryConfig, type ProductTelemetryRecord } from '@deepseek-ai/dsh-host-product-telemetry-otel'
+import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { CompactionId } from '@deepseek-ai/dsh-compaction'
+import Analytics from '../src/index.ts'
+
+const cleanup: (() => Promise)[] = []
+afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() })
+
+async function setup(enabled: boolean) {
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ const getDeviceIdentity = vi.fn().mockResolvedValue({ deviceId: 'login-device', userId: 'user-1', osVersion: 'fixture-os', ignored: 'private-field' })
+ const emit = vi.fn<(record: ProductTelemetryRecord) => void>()
+ ctx.provide('deepseekAccount', { getDeviceIdentity } as never)
+ ctx.provide('webServer', {} as never)
+ ctx.provide('productTelemetry', { emit } as never)
+ const fiber = await ctx.plugin(Analytics, { enabled, appVersion: 'test-version' })
+ return { ctx, fiber, getDeviceIdentity, emit }
+}
+
+it('disabled collection does not read identity or submit an event', async () => {
+ const b = await setup(false)
+ expect(b.ctx.productAnalytics.enabled()).toBe(false)
+ await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} })
+ expect(b.getDeviceIdentity).not.toHaveBeenCalled()
+ expect(b.emit).not.toHaveBeenCalled()
+})
+
+it('reuses login identity and copies only approved common fields', async () => {
+ const b = await setup(true)
+ await b.ctx.productAnalytics.report({ eventName: 'auth_page_click', timestamp: 100, attributes: { button_name: 'sign_in' } })
+ expect(b.getDeviceIdentity).toHaveBeenCalledWith()
+ expect(b.emit).toHaveBeenCalledExactlyOnceWith({
+ eventName: 'auth_page_click', body: 'auth_page_click', timestamp: 100,
+ attributes: { button_name: 'sign_in', device_id: 'login-device', user_id: 'user-1', app_version: 'test-version', os_version: expect.any(String) as string },
+ })
+ expect(JSON.stringify(b.emit.mock.calls)).not.toContain('private-')
+})
+
+it('missing identity does not discard an otherwise valid event', async () => {
+ const b = await setup(true)
+ b.getDeviceIdentity.mockRejectedValueOnce(new Error('credential store unavailable'))
+ await b.ctx.productAnalytics.report({ eventName: 'plugin_add_button_click', timestamp: 100, attributes: {} })
+ expect(b.emit.mock.calls[0]?.[0].attributes).toEqual({ app_version: 'test-version' })
+})
+
+it('unload suppresses an identity lookup that settles after disposal', async () => {
+ const b = await setup(true)
+ const pending = Promise.withResolvers()
+ b.getDeviceIdentity.mockReturnValueOnce(pending.promise)
+ const reporting = b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 100, attributes: {} })
+ await b.fiber.dispose()
+ pending.resolve(undefined)
+ await reporting
+ expect(b.emit).not.toHaveBeenCalled()
+})
+
+it('writes the selected event through the real exporter to an isolated collector', async () => {
+ const captures: string[] = []
+ const server = createServer((req, res) => {
+ const chunks: Buffer[] = []
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
+ req.on('end', () => { captures.push(Buffer.concat(chunks).toString()); res.end('{}') })
+ })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ cleanup.push(async () => { const done = once(server, 'close'); server.close(); server.closeAllConnections(); await done })
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('missing collector port')
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ ctx.provide('credentials', { readRecord: async () => undefined } as never)
+ ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never)
+ ctx.provide('webServer', {} as never)
+ await ctx.plugin(OTel)
+ const exporter = await ctx.plugin(ProductTelemetry, TelemetryConfig({ endpoint: `http://127.0.0.1:${address.port}/v1/logs`, serviceName: 'test', serviceVersion: '1', compression: 'none' }))
+ await ctx.plugin(Analytics, { enabled: true })
+ await ctx.productAnalytics.report({ eventName: 'api_key_save_click', timestamp: 1_800_000_000_000, attributes: {} })
+ await exporter.dispose()
+ expect(captures).toHaveLength(1)
+ expect(JSON.parse(captures[0]!)).toMatchObject({ resourceLogs: [{ scopeLogs: [{ logRecords: [{ eventName: 'api_key_save_click', timeUnixNano: '1800000000000000000' }] }] }] })
+ expect(captures[0]).not.toContain('device_id')
+ expect(captures[0]).not.toContain('app_version')
+})
+
+it.each([true, false])('collects live manual and automatic compaction only when enabled: %s', async (enabled) => {
+ const b = await setup(enabled)
+ const session = Session.create(SessionId('analytics-compaction'))
+ b.ctx.emit('session/event', session, { type: 'turn/start', seq: SessionSeq(0), time: 0, data: { turn: 1 } })
+ for (const turn of [null, 1]) {
+ b.ctx.emit('session/event', session, { type: 'compaction/start', seq: SessionSeq(1), time: 1,
+ data: { compactionId: CompactionId('analytics-compaction'), turn } })
+ }
+ if (enabled) await vi.waitFor(() => { expect(b.emit).toHaveBeenCalledTimes(2) })
+ expect(b.emit.mock.calls.map(([event]) => event.attributes?.trigger_type)).toEqual(enabled ? ['manual', 'auto'] : [])
+ expect(b.getDeviceIdentity).toHaveBeenCalledTimes(enabled ? 2 : 0)
+})
+
+it('omits an unavailable account and isolates exporter submission failure', async () => {
+ const b = await setup(true)
+ b.getDeviceIdentity.mockRejectedValueOnce(new Error('account unavailable'))
+ await b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 1, attributes: {} })
+ expect(b.emit.mock.calls[0]![0].attributes).not.toHaveProperty('user_id')
+ b.emit.mockImplementationOnce(() => { throw new Error('exporter unavailable') })
+ await expect(b.ctx.productAnalytics.report({ eventName: 'desktop_app_launch', timestamp: 2, attributes: {} })).resolves.toBeUndefined()
+})
+
+
+it('requires the exporter even when collection is disabled', async () => {
+ const ctx = new Context()
+ cleanup.push(() => ctx.fiber.dispose())
+ ctx.provide('deepseekAccount', { getDeviceIdentity: async () => undefined } as never)
+ const fiber = ctx.plugin(Analytics, { enabled: false })
+ await Promise.resolve()
+ expect(ctx.get('productAnalytics')).toBeUndefined()
+ ctx.provide('productTelemetry', { emit: vi.fn() } as never)
+ await fiber
+ expect(ctx.productAnalytics.enabled()).toBe(false)
+})
+
+it('streams live policy changes and rejects intake disabled during identity lookup', async () => {
+ const b = await setup(true)
+ const lifetime = new AbortController()
+ const iterator = b.ctx.productAnalytics.watchPolicy(lifetime.signal)[Symbol.asyncIterator]()
+ expect(await iterator.next()).toEqual({ value: true, done: false })
+ const identity = Promise.withResolvers()
+ b.getDeviceIdentity.mockReturnValueOnce(identity.promise)
+ const report = b.ctx.productAnalytics.report({ eventName: 'auth_page_view', timestamp: 1, attributes: {} })
+ const next = iterator.next()
+ // Loader commits the stable Config reference before publishing this notification.
+ const { updateVolatile, createVolatile } = await import('../../../../vendor/cosmokit/src/volatile.ts')
+ const config = b.fiber.config as import('../src/index.ts').Config
+ updateVolatile(config.enabled, createVolatile(false))
+ b.fiber.ctx.emit('loader/volatile-update', [['enabled']])
+ expect(await next).toEqual({ value: false, done: false })
+ identity.resolve(undefined)
+ await report
+ expect(b.emit).not.toHaveBeenCalled()
+ const closed = iterator.next()
+ lifetime.abort()
+ expect(await closed).toEqual({ value: undefined, done: true })
+})
+
+it('wakes pending policy readers on disposal and registers hidden settings presentation', async () => {
+ const b = await setup(true)
+ const dispose = vi.fn()
+ const configure = vi.fn(() => dispose)
+ b.ctx.provide('settings', { configure } as never)
+ await vi.waitFor(() => { expect(configure).toHaveBeenCalledWith({ auto: false }, b.fiber) })
+ const iterator = b.ctx.productAnalytics.watchPolicy(new AbortController().signal)[Symbol.asyncIterator]()
+ await iterator.next()
+ const pending = iterator.next()
+ await b.fiber.dispose()
+ expect(await pending).toEqual({ done: true, value: undefined })
+ expect(dispose).toHaveBeenCalledOnce()
+})
diff --git a/packages/client/product-analytics/tsconfig.client.json b/packages/client/product-analytics/tsconfig.client.json
new file mode 100644
index 0000000000..760e4bc222
--- /dev/null
+++ b/packages/client/product-analytics/tsconfig.client.json
@@ -0,0 +1,26 @@
+{
+ "extends": "../../../tsconfig.base.client.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+ },
+ "files": [
+ "src/client/index.ts",
+ "src/events.ts"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../api/gateway/tsconfig.client.json"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../llm/llm"
+ }
+ ]
+}
diff --git a/packages/client/product-analytics/tsconfig.host.json b/packages/client/product-analytics/tsconfig.host.json
new file mode 100644
index 0000000000..487bb221ac
--- /dev/null
+++ b/packages/client/product-analytics/tsconfig.host.json
@@ -0,0 +1,50 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+ },
+ "files": [
+ "src/index.ts",
+ "src/events.ts"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../typert/protocol"
+ },
+ {
+ "path": "../../host/product-telemetry-otel"
+ },
+ {
+ "path": "../../credentials/credentials"
+ },
+ {
+ "path": "../../credentials/deepseek-account"
+ },
+ {
+ "path": "../../host/webserver"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../compaction/compaction"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../../settings/settings"
+ },
+ {
+ "path": "../../../vendor/loader"
+ }
+ ]
+}
diff --git a/packages/client/product-analytics/tsconfig.json b/packages/client/product-analytics/tsconfig.json
new file mode 100644
index 0000000000..2eca820546
--- /dev/null
+++ b/packages/client/product-analytics/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.host.json"
+ },
+ {
+ "path": "./tsconfig.client.json"
+ }
+ ]
+}
diff --git a/packages/client/product-analytics/tsdown.config.ts b/packages/client/product-analytics/tsdown.config.ts
new file mode 100644
index 0000000000..4479f52fe2
--- /dev/null
+++ b/packages/client/product-analytics/tsdown.config.ts
@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-product-analytics', ['lib/types/index.js'], { hostPhase: true })
diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json
index fa7cd71706..d5febb5eb1 100644
--- a/packages/client/resources/package.json
+++ b/packages/client/resources/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-resources",
"description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json
index b3ea0b1fc2..79a13d595a 100644
--- a/packages/client/shortcuts/package.json
+++ b/packages/client/shortcuts/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-shortcuts",
"description": "Application keyboard command registry and physical-key routing",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/store/package.json b/packages/client/store/package.json
index 0b8c1810f0..f240542739 100644
--- a/packages/client/store/package.json
+++ b/packages/client/store/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-store",
"description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml
index 68a78586f7..7f08d21282 100644
--- a/packages/client/ui-agent-preset/README.i18n.yaml
+++ b/packages/client/ui-agent-preset/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: b3ad7867f23dad1c
/deepseek-ai-dsh-client-ui-agent-preset/use-this-package:
- en: 1b1af8f5a2ac6b01
- zh: 5ad3daeead8040df
+ en: cf381c4f00b42c3f
+ zh: 1b226687dcbd4875
/deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation:
en: 7813f0063c15e241
zh: 6eabc242edfc45ec
diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md
index 4cdf3e1ece..ca7bba393e 100644
--- a/packages/client/ui-agent-preset/README.md
+++ b/packages/client/ui-agent-preset/README.md
@@ -27,7 +27,7 @@ Choose Agent presets and the new-task default in Web, read what each mode does a
Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists.
-Developer tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears and the cards refuse selection, while the saved default keeps composing new tasks. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset.
+Coding Tools in General Settings decide whether a mode can be chosen at all: with them off the new-session picker disappears and the cards refuse selection, while the saved default keeps composing new tasks. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset.
Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's group badge, and the preset id appears beside the title. Guide copy and examples belong to this package.
diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md
index fce38232f8..d2a3471f45 100644
--- a/packages/client/ui-agent-preset/README.zh.md
+++ b/packages/client/ui-agent-preset/README.zh.md
@@ -27,7 +27,7 @@ kind: "package-reference"
设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。
-通用设置中的开发者工具决定能否选择模式:关闭后新会话选择器消失、卡片拒绝选择,而已保存的默认值继续用于新任务。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。
+通用设置中的代码工作工具决定能否选择模式:关闭后新会话选择器消失、卡片拒绝选择,而已保存的默认值继续用于新任务。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。
已知的内置预设提供只读的模式说明与使用示例对话框。各页签保留各自的滚动位置;关闭后焦点回到打开它的操作。帮助不会改变新任务默认值。默认徽标取代卡片的分组徽标,预设 id 显示在标题旁。指南文案与示例归本包所有。
diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json
index 518c25b092..a08b9d75ef 100644
--- a/packages/client/ui-agent-preset/package.json
+++ b/packages/client/ui-agent-preset/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-agent-preset",
"description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts
index 586665fc85..30b3e79eac 100644
--- a/packages/client/ui-agent-preset/src/client/locales.ts
+++ b/packages/client/ui-agent-preset/src/client/locales.ts
@@ -67,8 +67,8 @@ export const en: Record = {
creatorDraft: 'Let the agent help me create a preset',
- enableDevToolsToSetDefault: 'Turn on Developer tools in General settings to choose a default',
- enableDevToolsToCreate: 'Turn on Developer tools in General settings to start Creator mode',
+ enableDevToolsToSetDefault: 'Turn on Coding Tools in General settings to choose a default',
+ enableDevToolsToCreate: 'Turn on Coding Tools in General settings to start Creator mode',
}
/** Simplified Chinese copy. */
@@ -104,8 +104,8 @@ export const zh: Record = {
creatorDraft: '让 Agent 帮我创建预设模式',
- enableDevToolsToSetDefault: '请先在通用设置中开启开发者工具,再设置默认值',
- enableDevToolsToCreate: '请先在通用设置中开启开发者工具,再启动创造模式',
+ enableDevToolsToSetDefault: '请先在通用设置中开启代码工作工具,再设置默认值',
+ enableDevToolsToCreate: '请先在通用设置中开启代码工作工具,再启动创造模式',
}
// The resolution itself is the shared fold in `dsh-agent-preset-registry/display`,
diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json
index 294db17fa3..3d89009fe3 100644
--- a/packages/client/ui-approval/package.json
+++ b/packages/client/ui-approval/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-approval",
"description": "Approval composer takeover over the scoped Remote Event waterfall",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json
index 547e81ef38..d092d49e60 100644
--- a/packages/client/ui-attachment/package.json
+++ b/packages/client/ui-attachment/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-attachment",
"description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json
index ab29f603a1..e5605fcff0 100644
--- a/packages/client/ui-brand-official/package.json
+++ b/packages/client/ui-brand-official/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-brand-official",
"description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 43fe0e4ac9..f7077d438f 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -6,8 +6,8 @@
en: d88f75fc0178ba53
zh: 52bc7ae9551ab1cf
/deepseek-ai-dsh-client-ui-chat:
- en: a0be18d7053df6b0
- zh: 28c76e2cc32b7494
+ en: 02bd72c12ada3218
+ zh: 331b3a38b3ddfc6e
/deepseek-ai-dsh-client-ui-chat/summary:
en: fba5b2fb8080e2dd
zh: 32c337123b7dfbcb
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index b3343fd9c3..297b1b3d69 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -6,6 +6,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
+
## Summary
Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Work-details modes control reasoning previews and process visibility without hiding final answers; Verbose keeps completed-turn process rows visible. 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.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index b524f85580..d2582913b3 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -6,6 +6,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
+
## 概述
使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。工作步骤展示模式控制思考预览与过程显隐,不隐藏最终答案;完全展开模式保持已完成轮次的过程行可见。本地 transcript(文本记录)与 steering(中途引导)提交会立即显示并保留在原区域,在权威会话记录到达时原子地消失,而排队中的提交始终不进入 Chat。本包不组装或修改模型请求。
diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json
index faf1687e0e..bd51186a1b 100644
--- a/packages/client/ui-chat/package.json
+++ b/packages/client/ui-chat/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-chat",
"description": "Chat Conversation target, node definitions, renderers, and details surface",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
@@ -92,7 +92,8 @@
"@types/react-dom": "~18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "@deepseek-ai/dsh-settings": "workspace:*"
+ "@deepseek-ai/dsh-settings": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~"
diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts
index 7335a49db5..c4c6ce3b95 100644
--- a/packages/client/ui-chat/src/chat-settings.ts
+++ b/packages/client/ui-chat/src/chat-settings.ts
@@ -15,7 +15,7 @@ export const TRANSCRIPT_VIEW_MODES = ['compact', 'standard', 'detailed', 'verbos
export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
/**
- * Saved value from the two-mode generation of this setting. Read as `standard`;
+ * Saved value from the two-mode generation of this setting. Read as `detailed`;
* never offered as a choice and never written back.
*/
export const LEGACY_TRANSCRIPT_VIEW_MODE = 'normal'
@@ -28,8 +28,8 @@ const TRANSCRIPT_VIEW_SETTING_VALUES = [
...TRANSCRIPT_VIEW_MODES, LEGACY_TRANSCRIPT_VIEW_MODE, LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE,
] as const
-/** Standard process summaries for users without an explicit preference. */
-export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'standard'
+/** Default work details for non-Desktop Web clients. */
+export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'detailed'
/** Performance and usage detail levels accepted by user settings. */
export const PERFORMANCE_USAGE_MODES = ['compact', 'detailed'] as const
@@ -48,8 +48,8 @@ export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar'
/** Durable Chat section shared by the Host schema and browser scope. */
export interface ChatSettings {
- /** Work-details preference; legacy values are accepted only from existing saved settings. */
- transcriptView: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE
+ /** Work-details preference; absence uses the client default, and legacy saved values remain accepted. */
+ transcriptView?: TranscriptViewMode | typeof LEGACY_TRANSCRIPT_VIEW_MODE | typeof LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE | null
/** Detail level for composer statistics and completed-Turn usage. */
performanceUsage: PerformanceUsageMode
/** Default destination for Chat HTTP(S) links. */
@@ -60,8 +60,8 @@ export interface ChatSettings {
export const ChatSettingsFields = {
linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING),
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
- // Missing and unrecognized modes both use Standard.
- [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE).loose(),
+ // Missing and unrecognized modes defer to the client's default.
+ [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_SETTING_VALUES]).loose(),
}
/** Schema for shared configuration values. */
diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index 8c86d6e918..15752f6c70 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -1,4 +1,5 @@
/** Register the Chat Conversation target, renderers, stats, and details surface. */
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { Context } from '@deepseek-ai/cordis'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -36,7 +37,7 @@ import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/Tr
import { createChatStore } from './stores.ts'
import { TranscriptViewPolicy } from './transcript-view.ts'
import { derivePresentationPolicy } from './presentation-policy.ts'
-import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts'
+import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, DEFAULT_TRANSCRIPT_VIEW_MODE, type ChatSettings } from '../chat-settings.ts'
import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx'
import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx'
import { PerformanceUsagePolicy } from './performance-usage.ts'
@@ -145,7 +146,7 @@ export function apply(ctx: Context): void {
}),
}, LinkOpeningRow))
})
- const transcriptView = new TranscriptViewPolicy(chatSettings)
+ const transcriptView = new TranscriptViewPolicy(chatSettings, 'dshDesktop' in globalThis ? 'standard' : DEFAULT_TRANSCRIPT_VIEW_MODE)
const presentation = derivePresentationPolicy(transcriptView.mode)
const performancePolicy = new PerformanceUsagePolicy(chatSettings)
ctx.effect(() => () => { transcriptView.dispose(); performancePolicy.dispose() })
@@ -245,7 +246,11 @@ export function apply(ctx: Context): void {
read: () => chatScrollPositions.get(sessionId) ?? null,
},
forkAt: (seq) => {
- ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
+ const turn = [...chat.getSnapshot().timeline.turns.values()].find(turn => turn.end?.seq === seq)
+ const messageId = turn?.data.get('turn-tail')?.closing?.finalNode.messageId
+ ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true, onCreated: (childId) => {
+ ctx.get('productAnalytics')?.track('branch_session_click', { session_id: childId, parent_session_id: sessionId, ...messageId === undefined ? {} : { parent_message_id: messageId }, click_position: 'footer' })
+ } })
.then((childId) => { ctx.uiWorkspace.openSession(childId) })
.catch(() => {
// Fork or child-title failure leaves the source view unchanged.
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml
index 3017e3eaed..f274ffec4b 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml
+++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml
@@ -27,11 +27,14 @@
en: 8db4991626210708
zh: e18c8aa3cb434980
/chat-conversation-node-rules/display-modes:
- en: 42342a06ef189075
- zh: 1a554b50125fcdda
+ en: ada5a60d2c46f6a8
+ zh: f3eaeb5cb4d9b3de
+/chat-conversation-node-rules/display-modes/defaults-and-saving-preferences:
+ en: a47e16132ba3c736
+ zh: fbed2b6f43a93287
/chat-conversation-node-rules/display-modes/group-title-rules:
- en: a6d29a5ea3fd2362
- zh: dbd07bf82cdd61b1
+ en: 49fc1aa60cbd239e
+ zh: 44b0f9a96ec5b112
/chat-conversation-node-rules/process-grouping:
en: 276a97b95610f5e8
zh: 7627b2afb0a7c0f4
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md
index 12c63b5497..df66c91104 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/README.md
+++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md
@@ -108,7 +108,7 @@ Automatic collapse keeps the process open if hiding it would hide keyboard focus
## Display modes
-Settings → General → Work details offers `compact`, `standard` (default), `detailed`, and `verbose`; its description is “Choose how much detail to show for tool calls”. A saved `normal` reads as `standard`, and a saved `expanded` reads as `detailed`, without automatic write-back. Existing `detailed` remains `detailed`. Missing or invalid values, including the period before Host settings arrive, use `standard`; invalid values in other settings still fail validation.
+Settings → General → Work details offers four modes for choosing how much tool-call detail to show: Compact `compact`, Standard `standard`, Detailed `detailed`, and Verbose `verbose`.
| Behavior | Compact | Standard | Detailed | Verbose |
|---|---|---|---|---|
@@ -118,10 +118,20 @@ Settings → General → Work details offers `compact`, `standard` (default), `d
| Individual reasoning and tool bodies | Manual expansion | Manual expansion | Manual expansion | Manual expansion |
| Eligible completed Turn | Initially collapsed | Initially collapsed | Initially collapsed | Always open; duration/status header cannot collapse it |
-A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
+### Defaults and saving preferences
+
+Work details uses the Host setting `ui-chat.transcriptView`. The client reads it according to these rules:
+
+- Saved values for the four modes are used directly, regardless of changes to defaults.
+- Legacy `normal` and `expanded` values display as `detailed`, without automatically rewriting the saved values on disk.
+- When the setting is missing, `null`, or invalid, Desktop uses `standard` and non-Desktop Web uses `detailed`. Each client also uses its default before Host settings arrive; invalid values in other settings still fail validation.
+
+Reading a default does not automatically save configuration. When the user changes modes, the selected value is saved through Host settings; memory mode does not write to disk. Desktop onboarding writes preferences according to its rules when completed or skipped.
### Group-title rules
+A closed group's header names the first three categories from its ranked summary, without displaying counts. A group without categories uses the thinking label. A running header names its live tool category, otherwise thinking; Standard appends live detail. Live titles remain visible for at least 150ms, retaining only the newest pending title.
+
All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Standard mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation.
The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category.
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md
index 341c2826a1..2dec121a11 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md
+++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md
@@ -108,7 +108,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
## 展示模式
-设置 → 通用设置 → 工作步骤展示提供 `compact`、`standard`(默认)、`detailed` 和 `verbose`,说明为「选择希望看到多少工具调用细节」。旧值 `normal` 读取为 `standard`,`expanded` 读取为 `detailed`,不自动回写;已有 `detailed` 保持不变。未配置、无效值以及 Host 设置到达前均使用 `standard`;其他设置的无效值仍会校验失败。
+设置 → 通用设置 → 工作步骤展示提供简洁 `compact`、标准 `standard`、详细 `detailed` 和完全展开 `verbose` 四种模式,用于选择希望看到多少工具调用细节。
| 行为 | 简洁 | 标准 | 详细 | 完全展开 |
|---|---|---|---|---|
@@ -118,10 +118,20 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 单个推理与工具正文 | 手动展开 | 手动展开 | 手动展开 | 手动展开 |
| 符合条件的已完成轮次 | 初始收起 | 初始收起 | 初始收起 | 始终展开;时长或状态抬头不能收起它 |
-已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
+### 默认值与配置保存
+
+工作步骤展示对应 Host 设置 `ui-chat.transcriptView`,由客户端按以下规则读取:
+
+- 已保存的四种模式直接使用,不受默认值变化影响。
+- 旧值 `normal` 和 `expanded` 均按 `detailed` 展示,不自动改写磁盘中的值。
+- 未配置、值为 `null` 或无效时,Desktop 使用 `standard`,非 Desktop Web 使用 `detailed`。Host 设置到达前也使用各自的默认值;其他设置的无效值仍会校验失败。
+
+读取默认值不会自动保存配置。用户切换模式时,通过 Host 设置保存所选值;内存模式不写磁盘。Desktop onboarding 完成或跳过时,按引导规则写入偏好。
### 组标题规则
+已结束组的组头展示排序摘要的前三类,不显示次数;没有类别时使用思考标题。运行中的组头展示实时工具类别,没有运行工具时使用思考标题;标准模式附加实时详情。实时标题至少保留 150ms,只保留最新待显示标题。
+
三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在标准模式中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。
以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。
diff --git a/packages/client/ui-chat/src/client/transcript-view.ts b/packages/client/ui-chat/src/client/transcript-view.ts
index e7f6cc4fde..a9ed31f0e4 100644
--- a/packages/client/ui-chat/src/client/transcript-view.ts
+++ b/packages/client/ui-chat/src/client/transcript-view.ts
@@ -10,13 +10,18 @@ import {
/** Live work-details preference consumed by Chat and its Settings row. */
export class TranscriptViewPolicy {
private readonly unsubscribe: () => void
- /** Reactive current mode; defaults to Standard before Host settings arrive. */
- readonly mode: SnapshotStore = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE)
+ /** Reactive current mode, including the client default before Host settings arrive. */
+ readonly mode: SnapshotStore
/**
* @param host - durable Chat settings scope.
+ * @param defaultMode - presentation used without an explicit saved mode.
*/
- constructor(private readonly host: ConfigForm) {
+ constructor(
+ private readonly host: ConfigForm,
+ private readonly defaultMode: TranscriptViewMode = DEFAULT_TRANSCRIPT_VIEW_MODE,
+ ) {
+ this.mode = createSnapshotStore(defaultMode)
this.unsubscribe = host.subscribe(() => { this.adopt() })
this.adopt()
}
@@ -39,8 +44,8 @@ export class TranscriptViewPolicy {
const section = this.host.getSnapshot().value
if (section === undefined) return
const saved = section.transcriptView
- const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE ? 'standard'
- : saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE ? 'detailed' : saved
+ const mode = saved === LEGACY_TRANSCRIPT_VIEW_MODE || saved === LEGACY_EXPANDED_TRANSCRIPT_VIEW_MODE
+ ? 'detailed' : saved ?? this.defaultMode
if (this.mode.getSnapshot() !== mode) this.mode.set(mode)
}
}
diff --git a/packages/client/ui-chat/src/index.ts b/packages/client/ui-chat/src/index.ts
index b919f5786a..2380110127 100644
--- a/packages/client/ui-chat/src/index.ts
+++ b/packages/client/ui-chat/src/index.ts
@@ -2,7 +2,7 @@
import type {} from '@deepseek-ai/dsh-settings'
import type { Volatile, Context } from '@deepseek-ai/cordis'
-import type { LinkOpening, TranscriptViewMode, PerformanceUsageMode } from './chat-settings.ts'
+import type { ChatSettings, LinkOpening, PerformanceUsageMode } from './chat-settings.ts'
import z from '@deepseek-ai/schemastery'
import { TRANSCRIPT_VIEW_FIELD } from './chat-settings.ts'
@@ -17,7 +17,7 @@ export {
/** Runtime preferences projected to the browser. */
export interface Config {
/** Completed turn transcript presentation. */
- transcriptView: Volatile
+ transcriptView: Volatile
/** Performance and usage detail level. */
performanceUsage: Volatile
/** Default destination for Chat HTTP(S) links. */
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 04dc15407d..ad948ff2ff 100644
--- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
+++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx
@@ -1,3 +1,4 @@
+import { MessageId } from '@deepseek-ai/dsh-llm/brand'
// @vitest-environment jsdom
/** Chat inject factories exercised over independently mounted Conversation and Chat plugins. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -170,18 +171,36 @@ describe('Chat inject API', () => {
void injected.loadThrough(SessionSeq(42))
expect(b.session.loadThrough).toHaveBeenCalledWith(42)
+ const track = vi.fn()
+ b.runtime.ctx.provide('productAnalytics', { track } as never)
+ const forkCreated = vi.spyOn(b.runtime.sessions, 'fork').mockImplementation(async (input) => { input.onCreated?.(ROOT); return ROOT })
+ injected.forkAt(17)
+ expect(track).toHaveBeenCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, click_position: 'footer' })
+ await b.runtime.sessions.replaceEvents(ROOT, [
+ { type: 'event', event: { type: 'turn/start', seq: SessionSeq(10), time: 10, data: { turn: 1 } } },
+ { type: 'event', event: { type: 'step/start', seq: SessionSeq(11), time: 11, data: { turn: 1, step: 1 } } },
+ { type: 'event', event: { type: 'assistant/message', seq: SessionSeq(12), time: 12, surfaceOp: 'append', data: {
+ turn: 1, step: 1, stream: [], message: { id: MessageId('reply'), role: 'assistant', content: [{ type: 'text', text: 'answer' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } },
+ } } },
+ { type: 'event', event: { type: 'turn/end', seq: SessionSeq(17), time: 17, data: { turn: 1, reason: { kind: 'completed' } } } },
+ ])
+ injected.forkAt(17)
+ expect(track).toHaveBeenLastCalledWith('branch_session_click', { session_id: ROOT, parent_session_id: ROOT, parent_message_id: 'reply', click_position: 'footer' })
+ forkCreated.mockRestore()
injected.forkAt(17)
await vi.waitFor(() => {
expect(b.openSession).toHaveBeenCalledWith(ROOT)
})
expect(b.runtime.sessions.calls).toContainEqual({
- method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
+ method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void }],
})
const fork = vi.spyOn(b.runtime.sessions, 'fork').mockRejectedValueOnce(new Error('fork failed'))
injected.forkAt(18)
await vi.waitFor(() => {
- expect(fork).toHaveBeenCalledWith({ sessionId: ROOT, atSeq: 18, increaseTitle: true })
+ expect(fork).toHaveBeenCalledWith({
+ sessionId: ROOT, atSeq: 18, increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void,
+ })
})
await b.runtime.dispose()
})
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 d9fed8b140..98df8ebccc 100644
--- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import {
SlotTestRuntime, stubConfigForm, usePinnedBrowserLanguages,
@@ -133,22 +133,29 @@ describe('Chat apply wiring', () => {
await b.runtime.dispose()
})
- it('mirrors the Host transcript preference into its Settings row', async () => {
+ it.each([
+ { desktop: false, initial: 'detailed', choice: 'standard' },
+ { desktop: true, initial: 'standard', choice: 'detailed' },
+ ] as const)('mirrors the Host transcript preference into its Settings row (desktop: $desktop)', async ({ desktop, initial, choice }) => {
+ if (desktop) {
+ vi.stubGlobal('dshDesktop', {})
+ onTestFinished(() => { vi.unstubAllGlobals() })
+ }
const b = await bench()
+ onTestFinished(() => b.runtime.dispose())
const row = b.runtime.slots.entries('settings.general.item')
.find(entry => entry.options.id === 'transcript-view')!
const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
- expect(face.hooks.transcriptView.getSnapshot()).toBe('standard')
- face.setTranscriptView('detailed')
- expect(face.hooks.transcriptView.getSnapshot()).toBe('detailed')
- expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'detailed')
+ expect(face.hooks.transcriptView.getSnapshot()).toBe(initial)
+ face.setTranscriptView(choice)
+ expect(face.hooks.transcriptView.getSnapshot()).toBe(choice)
+ expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', choice)
b.chatSettings.publish({
status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true,
})
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
- await b.runtime.dispose()
})
it('shares the accepted performance preference with settings, composer, and turn tails', async () => {
diff --git a/packages/client/ui-chat/tests/config.host.spec.ts b/packages/client/ui-chat/tests/config.host.spec.ts
index e8591c98b1..a7fa8fa80a 100644
--- a/packages/client/ui-chat/tests/config.host.spec.ts
+++ b/packages/client/ui-chat/tests/config.host.spec.ts
@@ -4,7 +4,7 @@ import * as HostPlugin from '../src/index.ts'
import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts'
import { plainConfig } from '../../../settings/settings/src/schema.ts'
import {
- DEFAULT_TRANSCRIPT_VIEW_MODE, Config, apply,
+ Config, apply,
} from '../src/index.ts'
@@ -14,7 +14,7 @@ describe('ui-chat Host settings', () => {
const configuration = await liveConfig(ctx, { Config, apply })
const { fiber } = configuration
- expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' })
+ expect(plainConfig(configuration.fiber.config)).toEqual({ performanceUsage: 'detailed', linkOpening: 'sidebar' })
await configuration.update({ transcriptView: 'normal' })
expect(plainConfig(configuration.fiber.config)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' })
for (const mode of ['expanded', 'compact', 'standard', 'detailed', 'verbose']) {
@@ -23,7 +23,7 @@ describe('ui-chat Host settings', () => {
}
for (const mode of ['dense', '', 42, false, {}, null, undefined]) {
await configuration.update({ transcriptView: mode })
- expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: 'standard' })
+ expect(plainConfig(configuration.fiber.config)).toMatchObject({ transcriptView: mode === null ? null : undefined })
}
await configuration.update({ performanceUsage: 'compact' })
expect(plainConfig(configuration.fiber.config)).toMatchObject({ performanceUsage: 'compact' })
diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts
index 28a9212054..829dad2fe4 100644
--- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts
+++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts
@@ -5,7 +5,7 @@ import type { ChatSettings } from '../src/chat-settings.ts'
import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
describe('TranscriptViewPolicy', () => {
- it('defaults to Standard and publishes explicit choices before persistence settles', () => {
+ it('defaults to Detailed and publishes explicit choices before persistence settles', () => {
const host = stubConfigForm()
const observed: string[] = []
let current = (): string => 'unconstructed'
@@ -19,30 +19,49 @@ describe('TranscriptViewPolicy', () => {
const policy = new TranscriptViewPolicy(scope)
current = () => policy.mode.getSnapshot()
- expect(policy.mode.getSnapshot()).toBe('standard')
- policy.setMode('detailed')
expect(policy.mode.getSnapshot()).toBe('detailed')
- expect(observed).toEqual(['transcriptView=detailed:detailed'])
- expect(host.set).toHaveBeenCalledWith('transcriptView', 'detailed')
+ policy.setMode('standard')
+ expect(policy.mode.getSnapshot()).toBe('standard')
+ expect(observed).toEqual(['transcriptView=standard:standard'])
+ expect(host.set).toHaveBeenCalledWith('transcriptView', 'standard')
})
- it('adopts Host state, reads normal as Standard, and ignores identical writes', () => {
+ it.each(['standard', 'detailed'] as const)('uses %s for missing preferences without writing a default', (defaultMode) => {
const host = stubConfigForm()
- const policy = new TranscriptViewPolicy(host.scope)
-
- host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
- expect(policy.mode.getSnapshot()).toBe('standard')
- policy.setMode('standard')
+ const policy = new TranscriptViewPolicy(host.scope, defaultMode)
+ expect(policy.mode.getSnapshot()).toBe(defaultMode)
+ host.publish({ value: { linkOpening: 'sidebar', performanceUsage: 'detailed' } })
+ expect(policy.mode.getSnapshot()).toBe(defaultMode)
+ host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' } })
+ expect(policy.mode.getSnapshot()).toBe('compact')
+ host.publish({ value: { linkOpening: 'sidebar', transcriptView: null, performanceUsage: 'detailed' } })
+ expect(policy.mode.getSnapshot()).toBe(defaultMode)
expect(host.set).not.toHaveBeenCalled()
+ policy.dispose()
+ })
+
+ it.each(['normal', 'expanded'] as const)('reads a later Host %s setting as Detailed without writing it back', (mode) => {
+ const host = stubConfigForm()
+ const policy = new TranscriptViewPolicy(host.scope, 'standard')
+
+ host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true })
+ expect(policy.mode.getSnapshot()).toBe('detailed')
+ expect(host.set).not.toHaveBeenCalled()
+ policy.setMode('detailed')
+ expect(host.set).not.toHaveBeenCalled()
+
+ policy.setMode('standard')
+ expect(policy.mode.getSnapshot()).toBe('standard')
+ expect(host.set).toHaveBeenCalledExactlyOnceWith('transcriptView', 'standard')
host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 })
expect(policy.mode.getSnapshot()).toBe('compact')
})
- it('adopts an accepted section standing at construction', () => {
+ it.each(['normal', 'expanded'] as const)('reads an initial Host %s setting as Detailed without writing it back', (mode) => {
const host = stubConfigForm()
- host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'expanded', performanceUsage: 'detailed' }, revision: 1, writable: true })
- expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('detailed')
+ host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: mode, performanceUsage: 'detailed' }, revision: 1, writable: true })
+ expect(new TranscriptViewPolicy(host.scope, 'standard').mode.getSnapshot()).toBe('detailed')
expect(host.set).not.toHaveBeenCalled()
})
diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json
index a0f8a5047f..e03a2b0a90 100644
--- a/packages/client/ui-chat/tsconfig.client.json
+++ b/packages/client/ui-chat/tsconfig.client.json
@@ -113,6 +113,9 @@
},
{
"path": "../ui-input-trigger"
+ },
+ {
+ "path": "../product-analytics/tsconfig.client.json"
}
],
"exclude": [
diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json
index 58daf7f0bc..1470bb3b19 100644
--- a/packages/client/ui-commands/package.json
+++ b/packages/client/ui-commands/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-commands",
"description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 8b219abf38..8da175dc89 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -6,8 +6,8 @@
en: 0ca51f37788dcbdb
zh: 14d73c6bc8d4e17f
/deepseek-ai-dsh-client-ui-conversation:
- en: 71d52109076d859d
- zh: 8e63171fbd9bde48
+ en: 77ecbd074ef3a3ce
+ zh: 2c0e2b720e3b201e
/deepseek-ai-dsh-client-ui-conversation/summary:
en: bac0bf0d7330f3b1
zh: 84a895f4b26d8490
@@ -18,8 +18,8 @@
en: 13ae68865fa315d7
zh: c152f020abf20f35
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
- en: 8de4fbd9df016e06
- zh: 06b15ab7855120cb
+ en: 7f1d7a29d2238f3a
+ zh: f7a66d2f34a1cd42
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 7c7739fee9..99e42d3017 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -7,6 +7,8 @@ kind: "package-reference"
English | [中文](README.zh.md)
+Desktop product events use the optional [product analytics service](../product-analytics/README.md); ordinary Web interactions are excluded.
+
## Summary
`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller `SessionEventLikeEntry` feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers.
@@ -48,7 +50,7 @@ The composer measures its expanded control groups after size, content, visibilit
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
-With Developer tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Developer tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
+With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
@@ -74,6 +76,8 @@ The resident composer survives no-Session and Session transitions. Whitespace hi
Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. The Conversation plugin registers the fixed send, newline, complementary-delivery, command-menu, and reference-menu rows and reserves their bindings until it unloads.
+Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending.
+
Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id.
Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text.
@@ -84,7 +88,7 @@ File chips and editable skill references share a whole-reference hover backgroun
When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry.
-Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. The plugin registers Stop as a fixed action in the `application` display group. Its registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip.
+Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. The plugin registers Stop as a fixed action in the `input` display group. Its registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip.
## Temporary composer entries
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 88ac5cb99f..353d8be2db 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -7,6 +7,8 @@ kind: "package-reference"
[English](README.md) | 中文
+桌面端产品事件使用可选的[产品埋点服务](../product-analytics/README.zh.md),不包含普通 Web 交互。
+
## 概述
`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller 的 `SessionEventLikeEntry` feed,通过 `ctx.uiConversation` 暴露不依赖 React 的注册表与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立包,由各自包注册 Definition、快照 builder、View 和 renderer。
@@ -48,7 +50,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
-关闭开发者工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭开发者工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
+关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
@@ -74,6 +76,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。Conversation 插件注册发送、换行、互补投递、命令菜单和引用菜单的固定条目,并在卸载前保留这些键位。
+每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。
+
默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。
排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。
@@ -84,7 +88,7 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
-在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。插件将 Stop 注册为 `application` 展示分组中的固定操作。该注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。
+在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。插件将 Stop 注册为 `input` 展示分组中的固定操作。该注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。
## 临时 composer entry
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index 4eabebefdf..c30f69d0b9 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-client-ui-conversation",
"description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
- "version": "0.1.7-rc.1",
+ "version": "0.1.7-rc.2",
"publishConfig": {
"access": "public"
},
@@ -96,7 +96,8 @@
"@lexical/history": "^0.49.0",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
- "@deepseek-ai/dsh-client-shortcuts": "workspace:*"
+ "@deepseek-ai/dsh-client-shortcuts": "workspace:*",
+ "@deepseek-ai/dsh-client-product-analytics": "workspace:*"
},
"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 550515deda..0fc9b2560c 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -1,4 +1,5 @@
/** Registers the target-neutral Conversation assembly, shell, input, and docks. */
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -256,7 +257,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
), 'ui-conversation: fixed stop input')
scope.effect(() => {
const command: ShortcutFixedCommand = {
- id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'application',
+ id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input',
}
const dispose = scope.shortcuts.registerFixed(command)
stopShortcut.set(command.keys)
diff --git a/packages/client/ui-conversation/src/client/contract/composer-submission.ts b/packages/client/ui-conversation/src/client/contract/composer-submission.ts
index 23d9df94c1..2d9e2cc039 100644
--- a/packages/client/ui-conversation/src/client/contract/composer-submission.ts
+++ b/packages/client/ui-conversation/src/client/contract/composer-submission.ts
@@ -9,3 +9,19 @@ export type InputSubmitMode = BusyEnterBehavior
/** Keyboard gesture whose delivery mode the submission policy resolves. */
export type ComposerSubmitGesture = 'enter' | 'accelerated'
+
+/** Session facts captured when a message submission starts, before asynchronous command arbitration. */
+export interface MessageSubmissionState {
+ readonly sessionId?: import('@deepseek-ai/dsh-session/types').SessionId
+ readonly model?: { readonly provider: string; readonly name: string; readonly effort?: string }
+ readonly runMode: 'default' | 'plan' | 'goal'
+ readonly running: boolean
+}
+
+/** Immutable occurrence and delivery intent carried by one composer attempt. */
+export interface MessageSubmission {
+ readonly timestamp: number
+ readonly source?: 'click' | 'enter'
+ readonly mode: InputSubmitMode
+ readonly state?: MessageSubmissionState
+}
diff --git a/packages/client/ui-conversation/src/client/contract/draft-editor.ts b/packages/client/ui-conversation/src/client/contract/draft-editor.ts
index d5682c97a6..4aaae89bc6 100644
--- a/packages/client/ui-conversation/src/client/contract/draft-editor.ts
+++ b/packages/client/ui-conversation/src/client/contract/draft-editor.ts
@@ -40,7 +40,7 @@ export interface ComposerKeyboard {
/** The shell-owned Lexical editor the composer binds its contenteditable to. */
readonly editor: LexicalEditor
/** Submit with an explicit delivery mode resolved by the submission policy (Enter gestures and the primary Send button). */
- submit(mode: InputSubmitMode): void
+ submit(mode: InputSubmitMode, source?: 'click' | 'enter'): void
/**
* Steer every still-pending queued message into the running turn (the
* empty-draft accelerated-Enter gesture; the queue dock's per-row steer
diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts
index 4036736374..7b792d2760 100644
--- a/packages/client/ui-conversation/src/client/contract/input.ts
+++ b/packages/client/ui-conversation/src/client/contract/input.ts
@@ -11,7 +11,7 @@ import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ArbitrateKey, ArbitrateOutcome, Occurrence, ReferenceInsert, TokenSpan } from './draft-editor.ts'
-import type { InputSubmitMode } from './composer-submission.ts'
+import type { InputSubmitMode, MessageSubmission } from './composer-submission.ts'
/** Attachment payload passed to a claimed command submission. */
export type SubmitAttachment =
@@ -186,7 +186,7 @@ export interface SessionInput extends InputTarget {
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
*/
- submit(mode?: InputSubmitMode): void
+ submit(mode?: InputSubmitMode, source?: 'click' | 'enter'): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -283,6 +283,8 @@ export interface SubmitAttempt {
readonly draftSnapshot: string
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
+ /** Original message occurrence, retained across command arbitration. */
+ readonly submission?: MessageSubmission
}
/**
@@ -296,7 +298,7 @@ export type InputEvent =
/** The editor applied a claim-token replacement: enter claimed. */
| { readonly type: 'claim'; readonly claim: CommandClaim }
/** Enter submission with the current clipboard projection. */
- | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string }
+ | { readonly type: 'enter'; readonly mode: InputSubmitMode; readonly draft: string; readonly submission?: MessageSubmission }
| { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome }
| { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string }
/** Settlement carries the live clipboard projection for suffix-retention and claim re-entry decisions. */
diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts
index 89218a887c..f42b3a42be 100644
--- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts
+++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts
@@ -134,7 +134,7 @@ export function installDraftKeymap(
g.running,
accelerated ? 'accelerated' : 'enter',
g.steeringAvailable,
- ))
+ ), 'enter')
},
intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) },
pasteText: (text) => {
diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts
index 3cd956bf76..79aa620f34 100644
--- a/packages/client/ui-conversation/src/client/input/facade.ts
+++ b/packages/client/ui-conversation/src/client/input/facade.ts
@@ -22,7 +22,7 @@ import type {
import type {
ArbitrateKey, ArbitrateOutcome, ComposerKeyboard, Occurrence, ReferenceInsert, TokenSpan,
} from '../contract/draft-editor.ts'
-import type { InputSubmitMode } from '../contract/composer-submission.ts'
+import type { InputSubmitMode, MessageSubmission, MessageSubmissionState } from '../contract/composer-submission.ts'
import { SubmitMachine } from './machine.ts'
import { DraftEditorRuntime } from './editor/runtime.ts'
import type { EditorProjection } from './editor/projection.ts'
@@ -41,6 +41,10 @@ export interface PopupDismissFace {
export interface SessionInputDeps {
/** Session-scope ctx handed to claim.submit transactions. */
actx: Context
+ /** Snapshot the Session facts before asynchronous command arbitration. */
+ submissionState?: () => MessageSubmissionState
+ /** Notify one ordinary message attempt before reference serialization. */
+ messageSubmitted?: (submission: MessageSubmission) => void
/** Enter adjudication face resolver; absent/undefined answer = every '/' line falls to the default sink. */
inputTriggers?: (() => InputTriggerController | undefined) | undefined
/** PopupSelect shell face resolver (dismissal on submit lock / escape). */
@@ -290,7 +294,16 @@ export class SessionInputShell implements SessionInput {
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
*/
- submit(mode: InputSubmitMode = 'queue'): void {
+ submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void {
+ if (this.disposed) return
+ const timestamp = Date.now()
+ let state: MessageSubmissionState | undefined
+ if (this.snapshot.phase === 'plain' && (this.snapshot.draft.trim() !== '' || this.attachmentIds.length > 0)) {
+ try { state = this.deps.submissionState?.() } catch (_error) { /* Optional Session observations cannot interrupt submission. */ }
+ }
+ const submission: MessageSubmission = Object.freeze({
+ timestamp, mode, ...source === undefined ? {} : { source }, ...state === undefined ? {} : { state },
+ })
if (this.snapshot.draft.trim() === '' && this.attachmentIds.length > 0) {
if (this.snapshot.phase === 'plain') {
const attachmentIds = [...this.attachmentIds]
@@ -299,6 +312,7 @@ export class SessionInputShell implements SessionInput {
const flight = this.attachmentFlightSeq
this.attachmentFlights.set(flight, { controller, attachmentIds })
this.commitSend(attachmentIds)
+ this.notifySubmission(submission)
void this.deps.defaultSink('', attachmentIds, mode, controller.signal).then((outcome) => {
if (this.disposed || !this.attachmentFlights.delete(flight)) return
if (outcome.kind === 'success') return
@@ -321,7 +335,7 @@ export class SessionInputShell implements SessionInput {
this.notify('error', this.deps.commandAttachments.unsupportedNotice(before.claim?.token ?? before.draft))
return
}
- this.dispatchRun(({ type: 'enter', mode, draft: this.projection.clipboardText }))
+ this.dispatchRun({ type: 'enter', mode, draft: this.projection.clipboardText, submission })
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -572,7 +586,8 @@ export class SessionInputShell implements SessionInput {
/** Dispatch + execute, refreshing the claim decoration when the styled token flips. */
private dispatchRun(ev: Parameters[0]): void {
const beforeToken = this.activeClaimToken()
- this.run(this.core.dispatch(ev))
+ const effects = this.core.dispatch(ev)
+ this.run(effects)
if (this.activeClaimToken() !== beforeToken) this.draftEditor.refreshClaimDecoration()
}
@@ -633,6 +648,7 @@ export class SessionInputShell implements SessionInput {
draft: string,
mode: InputSubmitMode,
): void {
+ this.notifySubmission(attempt.submission)
const attachmentIds = [...this.attachmentIds]
this.attachmentIds = []
const occurrences = this.projection.occurrences
@@ -677,6 +693,11 @@ export class SessionInputShell implements SessionInput {
)
}
+ private notifySubmission(submission: MessageSubmission | undefined): void {
+ if (submission === undefined) return
+ try { this.deps.messageSubmitted?.(submission) } catch (_error) { /* Notification consumers cannot interrupt submission. */ }
+ }
+
/** Settle one detached default send independently of other sends. */
private settleSink(
attempt: SubmitAttempt,
diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts
index e062a8050f..8a908bb7b0 100644
--- a/packages/client/ui-conversation/src/client/input/hub.ts
+++ b/packages/client/ui-conversation/src/client/input/hub.ts
@@ -7,6 +7,10 @@
* listeners on each Session context and owns the default-sink choreography: every session is a
* real host entity, so the sink is one unconditional prompt path.
*/
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
+import type { ModelSelectionProjection } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { PlanProjection } from '@deepseek-ai/dsh-plan-mode/types'
+import type { GoalProjection } from '@deepseek-ai/dsh-goal/types'
import type { Context } from '@deepseek-ai/cordis'
import type {
ISessions, SessionBinding, SessionFace,
@@ -23,6 +27,7 @@ import type { ComposerKeyboard } from '../contract/draft-editor.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
import type { PopupDismissFace } from './facade.ts'
import { SessionInputShell } from './facade.ts'
+import { reportMessageSubmission } from './submission-analytics.ts'
/** Structural command face for per-session popup resolution. */
interface CommandFace {
@@ -90,6 +95,23 @@ export class InputHub implements SessionInputResolver {
const { session, ctx: actx } = binding
const shell = new SessionInputShell({
actx,
+ submissionState: () => {
+ const state = session.getSnapshot()
+ const model = session.projections.faceOf('modelSelection').getSnapshot() as ModelSelectionProjection | undefined
+ const plan = session.projections.faceOf('plan').getSnapshot() as PlanProjection | undefined
+ const goal = session.projections.faceOf('goal').getSnapshot() as GoalProjection | null | undefined
+ const selection = model?.next ?? model?.lastUsed
+ return Object.freeze({
+ ...state.blank ? {} : { sessionId: state.sessionId },
+ ...selection == null ? {} : { model: Object.freeze({
+ provider: selection.provider, name: selection.model,
+ ...selection.reasoningEffort === undefined ? {} : { effort: selection.reasoningEffort },
+ }) },
+ runMode: plan?.active ? 'plan' : goal?.goal.phase === 'active' ? 'goal' : 'default',
+ running: state.running,
+ })
+ },
+ messageSubmitted: (submission) => { reportMessageSubmission(this.rootCtx, submission) },
inputTriggers: () => this.controller(actx),
popup: () => this.popup(actx),
inbox: session.projections.faceOf('inbox') as ObservableSnapshot,
diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts
index 7d1e503777..910a0a66fe 100644
--- a/packages/client/ui-conversation/src/client/input/machine.ts
+++ b/packages/client/ui-conversation/src/client/input/machine.ts
@@ -7,7 +7,7 @@
* at Enter, so the editor can clear immediately and accept another message
* while earlier admissions remain in flight.
*/
-import type { InputSubmitMode } from '../contract/composer-submission.ts'
+import type { InputSubmitMode, MessageSubmission } from '../contract/composer-submission.ts'
import type { CommandClaim, InputEffect, InputEvent, InputState, SubmitAttempt } from '../contract/input.ts'
/** Exhaustiveness backstop for the closed InputEvent union. */
@@ -77,7 +77,7 @@ export class SubmitMachine {
switch (ev.type) {
case 'draft-changed': return this.onDraftChanged(ev.draft)
case 'claim': return this.onClaim(ev.claim)
- case 'enter': return this.onEnter(ev.mode, ev.draft)
+ case 'enter': return this.onEnter(ev.mode, ev.draft, ev.submission)
case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome)
case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message)
case 'submit-settled': return this.onSubmitSettled(ev)
@@ -106,28 +106,28 @@ export class SubmitMachine {
}
/** Mint an attempt and controller without assigning its lifecycle owner. */
- private mintAttempt(mode: InputSubmitMode, draft: string): {
+ private mintAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): {
readonly attempt: SubmitAttempt
readonly controller: AbortController
} {
const controller = new AbortController()
this.seq += 1
return {
- attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode },
+ attempt: { seq: this.seq, signal: controller.signal, draftSnapshot: draft, mode, ...submission === undefined ? {} : { submission } },
controller,
}
}
/** Mint the frozen command/adjudication attempt. */
- private beginAttempt(mode: InputSubmitMode, draft: string): SubmitAttempt {
- const flight = this.mintAttempt(mode, draft)
+ private beginAttempt(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
+ const flight = this.mintAttempt(mode, draft, submission)
this.inflight = flight
return flight.attempt
}
/** Mint an ordinary send that leaves the phase plain. */
- private beginDetached(mode: InputSubmitMode, draft: string): SubmitAttempt {
- const flight = this.mintAttempt(mode, draft)
+ private beginDetached(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): SubmitAttempt {
+ const flight = this.mintAttempt(mode, draft, submission)
this.detached.set(flight.attempt.seq, flight.controller)
this.claim = undefined
this.phase = 'plain'
@@ -142,21 +142,21 @@ export class SubmitMachine {
]
}
- private onEnter(mode: InputSubmitMode, draft: string): readonly InputEffect[] {
+ private onEnter(mode: InputSubmitMode, draft: string, submission?: MessageSubmission): readonly InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
- const attempt = this.beginAttempt(mode, draft)
+ const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'submitting'
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(draft, this.claim.token) }]
}
const trimmed = draft.trim()
if (trimmed === '') return []
if (trimmed.startsWith('/')) {
- const attempt = this.beginAttempt(mode, draft)
+ const attempt = this.beginAttempt(mode, draft, submission)
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft }]
}
- return this.detachedEffects(this.beginDetached(mode, draft))
+ return this.detachedEffects(this.beginDetached(mode, draft, submission))
}
private onAdjudicated(
diff --git a/packages/client/ui-conversation/src/client/input/submission-analytics.ts b/packages/client/ui-conversation/src/client/input/submission-analytics.ts
new file mode 100644
index 0000000000..a0f1a8e1c9
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/input/submission-analytics.ts
@@ -0,0 +1,21 @@
+/** Maps ordinary message occurrences to Desktop product events. */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
+import type { MessageSubmission } from '../contract/composer-submission.ts'
+
+/**
+ * Report the original message occurrence without reading newer Session facts.
+ * @param ctx - client context.
+ * @param submission - original occurrence and Session snapshot.
+ */
+export function reportMessageSubmission(ctx: Context, submission: MessageSubmission): void {
+ const { state, timestamp, mode } = submission
+ if (state === undefined) return
+ const model = state.model
+ ctx.get('productAnalytics')?.track('send_button_click', {
+ ...state.sessionId === undefined ? {} : { session_id: state.sessionId },
+ ...model === undefined ? {} : { model_name: `${model.provider}/${model.name}`, ...model.effort === undefined ? {} : { thinking_effort: model.effort } },
+ run_mode: state.runMode,
+ msg_type: state.running ? mode : 'default',
+ }, timestamp)
+}
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index 070d9e352f..88ae0697f7 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -9,7 +9,6 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
- 'shortcut.hint': '{label} {keys}',
'shortcut.newline': '换行',
'shortcut.complementary': '使用互补的 Queue/Steer 方式',
'shortcut.slash': '打开命令菜单',
@@ -380,7 +379,6 @@ export type ConversationKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
- 'shortcut.hint': '{label} {keys}',
'shortcut.newline': 'New line',
'shortcut.complementary': 'Use the complementary Queue / Steer action',
'shortcut.slash': 'Open command menu',
diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
index 9139aa9ac8..2361d96c99 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
@@ -311,7 +311,6 @@ export const InputBar = memo(function InputBar({
: running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
: t('input.send')
- const stopHint = stopKeys.length ? t('shortcut.hint', { label: t('input.stop'), keys: stopKeys.join(' ') }) : t('input.stop')
const onPrimary = (): void => {
if (primaryStops) {
stop?.()
@@ -319,7 +318,7 @@ export const InputBar = memo(function InputBar({
}
if (keyboard === undefined) return // absent machine: the button is disabled
/* v8 ignore next -- defensive: the primary button is disabled for empty, disabled, and pending-upload states. */
- if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode)
+ if (!empty && !disabled && !machineBusy && !uploadsPending) keyboard.submit(primarySubmitMode, 'click')
}
// Claim ghost hint: rendered by CSS as generated content after the last
@@ -459,7 +458,7 @@ export const InputBar = memo(function InputBar({
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}