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/archived/architecture/2026-09-24-web-default-schedule-composition.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
new file mode 100644
index 0000000000..3f27bb09eb
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.md
@@ -0,0 +1,40 @@
+# Agent Note: Schedule in the shipped Web composition
+
+Status: implemented
+Archived: 2026-09-25
+
+English | [中文](2026-09-24-web-default-schedule-composition.zh.md)
+
+## Problem
+
+The Automation tasks page, the Session reminder catalog, and the `schedule_*` tools reached a Web deployment only through `--patch apps/cli/config/examples/schedule/cordis.yml`. `packages/bundle/web-app/cordis.patch.yml` carried the `ui-schedule` client row with `disabled: true`, and the Host rows `time-context` and `schedule` existed in that overlay alone. A person running the shipped `web` profile therefore saw no Automation tasks entry and no reminder tools, while every consumer that wanted them repeated the same three rows: the repository preview image kept its own overlay list, and two Web suites hard-coded the overlay path.
+
+## Decision
+
+`packages/bundle/web-app/cordis.patch.yml` inserts `time-context` and `schedule` in its Host row list and leaves `ui-schedule` enabled; `packages/bundle/web-app/package.json` declares both packages, which `verify-cordis-config` requires for a bare row name in a bundle patch. The `web` profile consequently ships the Automation tasks page, the Session-header reminder clock, the idle Session row's clock mark and hover list, the right-Sidebar task tab, and `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` on every live root Agent.
+
+`apps/cli/config/examples/schedule/cordis.yml` is deleted. `applyEntryPatches` appends an `insert` list without de-duplicating ids, so keeping the overlay would mount `time-context` and `schedule` a second time; the two Web suites and the preview packer that named the overlay compose the shipped profile alone.
+
+`time-context` ships with Schedule because a reminder request states a wall-clock target. The plugin appends one durable user message per eligible step carrying the sampled instant, the browser zone attached to the open request, and the elapsed time since the preceding model-visible message. The sampling 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 that interpretation boundary and the explicit-zone requirement it feeds.
+
+The Host service, its storage domain, and the client half are unchanged. This decision moves which composition mounts them, and the [Host-owned scheduled messages decision](2026-09-16-host-schedule-storage.md) continues to own task storage, activation, dispatch, and delivery records.
+
+## Recorded sessions
+
+A Web scenario that drives a live step now logs one time-context reading per step, so its committed Session fixture and header sidecars were refreshed. A reading's sampled instant, browser zone, and elapsed duration are volatile, and `normalizeWebSessionVolatiles` in `apps/web/tests/scaffold.ts` replaces them with `{{timeContextTimestamp}}`, `{{clientTimeZone}}`, and `{{elapsed}}`. The turn and step numbers and the preceding-event baseline stay readable, so a fixture still shows what the model received. Refresh writes the current writer generation (`session.v4.jsonl`) beside the retained predecessors, which remain committed replay baselines.
+
+## Alternatives considered
+
+**Keep the overlay and change nothing.** The shipped surface would keep contradicting the product documentation that describes the Automation tasks entry, and each consumer would keep its own copy of the three rows. The overlay also cannot satisfy the request that the default `dsh web` process exposes the page without a flag.
+
+**Ship the Schedule rows without `time-context`.** The model would then have no sampling instant of its own, so an unqualified date or time in a reminder request would depend on a separate clock source; the overlay paired the two rows for that reason.
+
+**Reach for a separate optional bundle.** `OPTIONAL_BUNDLES` exists for bundles a person switches on from the plugin manager, which is the same opt-in shape as the overlay; the decision here is that a default Web surface owns the page and the tools.
+
+## Consequences
+
+- Every Web session's request header carries four additional tool schemas, and every eligible step appends one durable user message. A conversation that never creates a reminder pays that token cost.
+- The clock reading is model-visible and durable, so it replays, compacts, and appears in exported Session logs like any other user message.
+- A deployment that wants the previous behavior disables the `time-context`, `schedule`, and `ui-schedule` rows in its own profile patch layer; the capability itself is untouched.
+- The `cordis_inspect_query` `listTools` answer for a full preset table now passes the base composition's 12,500-token inline budget, so the spill policy retains that answer's head and tail with a spill path instead of the complete JSON.
+- The repository preview image and both Web suites drop their overlay arguments, so one composition change reaches every surface at once.
diff --git a/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
new file mode 100644
index 0000000000..9e0e1fb77c
--- /dev/null
+++ b/.agents/notes/archived/architecture/2026-09-24-web-default-schedule-composition.zh.md
@@ -0,0 +1,40 @@
+# Agent Note: 默认 Web 组合中的 Schedule
+
+Status: implemented
+Archived: 2026-09-25
+
+[English](2026-09-24-web-default-schedule-composition.md) | 中文
+
+## Problem
+
+自动化任务页面、Session 提醒目录以及 `schedule_*` 工具此前只能通过 `--patch apps/cli/config/examples/schedule/cordis.yml` 进入某个 Web 部署:`packages/bundle/web-app/cordis.patch.yml` 里的 `ui-schedule` 客户端行带 `disabled: true`,而宿主行 `time-context` 与 `schedule` 只存在于那个 overlay 中。因此运行发布版 `web` profile 的人既看不到自动化任务入口,也拿不到提醒工具;同时每个需要它们的消费者都要重复同样的三行:仓库预览镜像自行维护了一份 overlay 列表,两个 Web 测试套件各自硬编码了 overlay 路径。
+
+## Decision
+
+`packages/bundle/web-app/cordis.patch.yml` 在其宿主行列表中插入 `time-context` 与 `schedule`,并让 `ui-schedule` 保持启用;`packages/bundle/web-app/package.json` 声明这两个包,这是 `verify-cordis-config` 对 bundle patch 中裸包名的要求。因此 `web` profile 会交付自动化任务页面、Session 页头的提醒时钟、空闲 Session 行的时钟标记与悬停列表、右侧栏任务页签,并在每个 live 根 Agent 上注册 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`。
+
+`apps/cli/config/examples/schedule/cordis.yml` 已删除。`applyEntryPatches` 追加 `insert` 列表时不对 id 去重,保留该 overlay 会把 `time-context` 与 `schedule` 挂载两次;原本引用它的两个 Web 测试套件与预览打包器现在只组合发布版 profile。
+
+`time-context` 随 Schedule 一起发布,因为提醒请求本身给出的是墙上时钟目标。该插件在每个符合条件的步骤追加一条持久 user 消息,包含采样瞬时、附加到当前开放请求的浏览器时区,以及自前一条模型可见消息以来的经过时长。正是采样瞬时让模型能把「明天九点」这类请求落成带偏移量的 `at` 值;该解释边界及其要求的显式时区由 [Schedule 子系统](../../../../docs/subsystems/schedule.zh.md)负责。
+
+宿主服务、其存储 domain 与客户端半边都没有改动。本决策改变的是由哪个组合挂载它们,任务存储、激活、投递与投递记录仍由[宿主定时消息决策](2026-09-16-host-schedule-storage.zh.md)负责。
+
+## Recorded sessions
+
+驱动 live 步骤的 Web 场景现在每步记录一条 time-context 读数,因此其已提交的 Session 夹具与页头 sidecar 都已重刷。一条读数中的采样瞬时、浏览器时区与经过时长是易变值,`apps/web/tests/scaffold.ts` 的 `normalizeWebSessionVolatiles` 会把它们替换为 `{{timeContextTimestamp}}`、`{{clientTimeZone}}` 和 `{{elapsed}}`。turn 与 step 序号以及"前一条事件"的基线保持可读,因此夹具仍能显示模型收到的内容。重刷会在已保留的上一代旁写入当前 writer 代次(`session.v4.jsonl`),旧代次继续作为已提交的回放基线保留。
+
+## Alternatives considered
+
+**保留 overlay,不做任何改动。** 发布版界面会继续与描述该自动化任务入口的产品文档相矛盾,而每个消费者都要各自保留那三行的副本。overlay 也无法满足「默认 `dsh web` 进程不带 flag 就暴露该页面」这一要求。
+
+**只发布 Schedule 两行,不带 `time-context`。** 模型将没有自己的采样瞬时,于是提醒请求中未明确限定时区的日期或时间要依赖另一个时钟来源;overlay 当初正是为此把两行配对。
+
+**另建一个可选 bundle。** `OPTIONAL_BUNDLES` 面向的是用户在插件管理器里主动开启的 bundle,与 overlay 是同一种 opt-in 形态;本决策要的是默认 Web 界面自己拥有该页面与这些工具。
+
+## Consequences
+
+- 每个 Web 会话的请求头多出四个工具 schema,且每个符合条件的步骤追加一条持久 user 消息。从不创建提醒的对话也要承担这份 token 成本。
+- 时钟读数对模型可见且持久,因此它与其他 user 消息一样参与回放、压缩,并出现在导出的 Session 日志中。
+- 想要恢复旧行为的部署,可在自己的 profile patch 层禁用 `time-context`、`schedule` 与 `ui-schedule` 三行;能力本身没有改动。
+- 完整预设表的 `cordis_inspect_query` `listTools` 答案现在超过基础组合 12,500 token 的内联预算,因此 spill 策略改为保留该答案的首尾并给出 spill 路径,而不是完整 JSON。
+- 仓库预览镜像与两个 Web 测试套件都不再传 overlay 参数,一处组合改动即同时作用于所有界面。
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-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
index 90546ac5db..bd2626e850 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml
@@ -21,11 +21,11 @@
en: e550ae9892474276
zh: fb6a2d9e91ebd4d9
/agent-note-session-observations-and-projection-owned-client-state/decision/projection-execution-boundary:
- en: b463e1854d2cc694
- zh: 84fb04c9ddeb2656
+ en: 148de86941b90f41
+ zh: c065a8e96e28b9d7
/agent-note-session-observations-and-projection-owned-client-state/decision/read-audiences:
- en: c94cd4f6ed488c24
- zh: 12f3b223fdd72c4a
+ en: 888f62659f4d868d
+ zh: e74491e8c85046ee
/agent-note-session-observations-and-projection-owned-client-state/decision/replayable-client-facts-are-projection-owned:
en: efded09f0cd30a8e
zh: 5e16580a39ff3359
@@ -33,8 +33,8 @@
en: fb8740be674e4708
zh: 7dc0c89558f51792
/agent-note-session-observations-and-projection-owned-client-state/decision/domain-applications:
- en: c45a0429091f1cf4
- zh: a76c7fe66e22e410
+ en: 4dcae9e00148b11e
+ zh: acfd1b6831cf50fe
/agent-note-session-observations-and-projection-owned-client-state/decision/failure-and-readiness-boundaries:
en: 0f90a8bccedf9e71
zh: 8452dfdf0af00fb3
@@ -45,11 +45,11 @@
en: 110ca8005f618ad1
zh: 8f3e83d59334b278
/agent-note-session-observations-and-projection-owned-client-state/decision/relationship-to-existing-decisions:
- en: a794043297325db2
- zh: 6459213d8dc35b1b
+ en: 8d699dfc22ab5c5f
+ zh: 8dcd9fc6bd1594db
/agent-note-session-observations-and-projection-owned-client-state/verification:
- en: e1458c504be960a4
- zh: 12c565a2576450cb
+ en: df17217023668428
+ zh: 88f7da4deffc6bf9
/agent-note-session-observations-and-projection-owned-client-state/alternatives-considered:
en: a25138b4298cda16
zh: 92799a9bd8867d47
diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
index 8573ea89fa..523122c9ae 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
+++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md
@@ -25,8 +25,7 @@ flowchart LR
List["list / search"] --> Corpus["SessionQuery corpus"]
Follow["follow"] --> Observe["observeSession"]
Page["page / attachment / fork"] --> Observe
- Subagent["subagent list / continuation"] --> Corpus
- Subagent --> Observe
+ Subagent["subagent list / continuation"] --> Observe
Corpus --> Cache["projection cache hints"]
Cache --> ClientList["Client Session list"]
Cache -->|"small miss"| Observe
@@ -71,7 +70,7 @@ The registry owns fold state; each domain owns its `init`, `apply`, `view`, sche
`view` remains an uncached synchronous conversion over folded state. Its cost is bounded by the registered projection units and is paid at snapshot publication; introducing a second cache would add invalidation states without reducing event replay.
-Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list and subagent list first use live projection state or durable projection-cache rows. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints.
+Corpus listing remains a separate lightweight operation. `listSessions()` returns live-preferred headers without materializing every log. Session list first uses live projection state or durable projection-cache rows. Subagent discovery reads parent-catalog projections through exact observations. Session list may take one complete observation for an individually stored artifact within its configured small-log limit when cached metadata cannot establish whether it is blank; a large or unreadable cache miss remains visible with unknown hints.
`session.follow` publishes a required opening snapshot containing header, cursor, the initial event window, and a complete projection baseline. Reconnect replaces the previous generation from another complete snapshot. `session.page` is reserved for older-history reads and gap repair. Observation-only reads never activate an Agent; only an ordinary follow may retain its prepared observation and request promotion after the opening snapshot has been delivered.
@@ -86,7 +85,7 @@ Each public operation chooses one query and projection policy. The choice is par
| `session.follow` | One exact observation | All, carried in the opening snapshot | Ordinary cold Session only, after snapshot delivery |
| `session.page` | One exact observation | None, except projection-backed subagent authorization | Never |
| Attachment and fork source | One exact observation | None unless authorization requires it | Never for the source |
-| Subagent list and continuation | Corpus plus live/cache/observation resolution | All on a cold fallback; audience consumes identity or inherited values | Never for listing; continuation follows its explicit command semantics |
+| Subagent list and continuation | Parent-catalog observations for listing; target observations for continuation | All; listing consumes catalog values, continuation consumes identity and inherited values | Never for listing; continuation follows its explicit command semantics |
### Replayable Client facts are projection-owned
@@ -123,7 +122,7 @@ Client-local interaction state also remains local: loading and error status, an
- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection.
- **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header.
- **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session.
-- **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself.
+- **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter for continuation and history authorization. Listing derives membership, mode, and label from the `subagentCatalog` projection through parent observations; descendant discovery recursively reads reachable catalogs ([parent-catalog decision](2026-09-01-parent-owned-subagent-catalog.md)).
- **Subagent presentation.** Opening projection values establish timing and identity before the Client declares the child interactive or offline, so transport loading does not masquerade as a durable state.
These migrations remove special-case Client state without making projection own provider catalogs or interaction mechanics. A domain still owns mutations and commands; projection owns only their replayable Session result.
@@ -167,14 +166,14 @@ These rules apply to new Session-derived Client state even when a direct event s
- [Reusable Session preparation](../../archived/architecture/2026-08-05-session-preparation.md) owns cold materialization, repair, reservation, and publication. Observation adds a shared read lease over that prepared object; it does not move preparation into SessionQuery.
- [Session history and Remote event transport](2026-08-18-session-history-and-event-transport.md) owns stream generations and replacement semantics. This decision supplies the exact snapshot that opens each journal generation.
- [Projection state and Client views](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md) owns the distinction between Host fold state and Client values. This decision governs where those values are consumed and how partial list hints differ from a complete baseline.
-- [Subagent identity projection](../../archived/architecture/2026-08-06-subagent-list-identity-projection.md) continues to own descriptor folding, the serializable `null` sentinel, and the own-suffix sequence check. This decision supersedes only its independent corpus merge and direct cold-inspection path: listing now uses SessionQuery's corpus and observation.
+- [The parent-catalog decision](2026-09-01-parent-owned-subagent-catalog.md) owns subagent discovery. Descriptor folding, the serializable `null` sentinel, and the own-suffix sequence check remain relevant to continuation and history authorization, not catalog identity.
- The broader [session projection and command-log proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) remains proposed for the portions not represented by shipped code. This decision records the shipped observation and Client-ownership subset.
## Verification
Persistence and SessionQuery tests pin shared cold loading, cancellation, live-source races, retained observations, disposal, and all-or-none projection calculation. Session Controller and Gateway tests pin snapshot-first opening, replacement reconnect, older-page reads, gap repair, list-cache hints, bounded small-log fallback, and promotion after snapshot delivery.
-Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin corpus enumeration, cache and observation fallback, lifecycle witnesses, bounded cold reads, and no Agent activation during listing.
+Client tests pin higher-sequence-wins projection storage, title updates, model catalog and selection readiness, preset roster refresh and Session-specific selection, and subagent loading without transient offline presentation. Subagent tests pin parent-catalog order, recursive traversal, branch diagnostics, cancellation, fork isolation, and no Agent activation during listing.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
index 55c477e994..6639047731 100644
--- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md
@@ -25,8 +25,7 @@ flowchart LR
List["list / search"] --> Corpus["SessionQuery corpus"]
Follow["follow"] --> Observe["observeSession"]
Page["page / attachment / fork"] --> Observe
- Subagent["subagent list / continuation"] --> Corpus
- Subagent --> Observe
+ Subagent["subagent list / continuation"] --> Observe
Corpus --> Cache["projection cache hints"]
Cache --> ClientList["Client Session list"]
Cache -->|"small miss"| Observe
@@ -71,7 +70,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view`
`view` 保持为 folded state 上无 cache 的同步转换。其成本由已注册 projection unit 数量界定,并在 snapshot 发布时支付;引入第二层 cache 只会增加 invalidation 状态,无法减少 event replay。
-语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 与 subagent list 先读取 live projection 状态或持久 projection-cache row。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。
+语料库 list 仍是独立的轻量操作。`listSessions()` 返回 live-preferred header,而不物化每份日志。Session list 先读取 live projection 状态或持久 projection-cache row。Subagent 发现通过精确 observation 读取父目录 projection。当 cache 无法判断 Session 是否为空,且该 Session 拥有的独立产物未超过配置的小日志限制时,Session list 可以执行一次完整 observation;大型或不可读的 cache miss 仍以 hints 未知但 row 可见的方式返回。
`session.follow` 发布必需的 opening snapshot,其中包含 header、cursor、首个事件窗口和完整 projection baseline。重连使用另一份完整 snapshot 替换上一 generation。`session.page` 仅用于旧历史读取与 gap repair。只读 observation 不激活 Agent;只有普通 follow 可以保留 prepared observation,并在 opening snapshot 已交付后请求 promotion。
@@ -86,7 +85,7 @@ Registry 拥有 fold state;各领域拥有自己的 `init`、`apply`、`view`
| `session.follow` | 一份精确 observation | 全算,并由 opening snapshot 携带 | 仅普通 cold Session,且在 snapshot 交付后 |
| `session.page` | 一份精确 observation | 不计算,但 projection-backed subagent 鉴权除外 | 从不 |
| Attachment 与 fork source | 一份精确 observation | 鉴权不要求时不计算 | source 从不激活 |
-| Subagent list 与 continuation | Corpus 加 live/cache/observation 解析 | cold fallback 全算;audience 只消费 identity 或继承值 | Listing 从不;continuation 遵循显式命令语义 |
+| Subagent list 与 continuation | Listing 观察父目录;continuation 观察目标 | 全算;listing 消费目录值,continuation 消费身份与继承值 | Listing 从不;continuation 遵循显式命令语义 |
### 可回放的 Client 事实归 projection 所有
@@ -123,7 +122,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打
- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。
- **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。
- **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。
-- **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。
+- **Subagent identity。** `subagent` unit 仍是继续执行与历史授权的唯一 descriptor interpreter。Listing 通过父级 observation 从 `subagentCatalog` projection 派生成员关系、模式与标签;后代发现递归读取可达目录([父目录决策](2026-09-01-parent-owned-subagent-catalog.zh.md))。
- **Subagent presentation。** Opening projection value 在 Client 宣布 child 可交互或离线前建立 timing 与 identity,因此 transport loading 不会伪装成 durable state。
这些迁移删除特殊 Client state,但不会让 projection 接管 provider catalog 或交互机制。领域仍拥有 mutation 和 command;projection 只拥有其可回放 Session 结果。
@@ -167,14 +166,14 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打
- [可复用 Session preparation](../../archived/architecture/2026-08-05-session-preparation.md)拥有冷物化、修复、reservation 和发布。Observation 在该 prepared object 之上增加共享读取 lease,并未把 preparation 移入 SessionQuery。
- [Session 历史与 Remote event transport](2026-08-18-session-history-and-event-transport.zh.md)拥有 stream generation 与 replacement 语义。本决策提供每个日志 generation 的精确 opening snapshot。
- [Projection state 与 Client view](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)拥有 Host fold state 和 Client value 的区分。本决策规定这些值在哪里消费,以及部分 list hints 与完整 baseline 的差别。
-- [Subagent identity projection](../../archived/architecture/2026-08-06-subagent-list-identity-projection.md)继续拥有 descriptor folding、可序列化 `null` sentinel 和 own-suffix sequence 检查。本决策只取代其中独立 corpus merge 和直接 cold inspection 路径:listing 改为使用 SessionQuery corpus 和 observation。
+- [父目录决策](2026-09-01-parent-owned-subagent-catalog.zh.md)拥有 subagent 发现机制。Descriptor folding、可序列化 `null` sentinel 和 own-suffix sequence 检查仍用于继续执行与历史授权,不用于目录身份。
- 更广泛的 [session projection 与 command-log 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)仍为 proposed,其中尚未由已交付代码体现的部分不受影响。本决策记录已经交付的 observation 与 Client ownership 子集。
## 验证
Persistence 与 SessionQuery 测试固定共享冷加载、取消、live-source race、retained observation、dispose 和 all-or-none projection 计算。Session Controller 与 Gateway 测试固定 snapshot-first opening、replacement reconnect、旧分页读取、gap repair、list-cache hints、小日志有界 fallback,以及 snapshot 交付后的 promotion。
-Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定 corpus 枚举、cache 与 observation fallback、lifecycle witness、有界冷读,以及 listing 期间不激活 Agent。
+Client 测试固定 higher-sequence-wins projection store、title 更新、model catalog 与 selection readiness、preset roster refresh 与 Session 专属选择,以及不会短暂展示离线状态的 subagent loading。Subagent 测试固定父目录顺序、递归遍历、分支诊断、取消、fork 隔离,以及 listing 期间不激活 Agent。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
index 895e659b3a..dbb198ca69 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml
@@ -15,5 +15,5 @@
en: 7a3d4b9f69c243d8
zh: 82b03d16ccc94dd2
/agent-note-parent-owned-subagent-catalog-events/consequences:
- en: 97f2a8beb039a075
- zh: 80efa9f6dd12d438
+ en: d582273d5102d617
+ zh: 7f05590581b1df88
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
index 536ec33148..27582937d8 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md
@@ -44,6 +44,6 @@ Current-writer snapshot expectations include catalog facts even when replay inpu
## Consequences
-Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing uses the Session corpus and child identity projection. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
+Session observations and client snapshots expose the direct-child list through `projections.values.subagentCatalog`. The projection change feed publishes a complete list when catalog state changes. Each view costs O(D), so D creations can incur O(D²) cumulative view work; this follows the existing projection mechanism. `listChildren()` reads the parent projection through one live-preferred Session observation; descendant listing recursively calls the same reader in catalog order. Reachable catalog facts supply identity and membership without a global Session scan or a separate child-identity cache path. Unreadable branches remain diagnostics; uncataloged Sessions are outside discovery. [Web projection consumption](../simplification/2026-09-08-web-subagent-catalog-projections.md) owns browser loading and synchronization.
[Creation metadata exclusion](../simplification/2026-09-16-subagent-catalog-membership-only.md) records why model configuration is absent from catalog facts.
diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
index ec78ff9e4b..982dab234e 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md
@@ -44,6 +44,6 @@ snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clo
## 后果
-Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举使用 Session 语料库与子身份 projection。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
+Session observation 与 client snapshot 通过 `projections.values.subagentCatalog` 暴露直接子级列表。目录状态变化时,projection change feed 发布完整列表。每次 view 的成本为 O(D),因此 D 次创建可能累计产生 O(D²) 的 view 工作量;这沿用现有 projection 机制。`listChildren()` 通过一次 live-preferred Session observation 读取父 projection;后代枚举按目录顺序递归调用同一读取函数。可达目录事实提供身份和成员关系,无需全局 Session 扫描或独立的子身份缓存路径。不可读分支保留为诊断;未进入目录的 Session 不在发现范围内。[Web projection 消费决策](../simplification/2026-09-08-web-subagent-catalog-projections.zh.md) 说明浏览器加载与同步。
[创建元数据排除决策](../simplification/2026-09-16-subagent-catalog-membership-only.zh.md) 说明目录事实为何不包含模型配置。
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..c92c021186 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
@@ -9,11 +9,11 @@
en: f9721b730c7e70c7
zh: 367e7839912cb726
/agent-note-default-deepseek-session-log-upload/decision:
- en: ec3b05fb70ae490f
- zh: 59b0691e327855b4
+ en: b72ef475a41b1a23
+ zh: b17a68367679730b
/agent-note-default-deepseek-session-log-upload/alternatives-considered:
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..42327124fd 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
@@ -9,7 +9,9 @@ Ordinary DeepSeek requests do not contain the complete canonical Session traject
## Decision
-`session-log-deepseek.Config.enabled` defaults to `true` in every process. An explicit `enabled: false` disables the contribution. The plugin does not inspect test-runner or snapshot environment variables.
+`session-log-deepseek.Config.enabled` defaults to `true` in every process. A resolved `enabled: false` disables the contribution; configuration precedence determines which value takes effect. The plugin does not inspect test-runner or snapshot environment variables.
+
+The `enabled` value is read for each request through a volatile reference so a saved preference takes effect without restarting the plugin. Profile writes can override bundle defaults under the [profile-owned configuration rules](2026-09-19-profile-owned-live-configuration.md); home patches and command-line overlays still reject conflicting writes. Disabling upload leaves the acceptance watermark unchanged because it records provider acceptance, not user eligibility. Re-enabling therefore sends the unaccepted suffix, including events recorded while disabled. Already prepared requests retain their payload.
This supersedes only the opt-in default in the [request-extension decision](2026-08-21-deepseek-llm-api-request-extensions.md); that note still owns field serialization, destinations, acceptance, and retry semantics. The headless and ACP corpus base patches and Web scaffold explicitly disable upload. Later scenario patches can enable it. The SDK text-turn recording omits the setting and exercises the shipped default, including durable acceptance events.
@@ -23,6 +25,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..74bce2350d 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
@@ -9,7 +9,9 @@ Status: implemented
## 决策
-`session-log-deepseek.Config.enabled` 在所有进程中均默认为 `true`。显式设置 `enabled: false` 可关闭贡献。插件不读取测试运行器或快照环境变量。
+`session-log-deepseek.Config.enabled` 在所有进程中均默认为 `true`。最终生效的 `enabled: false` 会关闭贡献;配置优先级决定采用哪个值。插件不读取测试运行器或快照环境变量。
+
+每次请求通过 volatile 引用读取 `enabled`,因此保存的偏好无需重启插件即可生效。按照 [profile 所有的配置规则](2026-09-19-profile-owned-live-configuration.zh.md),profile 写入可覆盖组合包默认值;home patch 和命令行 overlay 仍会拒绝冲突写入。关闭上传不改变接受水位,因为它记录提供方的接受结果,而不是用户是否允许上传。重新开启后会发送尚未接受的后缀,包括关闭期间记录的事件。已准备的请求保留原有载荷。
本决策仅取代[请求扩展决策](2026-08-21-deepseek-llm-api-request-extensions.zh.md)中的主动启用默认策略;该记录仍负责字段序列化、目的地址、接受与重试语义。headless 和 ACP 语料的基础 patch 以及 Web scaffold 显式关闭上传。后续场景 patch 可以启用上传。SDK text-turn 录制省略该设置,验证产品默认值及持久接受事件。
@@ -23,6 +25,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-15-client-session-references.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
index f664219cd9..d2b3a75106 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.i18n.yaml
@@ -33,8 +33,8 @@
en: c46c9ca54ea9eaad
zh: 918dc8eebff5edf3
/agent-note-client-session-references-reference-sources-and-ui-status/decision/presets-and-creation-flows:
- en: 790eda215b64ee37
- zh: a4b5d3a61cc80cbc
+ en: 70ef26a3cd399b14
+ zh: 5c81515ff896f91a
/agent-note-client-session-references-reference-sources-and-ui-status/decision/host-event-context-ownership-and-typert:
en: eb3be89cf9431c44
zh: bfc26899286b37ad
diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
index 162ef08875..ca4055bd9e 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.md
@@ -166,7 +166,7 @@ Workspace connection preserves its navigation guards and panel-switch invalidati
Preset directories and deployment defaults may be shared. A bound Session's preset is read or changed through its Provider binding; preset controllers are cached by `SessionBinding` rather than managed by one root current-Session follower. The hero preset seat uses the `session-maybe` Provider: it shows the creation-flow choice without a Session and operates on the exact bound blank Session after one arrives. Header labels read the same Provider-bound Session projection.
-A preset chosen before Session creation remains in the main Conversation's `session-maybe` preset surface. After Workspace creation or reuse establishes the main Provider over a blank Session, that surface applies the choice to its Provider-bound Session. When Settings changes the default preset or picker setting, the preset service selects the blank Session whose established Provider binding carries `mainView` ownership and updates that Session. Non-blank main Sessions, independent Sidebar Providers, and other background references remain unchanged; preset subtrees do not read the main reference or use a global current follower to find their target.
+A preset chosen before Session creation remains in the main Conversation's `session-maybe` preset surface. After Workspace creation or reuse establishes the main Provider over a blank Session, that surface applies the choice to its Provider-bound Session. When Settings changes the default preset, the preset service selects the blank Session whose established Provider binding carries `mainView` ownership and updates that Session. Non-blank main Sessions, independent Sidebar Providers, and other background references remain unchanged; preset subtrees do not read the main reference or use a global current follower to find their target.
### Host-event Context ownership and Typert
diff --git a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
index 3af8932f7c..b8c6c313f6 100644
--- a/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md
@@ -166,7 +166,7 @@ Workspace 连接保持既有导航检查与面板切换失效规则。侧边栏
预设目录与部署默认值可以共享。已绑定 Session 的预设通过 Provider 的 binding 读取或修改;预设控制器按 `SessionBinding` 缓存,不由根级 current-Session 跟随器管理。Hero 的 preset seat 使用 `session-maybe` Provider:没有 Session 时显示创建流程选择,绑定空白 Session 后操作该确切 Session。标题标签读取同一 Provider 绑定 Session 的投影。
-Session 创建前选择的 preset 保留在主 Conversation 的 `session-maybe` preset surface 中。Workspace 创建或复用空白 Session 并建立主 Provider 后,该 surface 将选择应用到 Provider 绑定的 Session。设置页修改默认 preset 或 picker 设置时,preset 服务从 Provider 已建立的 binding 缓存中选择带 `mainView` 所有权标记的空白 Session,并更新该 Session。非空白主 Session、Sidebar 的独立 Provider 与其他后台引用均不受该设置动作影响;preset 子树不读取主引用,也不通过全局 current follower 寻找目标。
+Session 创建前选择的 preset 保留在主 Conversation 的 `session-maybe` preset surface 中。Workspace 创建或复用空白 Session 并建立主 Provider 后,该 surface 将选择应用到 Provider 绑定的 Session。设置页修改默认 preset 时,preset 服务从 Provider 已建立的 binding 缓存中选择带 `mainView` 所有权标记的空白 Session,并更新该 Session。非空白主 Session、Sidebar 的独立 Provider 与其他后台引用均不受该设置动作影响;preset 子树不读取主引用,也不通过全局 current follower 寻找目标。
### Host 事件 Context 所有权与 Typert
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 945359e6e7..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: 5b8d0a26024f795e
- zh: c4d6f36d54783c47
+ 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 f063dba6e4..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 opt-in overlay remains responsible for enabling Schedule. 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 433c1acb0a..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 持久化和恢复服务,Schedule 仍由显式启用的 overlay 加载。显式删除会阻止后续投递,并将任务从 `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-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
index a857ff2101..209838a431 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml
@@ -9,8 +9,8 @@
en: 9ca845902f1df3a2
zh: 41e3157a843a511e
/agent-note-plugin-configuration-on-the-plugins-page/decision:
- en: edce6321f96653c1
- zh: b9af8efeff5a05ed
+ en: ccee8068ea810fcb
+ zh: c87c4e6c44b33a67
/agent-note-plugin-configuration-on-the-plugins-page/consequences:
en: d3d131d6c46bc32e
zh: eb7c06f119dfbe8d
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
index 700ada1538..35aaefd028 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
@@ -20,7 +20,7 @@ The page passes `view: 'page'` for each form. Official plugin cards and details
**The four host-plane pages register while the Host serves their namespaces.** Each page's package registers it through `ctx.slots.inject` when the shared settings mirror shows the namespace and disposes it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it; since [settings pages as companion packages](2026-09-17-settings-pages-as-companion-packages.md) that is one companion package per page, and `ui-settings-plugins` keeps the Settings section as the **Built-in plugins** shell around the inventory tab. The `settings.plugin.item` slot is retired.
-**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency.
+**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the package is experimental, with no official tag; the configuration pages follow. The [optional-bundle decision for experimental capabilities](2026-09-21-experimental-capabilities-as-optional-bundles.md) names the entries.
## Consequences
diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
index 23935312d2..ccdaf5629a 100644
--- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md
@@ -20,7 +20,7 @@ Status: implemented
**四个宿主平面页面在 Host 服务其命名空间期间注册。** 每个页面所在的包在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹;自[设置页作为伴生包](2026-09-17-settings-pages-as-companion-packages.zh.md)起,每个页面各是一个伴生包,`ui-settings-plugins` 把设置分区保留为清单标签页外面的**内置插件**外壳。`settings.plugin.item` slot 退役。
-**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。
+**官方分组。** 安装随附的可选组合包开启这个分组,属于实验包的带 **Beta** 标签,没有官方标签;配置页排在其后。[实验能力的可选 bundle 决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)列出这些条目。
## 后果
diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml
index 41b02cd5ae..1f07fe4f8b 100644
--- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.i18n.yaml
@@ -9,8 +9,8 @@
en: 725f773bb350218e
zh: dd8fbc09ef48208d
/agent-note-declarative-agent-presets-and-retained-revisions/decision:
- en: fa2ac428de2ba3d0
- zh: c75da98e1cf076ef
+ en: 2a2489303fc76fc0
+ zh: 1a760e9a5edfccc8
/agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered:
en: 09f40eab4659e9f6
zh: 7a70da0d0ae6845b
diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md
index 55ec173b33..bf900bc4dc 100644
--- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md
+++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.md
@@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s
The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host.
-Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. The existing chooser policy and blank-session selection remain: hiding the chooser uses the deployment default without deleting the saved user preference.
+Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. The saved user default and blank-session selection remain; `selectedDefault` is what an unnamed new session resolves, and the Client's Developer tools preference decides only whether the choice is shown.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md
index 60c81de4d9..21611da37f 100644
--- a/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-18-declarative-agent-presets.zh.md
@@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数
Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。
-会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。现有选择器策略和空白会话选择保持不变:隐藏选择器使用部署默认值,但不删除保存的用户偏好。
+会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。保存的用户默认值与空白会话选择保持不变:未指定 preset 的新会话解析 `selectedDefault`,客户端的开发者工具偏好只决定是否展示这项选择。
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml
index fa28db5307..938a007c31 100644
--- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml
@@ -9,11 +9,11 @@
en: 2e7f99c81fbc941f
zh: b0fac7cd5eb86ffe
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/problem/mechanism:
- en: faa32b20dc128571
- zh: d01263a543be675d
+ en: 36d24e92eedf8cea
+ zh: 5dafc5758ee0f380
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/problem/the-cache-s-two-uses-and-where-validation-lives:
- en: 7bec01a054dffbad
- zh: e158b106a079a770
+ en: 055ead18162ca1cd
+ zh: 9046bf04229e45d1
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/problem/the-client-store-cannot-guarantee-that-connected-data-replaces-hints:
en: 597b346459a9d8b0
zh: f7282a40061d645c
@@ -27,8 +27,8 @@
en: 1138b58239be186b
zh: cf5fc5f1d3f21bd3
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/consumers:
- en: 5b8c1e5eb06f7d59
- zh: 4018838c3390342a
+ en: 743f2dceda3072aa
+ zh: f4d53ddc13a4127e
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/the-client-store-separates-cached-and-sequenced-rows:
en: 8a4e9e9ac2ada06f
zh: b5884535cd934f72
@@ -42,8 +42,8 @@
en: d71031c0dc256d80
zh: 753c948a90a0c9ad
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/consequences:
- en: c95faf40ca336afb
- zh: 95d6030671113056
+ en: 525ea6842ad2e19a
+ zh: 595da1db20255617
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/testing:
- en: 20aef0e3d10b7f98
- zh: 478b2d22eb3fcc2a
+ en: 8faa78adc442992c
+ zh: 29cb78232985fd47
diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md
index 61b82e5003..bfdd8bda22 100644
--- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md
+++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md
@@ -14,13 +14,12 @@ This is neither cache corruption nor a version mismatch. The projcache record's
A cache record (record format and predecessor recovery: [Projection-cache predecessor recovery and Session-format binding](2026-09-02-projcache-cross-version-read-compat.md)) is bound to a lifecycle identity: `formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`, matched by `identityMatches` on full equality. Since #3346, `inheritedEventCount` (the length of the event prefix a fork inherits, the cut below) no longer appears in the logical header: the header keeps only the `isSeeded` bit and the exact cut follows the body. Since Session format v2 (#3398) the physical header line no longer stores `seedLength` either; the reader derives the cut from the seq of the `session/end-seed {inherited: true}` marker in the body.
-A header-only read therefore cannot obtain the cut: the JSONL backend's `fromHeaderLine` hard-codes `inheritedEventCount: 0` for header-only reads, and `SessionPersistenceSnapshot` carries only the header, the revision, and an optional eventCount. All three header-only cache consumers grew the same guard:
+A header-only read therefore cannot obtain the cut: the JSONL backend's `fromHeaderLine` hard-codes `inheritedEventCount: 0` for header-only reads, and `SessionPersistenceSnapshot` carries only the header, the revision, and an optional eventCount. The Session-list and reference consumers grew the same guard:
| Consumer | Guard | Fallback | Consequence |
|---|---|---|---|
| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | none | no title, no `sessionListMetadata`; blank falls back to `false`, ordering falls back to `createdAt` |
| `packages/context/session-reference/src/index.ts` `projectedLabels` | same | none | `@` completion labels by id; the title cannot be searched |
-| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | same | `observeSession` reads the body | correct result, one body read per seeded child |
When the guard landed (#3346, 2026-09-01), `list.ts` still had `probeSmallCold`: on a cache miss it read the body when the log file was at most `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` bytes. Its purpose was blank-session detection; it covered only logs under 1KB and never applied to an ordinary fork. #3400 (2026-09-03) removed it and the list returned to metadata plus cache only. #4320 (2026-09-19) made forking a first-class feature (`packages/core/session/src/fork.ts`, fork at any seq); every fork is `isSeeded: true` with an exact cut, seeded sessions went from a handful to dozens, all of them cold after a restart, and the gap became visible at once.
@@ -33,7 +32,7 @@ The read and write faces of `SessionProjectionCache` and their callers:
| `write(session)` | writes the checkpoint at three mandatory points plus a throttle | yes | the cache's own listeners |
| `hydratePrepared(session, events)` | uses `rows` as the fold starting point and applies events from `row.seq + 1`; the result lands in live cells | the next checkpoint | `session-query/src/observation.ts` |
| `coldSnapshot(meta, cut, events)` | fold starting point plus write-back | yes | no production caller |
-| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`: each row passes `ver` and `stateSchema`, is `view`ed, and returned | no | `list.ts`, `session-reference`, `list-children` |
+| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`: each row passes `ver` and `stateSchema`, is `view`ed, and returned | no | `list.ts`, `session-reference` |
| `cachedPredecessorTitle(meta, cut)` | the same, `title` only, allowing an older `formatVersion` | no | `list.ts` |
Identity validation lives entirely inside the cache (`recordFor` → `identityMatches`; `viewCheckpoint` checks `ver` and schema per row). `list.ts` validates nothing itself; it merely declines to call when it cannot supply a cut.
@@ -70,7 +69,6 @@ In the block the read-only face returns, `asOfSeq` is the lowest watermark among
|---|---|
| `list.ts` `projectionsFor` | drop the `isSeeded` branch and `SessionLogOffset(0)`; every cold row reads `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` |
| `session-reference` `projectedLabels` | same symptom, same change |
-| `list-children.ts` `resolveColdIdentity` | follows the signature only; the `!header.isSeeded` guard and the body fallback stay, because it needs the cut to classify a seq as inherited or owned, which is fold semantics |
### The client store separates cached and sequenced rows
@@ -147,7 +145,7 @@ Bought:
Paid:
-- `cachedSnapshot` and `cachedPredecessorTitle` change signature; three callers change with them.
+- `cachedSnapshot` and `cachedPredecessorTitle` change signature; the Session-list and reference callers use the header-only signatures.
- `SessionProjectionHints` gains the required field `kind`; every producer of a list summary and every test fixture that builds one carries it.
- A hand-crafted record with the same four fields and a different cut is displayed in the list until the session is opened.
- Keys the baseline omits clear together with their hints: when a Host does not mount `schedule`, the schedule mark the list hinted disappears after the session opens. Under "connected data is the truth" this is the correct behavior.
@@ -163,4 +161,3 @@ Paid:
- `api/session-controller/tests/manager.client.spec.ts`: a `cached` `api-session/added` block is replaced by a control baseline at the same cursor; a `cached` list block does not displace an existing sequenced title.
- `api/session-controller/tests/inbox-projection.client.spec.ts`: unchanged; a live session's `sequenced` list block still outranks a delayed control baseline at a lower cut.
- `context/session-reference/tests/session-reference.spec.ts`: a seeded cold session is labeled and searchable by its cached title, a session without a cache record is still labeled by id, and neither reads a log.
-- `subagent/subagent/tests/list-children.spec.ts`: unchanged; seeded children still go through body observation.
diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md
index ef49d90bc6..470c96385c 100644
--- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md
@@ -14,13 +14,12 @@ Host 进程重启后,所有 fork 出来的会话(`SessionHeader.isSeeded ===
缓存记录(记录格式与前代恢复见 [投影缓存前代恢复与 Session 格式绑定](2026-09-02-projcache-cross-version-read-compat.zh.md))绑定一份 lifecycle identity:`formatVersion + createdAt + cwd + isSeeded + inheritedEventCount`,`identityMatches` 做全等匹配。其中 `inheritedEventCount`(fork 继承的事件前缀长度,下称 cut)从 #3346 起不再出现在逻辑 header 里:header 只保留 `isSeeded` 这一位,精确 cut 跟随正文。Session 格式 v2 起(#3398),物理 header 行也不再存 `seedLength`,reader 从正文里 `session/end-seed {inherited: true}` 标记的 seq 推出 cut。
-于是 header-only 的读取拿不到 cut:JSONL 后端 `fromHeaderLine` 对 header-only 读取硬编码 `inheritedEventCount: 0`,`SessionPersistenceSnapshot` 只有 header、revision 和可选的 eventCount。三个只读 header 的缓存消费者都因此加了同一个守卫:
+于是 header-only 的读取拿不到 cut:JSONL 后端 `fromHeaderLine` 对 header-only 读取硬编码 `inheritedEventCount: 0`,`SessionPersistenceSnapshot` 只有 header、revision 和可选的 eventCount。Session 列表与引用消费者都因此加了同一个守卫:
| 消费者 | 守卫 | 兜底 | 后果 |
|---|---|---|---|
| `packages/api/session-controller/src/list.ts` `projectionsFor` | `header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0)` | 无 | 无 title,无 `sessionListMetadata`,blank 判定退到 `false`,排序退到 `createdAt` |
| `packages/context/session-reference/src/index.ts` `projectedLabels` | 同 | 无 | `@` 补全按 id 显示,搜不到 title |
-| `packages/subagent/subagent/src/list-children.ts` `resolveColdIdentity` | 同 | `observeSession` 读正文 | 结果正确,每个 seeded 子代一次正文读 |
守卫存在时(#3346,2026-09-01),`list.ts` 还有 `probeSmallCold`:缓存 miss 且日志文件不超过 `DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024` 字节时读正文补全。它的目的是识别 blank 会话,只覆盖不到 1KB 的日志,对正常 fork 从未生效。#3400(2026-09-03)删掉它,列表回到纯元数据加缓存。#4320(2026-09-19)把 fork 变成一等功能(`packages/core/session/src/fork.ts`,任意 seq fork),每个 fork 都是 `isSeeded: true` 加精确 cut,seeded 会话数量从个位数变成几十条,重启后全部冷,问题集中显形。
@@ -33,7 +32,7 @@ Host 进程重启后,所有 fork 出来的会话(`SessionHeader.isSeeded ===
| `write(session)` | 三个强制点加节流写 checkpoint | 有 | 缓存内部监听 |
| `hydratePrepared(session, events)` | 把 `rows` 当 fold 起点,从 `row.seq + 1` 继续 apply,结果进 live cell | 后续 checkpoint 写回 | `session-query/src/observation.ts` |
| `coldSnapshot(meta, cut, events)` | fold 起点加回写 | 有 | 无生产调用方 |
-| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`:每行过 `ver` 和 `stateSchema`,`view` 后返回 | 无 | `list.ts`、`session-reference`、`list-children` |
+| `cachedSnapshot(meta, cut, keys?)` | `viewCheckpoint`:每行过 `ver` 和 `stateSchema`,`view` 后返回 | 无 | `list.ts`、`session-reference` |
| `cachedPredecessorTitle(meta, cut)` | 同上,只看 `title`,允许更旧的 `formatVersion` | 无 | `list.ts` |
身份校验全部在缓存内部(`recordFor` → `identityMatches`;`viewCheckpoint` 逐行校验 `ver` 与 schema)。`list.ts` 自己不校验,只是给不出 cut 时不调。
@@ -70,7 +69,6 @@ spec.ts 与 README 描述身份检查的目的时用的都是同一个动词:
|---|---|
| `list.ts` `projectionsFor` | 删 `isSeeded` 分支和 `SessionLogOffset(0)`,冷会话统一 `cachedSnapshot(header) ?? cachedPredecessorTitle(header)` |
| `session-reference` `projectedLabels` | 同一症状,同一改法 |
-| `list-children.ts` `resolveColdIdentity` | 只跟随签名,`!header.isSeeded` 守卫和正文兜底原样保留;它需要 cut 做 inherited/owned 分类,属于 fold 语义 |
### 客户端 store 区分 cached 与 sequenced 行
@@ -147,7 +145,7 @@ seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `cl
付出的:
-- `cachedSnapshot` 与 `cachedPredecessorTitle` 签名变化,三个调用方同步修改。
+- `cachedSnapshot` 与 `cachedPredecessorTitle` 签名变化,Session 列表与引用调用方使用仅需 header 的签名。
- `SessionProjectionHints` 新增必填字段 `kind`,所有产出列表摘要的地方和构造摘要的测试夹具都要带上。
- 手工构造的同四字段、不同 cut 的记录会在列表上显示到会话被打开为止。
- 基线没带的键连提示一起清掉:某个 Host 未挂载 `schedule` 时,打开会话后列表提示过的 schedule 标记消失。按"建连数据是真值"这是正确行为。
@@ -163,4 +161,3 @@ seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `cl
- `api/session-controller/tests/manager.client.spec.ts`:`api-session/added` 的 `cached` block 被同 cursor 的 control 基线替换;`cached` 列表 block 不覆盖已有的 sequenced title。
- `api/session-controller/tests/inbox-projection.client.spec.ts`:不变,活会话的 `sequenced` 列表 block 仍压过延迟到达、cut 更低的 control 基线。
- `context/session-reference/tests/session-reference.spec.ts`:seeded 冷会话按缓存 title 标注并可按 title 搜到,没有缓存记录的会话仍按 id 标注,两者都不读日志。
-- `subagent/subagent/tests/list-children.spec.ts`:不变,seeded 子代仍走正文观察。
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
new file mode 100644
index 0000000000..521567dd7e
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-21-experimental-capabilities-as-optional-bundles.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-21-experimental-capabilities-as-optional-bundles.md
+/agent-note-ship-experimental-capabilities-as-optional-bundles:
+ en: 9ec3d64c70d601b6
+ zh: c85722cfc7bf70df
+/agent-note-ship-experimental-capabilities-as-optional-bundles/problem:
+ en: 2ba6c43b20a3d347
+ zh: 5647179ddaa38ad9
+/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
+ 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: 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
new file mode 100644
index 0000000000..b7139f6d9a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
@@ -0,0 +1,25 @@
+# Agent Note: Ship experimental capabilities as optional bundles
+
+Status: implemented
+
+English | [中文](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)
+
+## Problem
+
+The Web plugin page offered two optional bundles, Agent Teams and voice input. Auto review and the Inspector were published experimental packages that a person had to install by name or mount through a hand-written profile patch, although both already declared a bundle patch or shipped a mountable overlay.
+
+## Decision
+
+`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.
+
+## Alternatives considered
+
+**Ship every provider as an optional bundle.** Composes and displays correctly, but grows every install by provider runtimes for a capability most installations never switch on; voice input's `sherpa-onnx-node` is the one accepted precedent.
+
+**Mount the registration-only `computer-use` and `browser-use` services in `dsh-base`.** The shared composition would carry rows that only an optional provider bundle needs; a provider bundle can insert the service row from its own patch and dependencies.
+
+## Consequences
+
+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
new file mode 100644
index 0000000000..af4048bc5e
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
@@ -0,0 +1,25 @@
+# Agent Note: 将实验能力作为可选 bundle 发布
+
+Status: implemented
+
+[English](2026-09-21-experimental-capabilities-as-optional-bundles.md) | 中文
+
+## 问题
+
+Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto review 与 Inspector 都是已发布的实验包,用户必须按名称安装或手写 profile patch 才能挂载,尽管两者已经声明了 bundle 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 密钥,而插件页没有对应的配置表单。
+
+## 考虑过的替代方案
+
+**把所有提供方都做成可选 bundle。** 组合与展示都正确,但会为多数安装从不启用的能力把提供方运行时塞进每次安装;语音输入的 `sherpa-onnx-node` 是唯一被接受的先例。
+
+**在 `dsh-base` 中挂载仅负责注册的 `computer-use` 与 `browser-use` 服务。** 共享组合将携带只有可选提供方 bundle 才需要的行;提供方 bundle 可以从自己的 patch 和依赖插入服务行。
+
+## 后果
+
+官方分组包含四个可选 bundle 条目,每项都因实验包名而带实验性标签。安装的运行时依赖闭包保持不变。浏览器操作或电脑操作提供方仍需在 profile patch 或组合中同时挂载其 Service Definition 与提供方。
diff --git a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml
new file mode 100644
index 0000000000..28b2efa813
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-23-desktop-close-to-background-and-quit-confirmation.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-23-desktop-close-to-background-and-quit-confirmation.md
+/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits:
+ en: 9e029938c58bf143
+ zh: f60f9cf2067a02ee
+/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/problem:
+ en: 782e934449eae28c
+ zh: 0941465dff3a26d9
+/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/decision:
+ en: 54c0d8811a8a4390
+ zh: 2baa107b3d25a138
+/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/alternatives-considered:
+ en: 74dcbb66adffd7be
+ zh: 1be3d3fa8e68d81f
+/agent-note-hide-the-desktop-window-on-close-and-confirm-interruptible-quits/consequences:
+ en: dc219aecdf03014e
+ zh: 621cd97cef5dc858
diff --git a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md
new file mode 100644
index 0000000000..67c7bf92e0
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md
@@ -0,0 +1,35 @@
+# Agent Note: Hide the Desktop window on close and confirm interruptible quits
+
+Status: implemented
+
+English | [中文](2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md)
+
+Window timing and shutdown ownership otherwise follow [showing the window before the Host starts](2026-09-09-desktop-immediate-window-and-direct-start.md); the task rule reuses the [update restart check](2026-08-25-electron-desktop-packaging-and-updates.md). This partially supersedes the [standard macOS window menus](../bug-fix/2026-09-16-desktop-window-menus.md) decision: ⌘W no longer destroys the window through Electron's role, and Dock activation reopens the hidden window when no window is visible instead of only when none exists. That note's menu declarations stay in force.
+
+## Problem
+
+Closing the Desktop window destroyed it. On macOS the Host kept running but the Dock rebuilt a fresh page, losing the current session, draft, and scroll position; on Windows the close quit the application and cut running tasks without any warning. Quit itself never asked, although the Host knows which agents, jobs, and scheduled reminders would stop.
+
+## Decision
+
+Closing the main window hides it on both platforms; the page and the Host keep running and the next show presents the same document. Windows gets a permanent tray icon (single click opens, context menu offers Open and Quit) because Windows users expect × to quit and need a visible way back, plus a first-close acknowledgement through the shared update-dialog overlay before hiding. Only Confirm records acknowledgement; cancellation keeps the window visible. Closing sends no system notification. macOS keeps the Dock and ships no menu bar icon. Closing the welcome window before the workspace opens quits on Windows and drops the unused main window on macOS, so the Dock rebuilds the welcome.
+
+Every ordinary quit entry asks the Host over the private IPC channel for two facts: active tasks under the update-restart rule, and armed scheduled reminders reported by the `schedule` family of `workspace/session-activity` for the sessions loaded in this run. Both absent, the quit proceeds silently; otherwise a native message box without an owner window shows one of three fixed explanations with Quit as the default and Cancel on Esc. Repeated quit requests join the open box, the copy is frozen while it is open, and approval does not re-inspect. A Host that is not ready cannot run tasks and the quit proceeds; an inspection failure or a missed two-second deadline counts as running tasks because a needless prompt is cheaper than a silent interruption. The installer restart, fatal recovery, the development restart command, and operating-system session end skip the confirmation.
+
+The Windows tray bitmaps are rendered from the vector icon at seven sizes and committed as an ICO; the confirmation on Windows uses the application icon in a task dialog and stays light because the control does not follow the application theme. The installer and uninstaller copy for a running application points at the tray.
+
+## Alternatives considered
+
+**Keep quitting on close and add a confirmation there.** It asks on every close, and Windows users would still lose background work whenever they dismissed the window. Hiding matches the product requirement that tasks continue.
+
+**Render the quit confirmation in the shell's HTML overlay.** The overlay needs a visible parent window and a working renderer; the quit must also work when the window is hidden or the page is stuck, so the native dialog is the reliable choice.
+
+**Reuse the update-tasks IPC request with a flag.** The quit needs a second fact and a shorter deadline; a distinct request keeps the update admission lock semantics untouched and lets both share one correlation id space.
+
+**Downscale one large bitmap for the tray.** Rasterizing the vector source per size keeps edges crisp at 100 % through 400 % display scale, which a single downscale does not guarantee.
+
+## Consequences
+
+Users can close the window freely; background tasks and scheduled reminders continue, and reopening restores the same page. Quitting warns only when it interrupts something. Windows carries a tray icon for the whole run, and a hidden window that finishes a user-initiated update download defers its install confirmation until the window is shown again. Reminders in sessions never loaded during the run are neither counted nor resumed until those sessions open, and Desktop does not enable scheduled tasks by default, so the scheduled-task copy appears only once that feature is on.
+
+Verification covers the four inspection outcomes, inspection failure and deadline, each quit entry's bypass or confirmation, the close-to-hide path with the tray and the first-close acknowledgement, the deferred update prompt, the Host-side inspection, the IPC correlation and deadline, and the committed tray icon's bitmap set. Windows tray and dialog behavior needs a manual pass on a Windows machine at several display scales.
diff --git a/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md
new file mode 100644
index 0000000000..e5a59bcd0b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: Hide the Desktop window on close and confirm interruptible quits
+
+Status: implemented
+
+[English](2026-09-23-desktop-close-to-background-and-quit-confirmation.md) | 中文
+
+窗口时序和关闭归属仍遵循[在 Host 启动前显示窗口](2026-09-09-desktop-immediate-window-and-direct-start.zh.md);任务判定复用[更新重启检查](2026-08-25-electron-desktop-packaging-and-updates.zh.md)。本记录部分取代[标准 macOS 窗口菜单](../bug-fix/2026-09-16-desktop-window-menus.zh.md)决策:⌘W 不再通过 Electron 的 role 销毁窗口,Dock 激活在没有可见窗口时重新打开隐藏窗口,而不再只在没有任何窗口时才创建。该记录中的菜单声明仍然有效。
+
+## 问题
+
+关闭 Desktop 窗口会销毁它。macOS 上 Host 继续运行,但 Dock 会重建一个全新页面,丢失当前会话、草稿和滚动位置;Windows 上关闭即退出应用,正在运行的任务被直接切断,没有任何提示。退出本身从不询问,尽管 Host 知道哪些 agent、后台任务和定时提醒会因此停止。
+
+## 决策
+
+两个平台上关闭主窗口都只隐藏它;页面和 Host 继续运行,下次显示仍是同一个文档。Windows 增加常驻托盘图标(单击打开,右键菜单提供"打开"和"退出"),因为 Windows 用户习惯 × 即退出,需要一个看得见的回路;首次关闭时先通过复用的更新弹窗确认,再隐藏窗口。只有点击“确认”才记录已读;取消时保持窗口可见。关闭窗口不发送系统通知。macOS 沿用 Dock,不提供菜单栏图标。进入工作区前关闭欢迎窗口,Windows 上退出,macOS 上丢弃未使用的主窗口,让 Dock 重建欢迎窗口。
+
+所有普通退出入口都通过私有 IPC 通道向 Host 询问两项事实:按更新重启口径判定的运行中任务,以及本次运行中已加载会话里由 `workspace/session-activity` 的 `schedule` family 报告的已挂定时器的提醒。两项都没有时直接退出;否则弹出一个没有父窗口的原生消息框,显示三种固定说明之一,"退出"为默认按钮,Esc 对应"取消"。重复的退出请求并入已打开的弹框,弹框打开期间文案冻结,批准后不再重新查询。Host 未就绪时不可能有任务在跑,直接退出;查询失败或超过两秒截止时间按运行中任务处理,因为多提示一次的代价低于无声中断。安装更新的重启、致命错误恢复、开发版重启命令和操作系统会话结束跳过确认。
+
+Windows 托盘位图由矢量图标按七种尺寸分别渲染并提交为 ICO;Windows 上的确认框在任务对话框中显示应用图标,且始终为浅色,因为该系统控件不跟随应用主题。安装程序和卸载程序针对应用仍在运行的文案指向托盘。
+
+## 备选方案
+
+**保留关闭即退出,只在关闭时加确认。** 每次关闭都要询问,而 Windows 用户只要想收起窗口,后台工作就仍会丢失。隐藏才符合"任务继续运行"的产品要求。
+
+**用壳的 HTML 遮罩渲染退出确认框。** 遮罩需要可见的父窗口和正常工作的渲染进程;退出必须在窗口隐藏或页面卡死时也能完成,因此原生对话框才是可靠选择。
+
+**在 update-tasks IPC 请求上加一个标志复用。** 退出需要第二项事实和更短的截止时间;独立请求不触碰更新准入锁的语义,同时两者共用一个关联 id 空间。
+
+**为托盘缩小一张大位图。** 按尺寸分别栅格化矢量源,才能在 100% 到 400% 缩放下都保持边缘清晰,单次缩小无法保证这一点。
+
+## 影响
+
+用户可以随意关闭窗口;后台任务和定时提醒继续运行,重新打开时恢复同一页面。退出只在会中断工作时提醒。Windows 在整个运行期间携带托盘图标;窗口隐藏期间完成的用户主动发起的更新下载,把安装确认推迟到窗口再次显示时。本次运行中从未加载的会话里的提醒既不计入,也要等到这些会话打开后才会继续,而 Desktop 默认未开启定时任务,因此定时任务文案只在该功能开启后出现。
+
+验证覆盖四种查询结果、查询失败与截止时间、每个退出入口的跳过或确认、带托盘和首次关闭确认的关闭即隐藏路径、推迟的更新提示、Host 侧查询、IPC 关联与截止时间,以及已提交托盘图标的位图集合。Windows 托盘和对话框的表现需要在 Windows 机器上以多种显示缩放人工验收。
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-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-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml
index eb76836a1c..fdf261af26 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml
@@ -9,8 +9,8 @@
en: 75c201a0a687b59d
zh: 34593038f673d06f
/agent-note-desktop-standard-macos-window-menus/decision:
- en: 1797b5695e873e5a
- zh: 3376fda4fc36aaae
+ en: 0ce30c3a1e6202d7
+ zh: badac0021fae4a6f
/agent-note-desktop-standard-macos-window-menus/alternatives-considered:
en: 957382dbfb6cac48
zh: 2db98b7dbf3e01c0
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md
index 34eff83707..d8f8bdb9cc 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md
@@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem
On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior.
-Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers.
+Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers. Close and activation behavior is partly superseded by [close-to-background](../architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.md); the menu declarations here remain in effect.
## Alternatives considered
diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md
index c3cb3e5d89..c85ab23df1 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md
@@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。
-Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。
+Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。 关闭与激活行为已被[关闭时隐藏窗口](../architecture/2026-09-23-desktop-close-to-background-and-quit-confirmation.zh.md)部分取代;这里的菜单声明仍然有效。
## 考虑过的替代方案
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-20-session-open-read-coordination.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml
index 1ca3737cda..270547a0cb 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.i18n.yaml
@@ -21,8 +21,8 @@
en: 181653d75354df80
zh: 7984321c5a2715d5
/agent-note-session-opening-cache-identity-and-read-ordering/implementation-scope:
- en: 84310b075ac5537b
- zh: 6bf16dbf06353a39
+ en: f3e37b63e2e813e3
+ zh: 1c709bd0ee6cc3ff
/agent-note-session-opening-cache-identity-and-read-ordering/alternatives-considered:
en: 7d64c8151ffef2a0
zh: b899a22f4d070a78
diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md
index a4907df1d7..c00f8fa7a3 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.md
@@ -51,7 +51,7 @@ A known address does not waive authorization checks. When opening child history,
- Session formats, migrations, the Host observation's `all | none` policy, and the response fields of `session.projections` are unchanged.
- There is no global cold-read singleflight, and Agent lookups are not universally replaced with read-only lookups.
- `SessionManager.handleConnected()` still refreshes previously requested catalogs in bulk; this decision does not guarantee one cold read on reconnect.
-- Tool-side `listDescendants` can still read child Sessions on cache misses, and expanding a child node in the Client can still cold-read that node. Ordinary main-session opening is not equivalent to every descendant-enumeration scenario.
+- Tool-side `listDescendants` observes each reachable child catalog. An observation can reuse live state or a valid prepared Session; otherwise it reads the cold log. Expanding a child node in the Client can also cold-read that node. Ordinary main-session opening is not equivalent to every descendant-enumeration scenario.
- `@` Session candidates still enumerate headers and obtain names from live projections or projection caches, falling back to ids on misses. Existing ranking, the default limit of 50, and direct-subagent grouping remain unchanged; discovery does not recursively traverse `subagentCatalog`.
## Alternatives considered
diff --git a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md
index 759a45d8c5..ce9eac01db 100644
--- a/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-09-20-session-open-read-coordination.zh.md
@@ -51,7 +51,7 @@ Skill 保留共享目录请求自己的取消信号;`@` 查询将当前候选
- 没有修改 Session 格式、迁移、Host observation 的 `all | none` 策略或 `session.projections` 返回字段。
- 没有实现全局 cold-read singleflight,也没有把所有 Agent lookup 改成只读 lookup。
- 没有删除 `SessionManager.handleConnected()` 对此前请求目录的批量刷新;本决策不保证重连只有一次冷读。
-- 工具侧 `listDescendants` 的缓存缺失仍可能读取子会话;前端展开一个子节点也仍可能冷读该节点。普通主会话首开不等于所有后代枚举场景。
+- 工具侧 `listDescendants` 观察每个可达子级目录。观察可以复用实时状态或有效的 prepared Session,否则会读取冷日志。前端展开子节点也可能冷读该节点。普通主会话首开不等于所有后代枚举场景。
- `@` 会话候选仍枚举 header,并从 live projection 或 projection cache 取名称;缓存缺失回退到 id。原有排序、默认 50 条上限和直接 subagent 分组规则不变,没有改成沿 `subagentCatalog` 递归发现。
## 考虑过的替代方案
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/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml
new file mode 100644
index 0000000000..af95d9cdf7
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-pwsh-prompt-tail-grace.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-27-pwsh-prompt-tail-grace.md
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace:
+ en: 5e8c5673a0669d9c
+ zh: c8442a6186288a2a
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem:
+ en: ac4fb08e3203cee9
+ zh: 69fcfcb4f8fc0319
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision:
+ en: 653c74442decb65a
+ zh: 46f01c45d48b846b
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered:
+ en: 21a9a19b76f42758
+ zh: 30a8f5425b59900d
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/consequences:
+ en: b58904921286a1db
+ zh: a696c2954d86c04f
+/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing:
+ en: 78253191eea198fa
+ zh: 32bdd6807ddc13a0
diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md
new file mode 100644
index 0000000000..0aa25795c1
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md
@@ -0,0 +1,31 @@
+# Agent Note: A seen prompt marker keeps its own tail grace
+
+Status: implemented
+
+English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md)
+
+## Problem
+
+Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds.
+
+## Decision
+
+`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
+
+## Alternatives considered
+
+**Raise the default instead of adding a field.** Rejected: it trades one fixed cost for another on every host. A marker whose tail never arrives (a stray marker from a child, a broken prompt function) would then fall back that much later everywhere, while the timing that matters differs per deployment.
+
+**Make the observation sticky: once the exact tail was seen, let later output stand.** Rejected: the tail rule exists to prove the marker was followed by the prompt text rather than by a command's output, and a sticky flag would settle a send while that command is still printing.
+
+**Wait for the tail up to the absolute `timeoutMs` once a marker was seen.** Rejected: a prompt that never completes its render would hang every send until the tool deadline instead of falling back after a bounded silence, and the send budget belongs to the caller.
+
+**Detect the stall in the test and retry the send.** Rejected: retries hide which tier settled a send, and the durable fix belongs where the evidence is interpreted, not where it is measured.
+
+## Consequences
+
+A deployment whose console renderer stalls keeps those sends on the exact path by setting `promptTailGraceMs`; the default path is bit-for-bit the previous bound, so the negative control — the same delayed tail with the field at zero — still settles `inferred_idle`. The tolerance applies only after a marker was seen: a missing prompt keeps the plain silence bound, so the regression the Loader-composition case pins (a lost prompt must fail) is still caught, and a marker whose tail never arrives falls back one configured interval later.
+
+## Testing
+
+`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md
new file mode 100644
index 0000000000..feb551d35f
--- /dev/null
+++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: 已看到的提示符标记保留自己的尾部宽限
+
+Status: implemented
+
+[English](2026-09-27-pwsh-prompt-tail-grace.md) | 中文
+
+## Problem
+
+自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。
+
+## Decision
+
+`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
+
+## Alternatives considered
+
+**不加字段,直接提高默认值。** 不采纳:那是把一种固定成本换成另一种,且对所有宿主机生效。尾部永不到达的标记(子进程打印的杂散标记、损坏的 prompt 函数)会在所有地方都多等那么久,而真正重要的时序因部署而异。
+
+**让观测变为粘性:一旦看到精确尾部,后续输出不再使其失效。** 不采纳:尾部规则的存在就是为了证明标记之后跟着的是提示符文本而不是命令输出;粘性标志会在命令仍在打印时结算该 send。
+
+**看到标记后一直等到绝对 `timeoutMs`。** 不采纳:渲染永不完成的提示符会让每次 send 都挂到工具截止时间,而不是在有界静默后回退,而 send 预算属于调用方。
+
+**在测试里检测停摆并重试该 send。** 不采纳:重试会掩盖究竟是哪一层结算的,而持久的修复属于解释证据的地方,而不是测量证据的地方。
+
+## Consequences
+
+控制台渲染器会停摆的部署通过设置 `promptTailGraceMs` 让这些 send 留在精确路径上;默认路径与此前的上界逐位一致,因此负控——同样的尾部延迟、字段为零——仍然按 `inferred_idle` 结算。该宽限只在看到标记之后生效:提示符缺失时保持普通静默上界,所以 Loader 组合用例所固定的回归(提示符丢失必须失败)仍会被抓住,而尾部永不到达的标记只会晚一个配置区间回退。
+
+## Testing
+
+`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
index 6b8dc5e1c0..07e39db02d 100644
--- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml
@@ -18,8 +18,8 @@
en: a6d7884e4359af3a
zh: e72588705942000a
/agent-note-the-approval-seam-one-shot-permission-decisions-over-a-waterfall-of-answerers/decision/design-detail/the-seam-mechanism-and-policy-split:
- en: 0aa6f3a90921d1ea
- zh: c4a734f84800dae8
+ en: 53da7800d8744d9e
+ zh: 316fef884853ca5d
/agent-note-the-approval-seam-one-shot-permission-decisions-over-a-waterfall-of-answerers/decision/design-detail/ask-routing-in-dsh-tools:
en: 1d4478059f608fd2
zh: 4a1d12e4ccb766b5
diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md
index 03a4269dd7..bb44957b6d 100644
--- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md
+++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md
@@ -55,7 +55,7 @@ After validation and a successful `approval/asked` append, the service resolves
Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates.
-`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `ToolCallId` brand without importing `dsh-tools`, which depends on this seam. Channel adapters correlate any richer call state by `callId`; the approval request does not duplicate tool arguments.
+`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, optional localized `displayReason`, and optional `signal`. It uses the `ToolCallId` brand without importing `dsh-tools`, which depends on this seam. Channel adapters correlate any richer call state by `callId`; the approval request does not duplicate tool arguments.
#### Ask routing in dsh-tools
diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
index f11e0956c9..b7e07a376c 100644
--- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md
@@ -55,7 +55,7 @@ tool/result "escalated" — this one call ran under the wider mode; the gra
应答者是 `approval/request` waterfall 监听器。零监听器会直接落到 `unavailable`;识别该 agent 的监听器占用先到先得的决策槽,而不识别的监听器必须调用 `next()` 委派。监听器会随其 fiber 一同 dispose(资源释放),因此卸载通道后,请求会在故障时默认被拒绝。由于兄弟插件的注册顺序不确定,部署应组合一个终端应答者,并保留 `prepend` 给「决策或委派」门禁。
-`ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason` 和可选的 `signal`。它使用 `ToolCallId` brand 而不导入依赖本 seam 的 `dsh-tools`。通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数。
+`ApprovalRequest` 携带发起请求的 `agent`、`toolName`、可选的精确 `callId`、人类可读的 `reason`、可选的本地化 `displayReason` 和可选的 `signal`。它使用 `ToolCallId` brand 而不导入依赖本 seam 的 `dsh-tools`。通道适配器可按 `callId` 关联任何更丰富的调用状态;审批请求本身不重复携带工具参数。
#### dsh-tools 中的 Ask 路由
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-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
index ac9be41f4d..6f55ce4f48 100644
--- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml
@@ -24,8 +24,8 @@
en: b92715f36550c728
zh: e18d7e0e2363347a
/agent-note-persistent-pty-sessions/decision/local-readiness-detection:
- en: cd8b4032a624c277
- zh: 47f0d8c15f1c9cad
+ en: 8f36f06869bd5367
+ zh: f3560477a8965cbe
/agent-note-persistent-pty-sessions/decision/model-visible-output-and-durability:
en: 1b504ab41b47216d
zh: 8fb3d360b3a4e6a1
diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
index d62587ced7..b309e1b1f9 100644
--- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
+++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
@@ -72,13 +72,13 @@ With `run_in_background: true`, `dsh-tool-terminal` registers the in-flight send
### Local readiness detection
-The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
+The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires the printable tail after the latest marker to exactly equal the controlled `PS1` before declaring prompt readiness and runs three bounded fallback tiers. Carrying that tail across data callbacks covers delivery where the marker and prompt arrive separately; requiring the exact tail rejects a delayed earlier prompt once echoed input or output follows it, so it cannot settle the current send. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, `promptTailGraceMs`, and `timeoutMs`.
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. The waiting thread's `/proc//task//fd/0` must identify the shell's controlling terminal device, so a thread-local fd table cannot substitute its leader's terminal descriptor and a pipeline reader blocked on its pipe remains a running command. Direct PTY descriptors use their device number; `/dev/tty` uses the owning process's `tty_nr` because `stat` reports the alias device rather than the selected PTY. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. A host policy that denies `/proc//task//syscall`, including hardened ptrace policy, likewise skips Tier 1 and preserves the bounded Tier 2 idle inference; process sleep state never substitutes for inaccessible syscall evidence. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; the inspector admits the runtime architecture, then checks every supported kernel ABI because `/proc` can report a different ABI under user-mode emulation. Unsupported runtime architectures skip Tier 1.
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
-Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
+Tier 2 returns `inferred_idle` after `idleSilenceMs` without output. A sleeping or network-blocked command can therefore look ready. When a prompt marker was already seen, Tier 2 waits a further `handoffGraceMs` so a bash foreground handoff that lands on the silence boundary still settles as the exact `stdin_read` attribution instead of the weaker inference; the grace is a deployment-owned config field validated to cover at least one `pollIntervalMs`, because a grace shorter than the poll period cannot contain a single readiness poll and so cannot change any outcome. A marker whose printable tail has not arrived yet adds `promptTailGraceMs` to that bound, because the marker and its tail are written by the same prompt render and a contended host delays only the tail; the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) owns the field, and it applies only while the tail can still complete. It bounds only sends that saw a marker, so its cost is the interactive return latency of that one case rather than every send. Tier 3 returns `timeout` after `timeoutMs` so a foreground tool call cannot hold the agent indefinitely. The result preserves the distinction; callers may wait through `ctx.jobs`, signal the foreground group, or inspect from another session.
Once a send settles under any tier, `TerminalSendOperation.append` stops accepting output, so later child output no longer reaches that settled operation; it still reaches the scrollback, and any send that is active when it arrives. A test that waits for a marker on the operation it started must therefore set `idleSilenceMs` and `timeoutMs` above the child's own startup latency; interpreter startup on a loaded macOS runner otherwise ends the send before the marker is printed.
diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
index a26c3ed852..46257a6835 100644
--- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md
@@ -72,13 +72,13 @@ UI 渲染约定精确且不携带位置信息。`terminal_send` 只为前台发
### 本地就绪检测
-本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
+本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在最近一个 marker 后的可打印尾部与受控 `PS1` 完全相等时才声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留该尾部,可以适配 marker 与 prompt 被分开交付的情况;如果回显的输入或输出跟在延迟到达的先前 prompt 之后,要求尾部完全相等会拒绝该 prompt,使其无法完成当前 send。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs`、`promptTailGraceMs` 和 `timeoutMs`。
在 Linux 上,检查器从 `/proc//stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。等待线程的 `/proc//task//fd/0` 还必须标识 shell 的控制终端设备,因此线程本地 fd 表无法用 leader 的终端描述符冒充自身 fd,阻塞于管道的流水线读取端仍属于正在运行的命令。直接 PTY 描述符使用其设备号;`/dev/tty` 则使用所属进程的 `tty_nr`,因为 `stat` 报告的是别名设备而非选定的 PTY。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。宿主策略(包括加固的 ptrace 策略)若拒绝读取 `/proc//task//syscall`,同样会跳过 Tier 1 并保留有界的 Tier 2 idle 推断;进程休眠状态绝不会代替不可访问的 syscall 证据。架构表只包含对应 Linux UAPI 定义的 syscall number;检查器先准入运行时架构,再检查每个受支持的内核 ABI,因为在用户态模拟下,`/proc` 可能报告不同的 ABI。不受支持的运行时架构会跳过 Tier 1。
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入,并在 Linux 上经过单元测试,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
-Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
+Tier 2 在持续 `idleSilenceMs` 没有输出后返回 `inferred_idle`,因此 sleep 或网络阻塞的命令可能看似 ready。如果此前已经见过 prompt marker,Tier 2 会再等待 `handoffGraceMs`,使恰好落在静默边界上的 bash 前台交接仍然以精确的 `stdin_read` 归因结束,而不是退到较弱的推断;该宽限是由部署方拥有的配置字段,并被校验为至少覆盖一个 `pollIntervalMs`——短于轮询周期的宽限装不下一次就绪轮询,因此不可能改变任何结果。若已见 marker 而其可打印尾部尚未到达,该上界还会加上 `promptTailGraceMs`:marker 与尾部由同一次提示符渲染写出,负载高的宿主机只会拖慢尾部;该字段由[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)拥有,且只在尾部仍可能补齐时生效。它只约束见过 marker 的 send,代价是这一种情况的交互返回延迟,而不是每一次 send。Tier 3 在 `timeoutMs` 后返回 `timeout`,避免前台工具调用无限占住 agent。结果保留这些区别;调用方可以通过 `ctx.jobs` 等待、向前台组发信号,或从另一个会话排查。
一次 send 在任一层级 settle 之后,`TerminalSendOperation.append` 就不再接受输出,此后子进程的输出不会再进入那个已 settle 的 operation;它仍然会进入 scrollback,以及此时恰好处于活跃状态的任何 send。因此,等待自己所启动的 operation 上出现标记的测试,必须把 `idleSilenceMs` 与 `timeoutMs` 设得高于子进程自身的启动耗时;否则在负载较高的 macOS runner 上,解释器启动会在标记打印之前就结束这次 send。
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-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml
index a030f39da0..8862a5bc60 100644
--- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml
@@ -15,8 +15,8 @@
en: e7e953783cc4a332
zh: be5d47a503b47170
/agent-note-continuable-subagent-current-turn-interrupt/consequences:
- en: 4a7ea28d02c66b63
- zh: bad8d6e58b45bd81
+ en: 8e75516b07cdc842
+ zh: 10e31033e14241e7
/agent-note-continuable-subagent-current-turn-interrupt/testing:
en: e46413bc89f9238e
zh: 4fb1639ae696e9a4
diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
index e865415bbb..3da06ff860 100644
--- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
+++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md
@@ -41,7 +41,7 @@ The address-only RPC exposes one bit of live residency: an absent target is acce
The Web surface keeps Send and Stop as independent actions for a running continuable child: the client `Session.cancel()` routes Stop through `subagent.interrupt` (one-shot addresses stay uncancellable, ordinary sessions keep their existing primary Send/Stop toggle through `session.cancel`), while Send delivers follow-ups through the busy-Enter setting's mode — Queue by default, Steer when selected — exactly as plain Enter does ([busy Send button](../bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). A running parent-offline continuable child keeps the default composer with input and Send disabled but Stop reachable, returning to the read-only takeover once it stops ([Web subagent conversations](2026-07-27-web-subagent-conversations.md) owns the surrounding catalog and composer contract).
-The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` passes `exec.agent` as the `ancestor` authority and adds none of its own: the core primitive verifies live registry identity and recorded lineage, so the tool can name a direct child or a deeper descendant with the same generic `agent_id` parameter — deliberately not `subagent_id`, which would imply direct children only. Discovery rides `list_agents({ scope: 'descendants' })` over the new `SubagentRuntime.listDescendants()` one-trace pre-order walk with verified `parentId`/`depth` per entry ([durable catalog note](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) owns the listing contract); discovery is a hint, never authority. `send_message` keeps its exact-direct-parent authority — only interrupt is ancestor-wide.
+The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` passes `exec.agent` as the `ancestor` authority and adds none of its own: the core primitive verifies live registry identity and recorded lineage, so the tool can name a direct child or a deeper descendant with the same generic `agent_id` parameter — deliberately not `subagent_id`, which would imply direct children only. Discovery rides `list_agents({ scope: 'descendants' })` over the `SubagentRuntime.listDescendants()` recursive catalog walk with `parentId`/`depth` per entry ([parent-catalog note](../architecture/2026-09-01-parent-owned-subagent-catalog.md) owns discovery); discovery is a hint, never authority. `send_message` keeps its exact-direct-parent authority — only interrupt is ancestor-wide.
## Testing
diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md
index ed4981bdac..0ef423905c 100644
--- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md
@@ -41,7 +41,7 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回
在 Web 侧,正在运行的 continuable child 使用相互独立的 Send 与 Stop 操作:客户端 `Session.cancel()` 将 Stop 路由到 `subagent.interrupt`(one-shot 地址保持不可取消,普通会话仍通过 `session.cancel` 保留既有的 primary Send/Stop 切换),同时 Send 按繁忙态 Enter 设置的模式投递后续消息——默认 Queue,选择后为 Steer——与 plain Enter 完全一致([繁忙态 Send 按钮](../bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。parent 离线但仍在运行的 continuable child 保留默认 composer,禁用输入区与 Send,但 Stop 仍然可达;停止后恢复为只读接管界面(周边目录与 composer 约定由 [Web subagent 对话](2026-07-27-web-subagent-conversations.zh.md)拥有)。
-`dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage,因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是新的 `SubagentRuntime.listDescendants()` 单次追踪 pre-order 遍历,每个条目带经校验的 `parentId`/`depth`(列表约定由[持久化目录 note](../../archived/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
+`dsh-tool-subagent-control` 中面向模型的 `interrupt_agent(agent_id)` 工具把 `exec.agent` 作为 `ancestor` 授权传入,自身不增加任何权限:核心原语校验在线注册表身份与记录的 lineage,因此该工具可以用同一个通用 `agent_id` 参数指定直接 child 或更深的后代——刻意不用会暗示仅限直接 child 的 `subagent_id`。发现依赖 `list_agents({ scope: 'descendants' })`,其底层是 `SubagentRuntime.listDescendants()` 递归目录遍历,每个条目带 `parentId`/`depth`(发现机制由[父目录 note](../architecture/2026-09-01-parent-owned-subagent-catalog.zh.md)拥有);发现只是提示,绝非权限。`send_message` 保持其确切直接 parent 权限——只有中断是 ancestor 级的。
## 测试
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
index cfa5b6c2ad..7c1c12f8bd 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml
@@ -10,7 +10,10 @@
zh: 91844972d41dd6f9
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
en: 125dea7427047966
- zh: 5d0bfd3921b38714
+ zh: 1e459b1106597695
+/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
+ en: 8ea2f91a914d540b
+ zh: cafada631044e43a
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
en: ac9b3d800889ea39
zh: 9db1065b6027f65f
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
index 65780b9bba..ecb2125d33 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md
@@ -12,6 +12,14 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor.
+## ACL diagnosis and recovery
+
+The bundled diagnosis skill runs its preserving ACL repairs outside the confined token because that token cannot inspect the conflicting objects. External PowerShell requires physical script files, so registration extracts resources from ASAR or SEA instead of exposing archive paths. The fiber owns that temporary copy. Recovery files live in the caller's persistent output directory so disposal of the skill does not remove the means to restore a DACL.
+
+A repair backs up before mutation and verifies effective access or the resulting ACEs. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed grant would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy.
+
+Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. The operator keeps those artifacts until verification of the original confined operation succeeds.
+
## How the restriction works (why no new identity)
The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
index fe9aadb97b..4c1e24bb6d 100644
--- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md
@@ -12,6 +12,15 @@ Status: implemented
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
+
+## ACL 诊断与恢复
+
+内置诊断技能在受限令牌之外执行保留原有权限的 ACL 修复,因为受限令牌无法检查发生冲突的对象。外部 PowerShell 需要实际脚本文件,因此注册时从 ASAR 或 SEA 提取资源,而不提供归档内路径。fiber 拥有该临时副本。恢复文件保存在调用者指定的持久输出目录中,确保技能释放后仍能恢复 DACL。
+
+修复在修改前备份,并验证有效权限或生成的 ACE。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:授权失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。
+
+提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。操作者应保留这些文件,直到原始受限操作验证成功。
+
## How the restriction works (why no new identity)
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
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-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml
index 490c2cd775..68731414ce 100644
--- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml
@@ -9,8 +9,8 @@
en: e22ee243b1503319
zh: b53c1a932d547f55
/agent-note-shared-developer-tool-settings/decision:
- en: 3fc14af35b025970
- zh: 8359262a5c5d3805
+ en: 62f71b49af7b9971
+ zh: f0f12da4cb0c7fc0
/agent-note-shared-developer-tool-settings/alternatives-considered:
en: df1b80e5b8d2a178
zh: 0136d16fc44d57bf
diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
index 5ae8861efa..4d9f0fea89 100644
--- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
+++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md
@@ -12,9 +12,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
The settings-owned `ctx.configForms.developerTools` preference uses a shared entry form and exposes one accepted `ui-settings.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
-Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
+Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and refuses the Agent-preset cards' selection, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
-Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
+Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the Agent-preset surfaces' `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md
index d6e8e138a3..ac5829ed95 100644
--- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md
+++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md
@@ -12,9 +12,9 @@ Status: implemented
设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
-关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换 agent 模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
+关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口并拒绝 Agent 预设卡片的选择操作,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
-插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
+插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、Agent 预设各界面读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
## Alternatives considered
diff --git a/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.i18n.yaml b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.i18n.yaml
new file mode 100644
index 0000000000..daa3d1e95a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-18-account-quota-top-up.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-18-account-quota-top-up.md
+/agent-note-account-quota-top-up-action:
+ en: c9f0d30bc28f815c
+ zh: 472505b18ee2e669
+/agent-note-account-quota-top-up-action/problem:
+ en: 4c0caf28d02faec2
+ zh: a6cc3e175a2f9f4a
+/agent-note-account-quota-top-up-action/decision:
+ en: d8046306e6488932
+ zh: a0d29f3949c96955
+/agent-note-account-quota-top-up-action/alternatives-considered:
+ en: ec6731c63d9c42ee
+ zh: 535b67062ce0269b
+/agent-note-account-quota-top-up-action/consequences:
+ en: 72febb3835a50018
+ zh: 0125b875f35b829e
diff --git a/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.md b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.md
new file mode 100644
index 0000000000..f48b12be29
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.md
@@ -0,0 +1,35 @@
+# Agent Note: Account quota top-up action
+
+Status: implemented
+
+English | [中文](2026-09-18-account-quota-top-up.zh.md)
+
+## Problem
+
+A provider quota failure can mean an exhausted first-party account balance, an API-key account problem, or a custom gateway response. All require a useful explanation, but only the first case can be repaired by opening the signed-in user's embedded DeepSeek recharge page. Inferring that case from provider prose or current UI configuration would make historical failures unstable and could expose an irrelevant billing action.
+
+## Decision
+
+The `llm-deepseek-account` plugin classifies quota failures for its own route. The credential callback it hands the shared Messages transport releases the account token only for the configured inference origin, and the plugin rewrites the provider-neutral `QUOTA` failure to `ACCOUNT_QUOTA` for both HTTP 402 responses and in-band SSE errors. The API-key plugin registers `deepseek-official` independently and keeps `QUOTA`; neither route borrows the other's credentials, and a separately configured API key does not affect account-route quota actions.
+
+A quota failure reaches the transcript and the frame-wide notice as two separate facts. The durable terminal-error record keeps the request-time code and always renders the same provider-neutral quota copy, so the Chat transcript, history paging, and the Session log do not depend on current credentials. A newly appended `QUOTA` or `ACCOUNT_QUOTA` failure in a Session this Client has bound and materialized also publishes one frame-wide notice; the newest notice replaces the previous one, and history replacement, pagination, and Sessions the Client never opened publish none.
+
+Chat owns the notice host and registers it into the frame-wide `shell.overlay` slot, so a notice outlives the Chat panel that reported it. The host offers the one live notice, with `code`, `message`, `dismiss`, and `keepOpen`, to its `shell.quota-notice` chain child, and renders a generic warning Toast when no entry claims the code. The account package claims `ACCOUNT_QUOTA` only, showing a Cancel/Top up Modal; without the shared host entry, or with a snapshot that reports no stored credential or a failed account stream, the entry renders the neutral Toast itself, and with that host entry it waits for the first account snapshot instead of rendering a Toast that its own timer could discard. The plugin keeps one shared `PlatformPages` request channel and one `shell.overlay` host entry for the Desktop host's single native view: the Account settings page, the quota notice, and the Desktop onboarding recharge page request a page there instead of mounting their own container, the latest request retires the previous owner before the newer page shows, the shared host only closes the page it is given, and each owner decides what its own return re-reads: returning from a top-up page on the Account settings page or the quota notice re-reads profile, balance, and unnotified bonus, while the onboarding recharge return re-reads profile and balance only. Superseding a page, releasing a request, plugin unload, and leaving usage read nothing, and a release arriving after a newer request or a dismissal clears nothing. Back restores focus before any deferred Modal takes it. While a page requested through that shared channel is showing, the native view covers the document, so the entry renders neither Modal nor Toast. A quota failure that arrives while a Settings-opened page is already showing is not retained: it stays subject to newest-notice replacement, and the Modal appears after the viewer returns while that notice is still current. That Modal reports the failure of the original request rather than the account's current balance: the top-up re-read is a separate read, and returning neither proves a successful payment nor dismisses the notice by itself. The notice's own Top up is the retained case: it retains the notice with `keepOpen` and requests the `top-up` page; `keepOpen` returns an identity-safe release, and the entry releases both the hold and its page request when it unmounts, so a claim cannot outlive the entry that took it. Opening the top-up page therefore keeps the notice and the page mounted through later quota failures, which are dropped rather than queued while their persistent failure rows still render. Dismissal or sign-out releases the hold and the page request, so the next live quota event publishes again. Chat's subscription to a Session binding's live events ends with that binding or with the plugin, whichever goes first.
+
+Credentials and billing URLs never enter Chat props or the Session log. Dismissing the notice does not retry the request; signing out closes an open account Modal or embedded page.
+
+## Alternatives considered
+
+**Derive eligibility entirely in the renderer.** Current endpoint, key, and login state may differ from the facts of a historical failed request. It would also couple Chat to credentials and provider configuration rather than keeping only a stable code at the presentation boundary.
+
+**Parse the provider error message in the UI.** Provider prose is not a stable contract, cannot distinguish API-key and account-token billing, and would duplicate adapter error classification.
+
+**Offer recharge for every quota failure.** This would send API-key and custom-gateway users to unrelated first-party billing. The generic notice remains useful without claiming a repair path.
+
+**Infer billing from global credentials.** A configured API key may belong to another account and may be unused by the failed request. The explicit provider route owns credential selection and quota classification.
+
+## Consequences
+
+The Session log distinguishes account balance failure from generic quota exhaustion while the transcript renders both with the same neutral copy. One notice serves the whole app, so it stays visible after the reporting Chat panel closes, and a newer notice supersedes an older one instead of stacking. Because the account package claims the notice through a chain child, Web and signed-out users see the neutral Toast and no billing affordance, and the account package never imports Chat. A notice retained by Top up keeps that entry's page mounted through later quota failures at the cost of dropping those notices; the persistent failure rows remain their record. One shared request channel serves both the Account settings page and the notice, so a newer request replaces the visible page while identity-checked releases keep a superseded owner from closing it.
+
+Provider tests cover routing with simultaneous credentials, HTTP 402 classification, and in-band SSE errors; presentation tests cover append-only publication, replacement, dismissal, Toast fallback, and embedded top-up navigation.
diff --git a/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.zh.md b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.zh.md
new file mode 100644
index 0000000000..965e246ecb
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-18-account-quota-top-up.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 账号欠费充值操作
+
+Status: implemented
+
+[English](2026-09-18-account-quota-top-up.md) | 中文
+
+## Problem
+
+提供方的额度错误可能表示第一方账号余额耗尽、API Key 账号问题,或自定义网关响应。所有情况都需要清晰提示,但只有第一种情况适合打开已登录用户的内嵌 DeepSeek 充值页。若根据提供方文案或当前 UI 配置推断,会导致历史错误随当前状态变化,并可能展示无关的充值操作。
+
+## Decision
+
+`llm-deepseek-account` 插件为本路由分类额度错误:它交给共享 Messages 传输的凭据回调只在配置的推理 origin 上释放账号 token,并把提供方中立的 `QUOTA` 错误改写为 `ACCOUNT_QUOTA`,同时覆盖 HTTP 402 响应与流内 SSE 错误。API Key 插件独立注册 `deepseek-official` 并保留 `QUOTA`;两条路由不会借用对方的凭据,单独配置的 API Key 也不影响账号路由的欠费操作。
+
+额度错误分别到达消息记录和全应用提示这两条路径。持久化的终止错误记录保留请求时的错误码,并始终渲染同一段提供方中立的欠费文案,因此 Chat 消息记录、历史分页和 Session 日志都不依赖当前凭据。本 Client 已绑定并已物化的 Session 中新追加的 `QUOTA` 或 `ACCOUNT_QUOTA` 错误还会发布一条全应用提示;最新提示替换上一条,历史替换、分页以及从未打开的 Session 都不发布提示。
+
+Chat 拥有提示宿主,并将其注册到全应用的 `shell.overlay` slot,因此提示的生命周期长于报告它的 Chat 面板。宿主把这条实时提示连同 `code`、`message`、`dismiss`、`keepOpen` 交给 `shell.quota-notice` chain 子 slot,没有 entry 认领该错误码时渲染通用警告 Toast。账号包只认领 `ACCOUNT_QUOTA`,显示带取消和去充值按钮的 Modal;缺少共享宿主条目,或快照报告未存储凭证、账号流失败且无快照时,由该条目自行渲染中立文案 Toast,而存在该宿主条目时它会等待首个账号快照,而不是渲染一个可能被自身计时器丢弃的 Toast。插件为 Desktop 宿主唯一的原生视图保留一个共享 `PlatformPages` 请求通道和一个 `shell.overlay` 宿主条目:Account 设置页、欠费提示与 Desktop 引导充值页都通过它申请页面,而不再各自挂载容器;最新请求会在新页面显示前退掉上一个持有者,而在更新请求或关闭之后到达的释放不会清除任何内容。共享宿主只关闭它接到的页面,每个持有者自行决定其返回时重读什么:Account 设置页或欠费提示的充值返回会重读资料、余额与未通知赠金,而引导充值返回只重读资料与余额。替换页面、释放请求、插件卸载与从用量页返回均不读取。返回时先恢复焦点,再由任何被延后的 Modal 接管。只要通过该共享请求通道请求的页面正在显示,该原生视图就会盖住本页,因此该条目既不渲染 Modal 也不渲染 Toast。在 Account 设置页已打开页面的情况下新到达的欠费不会被保持:它仍受最新提示替换规则约束,返回后若该提示仍是最新一条就显示 Modal。该 Modal 报告的是原始请求的失败而非账号当前余额:返回时的重读是一次独立读取,返回本身既不证明支付成功,也不会自动撤下该提示。提示自身的去充值属于被保持的情形:它用 `keepOpen` 保持住提示,并申请 `top-up` 页面;`keepOpen` 返回身份安全的释放函数,且该条目在卸载时同时释放保持与页面请求,因此声明不会比取得它的条目活得更久。打开充值页因此会让该提示与其页面在后续欠费期间保持挂载,这些欠费被丢弃而不排队,其持久化失败行仍然渲染。关闭或退出登录会释放保持与页面请求,使下一条实时欠费重新发布。Chat 对某个 Session binding 实时事件的订阅在 binding 或插件先结束的一侧终止。
+
+凭据和计费 URL 不会进入 Chat props 或 Session 日志。关闭提示不会重试请求;退出登录会关闭已打开的账号 Modal 或内嵌页面。
+
+## Alternatives considered
+
+**完全在 renderer 中推导资格。** 当前端点、密钥和登录状态可能已经不同于历史失败请求的事实;这也会让 Chat 依赖凭据和提供方配置,而不是在展示边界只接收稳定错误码。
+
+**在 UI 中解析提供方错误文案。** 提供方文案不是稳定契约,无法区分 API Key 和账号 token 的计费,同时会重复 adapter 的错误分类。
+
+**为所有额度错误提供充值。** 这会把 API Key 和自定义网关用户带到无关的第一方计费页面。普通提示已经足够,不应虚构修复路径。
+
+**根据全局凭据推断计费。** 已配置的 API Key 可能属于其他账号,也可能未被失败请求使用。明确的 provider 路由负责凭据选择和欠费分类。
+
+## Consequences
+
+Session 日志区分账号余额失败与普通额度耗尽,消息记录对两者渲染同一段中立文案。一条提示服务整个应用,因此在报告它的 Chat 面板关闭后仍然可见,且新提示取代旧提示而不是叠加。由于账号包通过 chain 子 slot 认领提示,Web 与未登录用户只看到中立 Toast,没有计费入口,账号包也无须导入 Chat。被去充值保持住的提示会让该条目的页面在后续欠费期间继续挂载,代价是丢弃这些提示;持久化失败行仍是它们唯一的记录。一个共享请求通道同时服务 Account 设置页与提示,因此更新的请求会替换可见页面,而身份校验过的释放使被取代的持有者无法关闭它。
+
+提供方测试覆盖双凭据并存时的路由、HTTP 402 分类和流内 SSE 错误;展示测试覆盖仅追加发布、替换、关闭、Toast 回退和内嵌充值页跳转。
diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml
new file mode 100644
index 0000000000..0e8511d871
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-20-focus-ring-brand-primary.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-20-focus-ring-brand-primary.md
+/agent-note-one-keyboard-focus-colour-for-every-control:
+ en: 967e19daff3df9bc
+ zh: 81e1a21d4204eb8a
+/agent-note-one-keyboard-focus-colour-for-every-control/problem:
+ en: 2f3f77b1de539959
+ zh: a4fa146b1d9ddecf
+/agent-note-one-keyboard-focus-colour-for-every-control/decision:
+ en: 613ec184df6bf5d5
+ zh: e4b3f95f4d33a54c
+/agent-note-one-keyboard-focus-colour-for-every-control/alternatives-considered:
+ en: 392968102d520d1d
+ zh: e7b7b68f145cf3d3
+/agent-note-one-keyboard-focus-colour-for-every-control/consequences:
+ en: 25b9933c3d43ad76
+ zh: 75d374ce3b7ec2eb
diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md
new file mode 100644
index 0000000000..8212eaf529
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.md
@@ -0,0 +1,43 @@
+# Agent Note: One keyboard-focus colour for every control
+
+Status: implemented
+
+English | [中文](2026-09-20-focus-ring-brand-primary.zh.md)
+
+## Problem
+
+Different explicit focus colours and browser-default outlines do not provide a consistent keyboard-focus cue. Browser defaults can also depend on the operating-system accent.
+
+Focus feedback has separate interaction and geometry constraints. A browser's `:focus-visible` heuristic can reveal a pointer-focused button after a key that does not move focus, while editable text controls need focus feedback on click. Containers that clip their contents can hide an outer ring.
+
+## Decision
+
+[The theme's focus stylesheet](../../../../packages/client/ui-theme/src/styles/focus.css) owns the shared ring colour and standard width. [Input modality](../../../../packages/client/ui-primitives/README.md#input-modality) distinguishes tooltip input from keyboard focus navigation.
+
+**One colour.** Global and component `:focus-visible` outlines and focus-ring shadows read `--dsw-focus-ring-color`, falling back to `--dsw-alias-state-business-primary`. The global fallback names the colour and the standard width but never the style, so it cannot create an outline where the component disables one. Naming the width is what keeps a control with no ring of its own at the standard geometry instead of Chromium's `auto 1px`.
+
+**Context-owned geometry.** `--dsw-focus-ring-width` carries the standard 2px width. Dense tables and toolbars may keep 1px so the ring does not dominate compact content. Components own their offsets and use an inset ring where an outer ring would be clipped.
+
+**Pointer focus does not imply keyboard navigation.** In pointer modality, `:focus-visible:not(:read-write)` controls use a transparent ring colour. Descendant and pseudo-element rings inherit it. Only focus-ring paint is suppressed: selected-state borders and elevation shadows remain, and editable text controls keep their own focus feedback on click. DOM focus stays on the control.
+
+Tooltips follow the last input and treat any key as keyboard input. Rings instead resume keyboard styling on non-composing navigation keys, or on focus reaching a different control after a non-composing key. Refocusing the same control is not navigation; pointer input, composition keys, and window blur clear the pending key.
+
+## Alternatives considered
+
+**Rewrite only the component declarations.** Explicit rings would agree, but controls using a browser-default outline would retain its colour. Requiring every control to declare its own ring would also leave new controls inconsistent until their authors add that rule.
+
+**Unify width and offset globally.** Positive offsets can be clipped by a scrolling ancestor, while a uniform thick ring can overwhelm dense content. Neither colour consistency nor input modality requires identical geometry.
+
+**Suppress by moving focus instead of by style.** Calling `blur()` on a pointer-focused button would hide its ring but also remove the target for subsequent Enter or Space activation.
+
+**Clear all shadows on pointer focus.** A `box-shadow` can contain elevation or selected-state paint as well as a focus ring. Suppressing its ring colour preserves those independent cues; exempting `:read-write` preserves text-entry feedback.
+
+**Replace every `outline: none` with a shared ring.** Some controls use an inset shadow or container-level focus treatment instead; some containers deliberately show no ring. Replacing those declarations would change their focus treatment, not merely its colour.
+
+**Give tooltips and rings the same modality rule.** A key can justify a tooltip on subsequent programmatic focus without justifying a ring on the same pointer-focused button. Keeping separate answers preserves both behaviours.
+
+## Consequences
+
+Focus rings share a theme colour while components retain their geometry and non-focus state cues. Ring visibility changes without changing the keyboard's active target. These rules do not guarantee identical browser rendering or eliminate component-specific clipping.
+
+[Stylesheet-order checks](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts), [focus-style checks](../../../../packages/client/ui-theme/tests/focus-ring-styles.client.spec.ts), and [input-modality tests](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts) cover stylesheet declarations and modality transitions. Static declarations and synthetic events do not establish rendered ring visibility or cross-platform behaviour.
diff --git a/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md
new file mode 100644
index 0000000000..479e2d0efd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-20-focus-ring-brand-primary.zh.md
@@ -0,0 +1,43 @@
+# Agent Note:所有控件共用一个键盘焦点颜色
+
+Status: implemented
+
+[English](2026-09-20-focus-ring-brand-primary.md) | 中文
+
+## 问题
+
+不同的显式焦点颜色与浏览器默认轮廓无法提供一致的键盘焦点提示。浏览器默认值还可能取决于操作系统强调色。
+
+焦点反馈另有交互与几何约束。浏览器的 `:focus-visible` 启发式可能在不移动焦点的按键之后显出指针聚焦按钮的环,而可编辑文本控件在点击时需要焦点反馈。裁切内容的容器可能隐藏外扩环。
+
+## 决策
+
+[主题焦点样式表](../../../../packages/client/ui-theme/src/styles/focus.css) 拥有共享焦点环颜色与标准宽度。[输入模态](../../../../packages/client/ui-primitives/README.zh.md#input-modality) 区分 tooltip 输入与键盘焦点导航。
+
+**一个颜色。** 全局与组件的 `:focus-visible` 轮廓和焦点环阴影读取 `--dsw-focus-ring-color`,回退到 `--dsw-alias-state-business-primary`。全局兜底声明颜色与标准宽度、但不声明样式,因此组件禁用轮廓时它不会创建轮廓。声明宽度可让没有自有焦点环的控件保持标准几何,而不是落到 Chromium 的 `auto 1px`。
+
+**场景决定几何。** `--dsw-focus-ring-width` 提供标准的 2px 宽度。密集表格与工具栏可以保留 1px,避免焦点环压过紧凑内容。组件拥有自己的 offset,并在外扩环会被裁切时使用内嵌环。
+
+**指针焦点不等于键盘导航。** 指针模态下,`:focus-visible:not(:read-write)` 控件使用透明的焦点环颜色。后代与伪元素上的环继承该值。只抑制焦点环绘制:选中态边框与 elevation 阴影保留,可编辑文本控件在点击时保留自身焦点反馈。DOM 焦点仍留在控件上。
+
+Tooltip 跟随最近一次输入,将任意按键视为键盘输入。焦点环则在非组合输入的导航键到达时,或非组合输入按键之后焦点到达不同控件时,恢复键盘样式。重新聚焦同一控件不算导航;指针输入、组合输入按键和 window blur 会清除待处理按键。
+
+## 考虑过的其他做法
+
+**只重写组件声明。** 显式焦点环会一致,但使用浏览器默认轮廓的控件仍会保留其颜色。要求每个控件自行声明焦点环,也会让新控件在作者补上规则之前保持不一致。
+
+**全局统一宽度与 offset。** 正 offset 可能被滚动祖先裁切,统一的粗环则可能压过密集内容。颜色一致性和输入模态都不要求相同的几何。
+
+**用移动焦点取代样式抑制。** 对指针聚焦的按钮调用 `blur()` 可以隐藏焦点环,但也会移除后续 Enter 或 Space 激活的目标。
+
+**指针聚焦时清除所有阴影。** 一个 `box-shadow` 除了焦点环,还可能包含 elevation 或选中态绘制。抑制其焦点环颜色可保留这些独立提示;豁免 `:read-write` 可保留文本输入反馈。
+
+**把所有 `outline: none` 改为共享焦点环。** 一些控件改用内嵌阴影或容器级焦点处理;一些容器则刻意不显示焦点环。替换这些声明会改变焦点处理方式,而不只是颜色。
+
+**让 tooltip 与焦点环共用同一条模态规则。** 按键可以让后续程序化聚焦显示 tooltip,却不一定需要在同一个指针聚焦按钮上显示焦点环。分别记录两个答案可保留两种行为。
+
+## 影响
+
+焦点环共享主题颜色,组件保留自己的几何与非焦点状态提示。焦点环可见性的变化不改变键盘的活动目标。这些规则不保证浏览器渲染完全相同,也不会消除组件特有的裁切。
+
+[样式表顺序检查](../../../../packages/client/ui-theme/tests/client-styles.client.spec.ts)、[焦点样式检查](../../../../packages/client/ui-theme/tests/focus-ring-styles.client.spec.ts)与[输入模态测试](../../../../packages/client/ui-primitives/tests/input-modality.client.spec.ts)覆盖样式声明和模态转换。静态声明与合成事件不能确认焦点环的实际渲染可见性或跨平台行为。
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/feature/2026-09-24-fixed-rate-floor-one-minute.i18n.yaml b/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.i18n.yaml
new file mode 100644
index 0000000000..7ed8eaaa55
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-24-fixed-rate-floor-one-minute.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-fixed-rate-floor-one-minute.md
+/agent-note-one-minute-fixed-rate-floor:
+ en: d74434992e0ca252
+ zh: cae88d61368d5555
+/agent-note-one-minute-fixed-rate-floor/problem:
+ en: a16373df16ee9126
+ zh: 10e86f4cfe0e9a11
+/agent-note-one-minute-fixed-rate-floor/decision:
+ en: fb49d104c6539934
+ zh: 04655695ccbb58c6
+/agent-note-one-minute-fixed-rate-floor/alternatives-considered:
+ en: 080ae22387dd35b7
+ zh: 8247747dc444d7a4
+/agent-note-one-minute-fixed-rate-floor/consequences:
+ en: c11eac4968a2a707
+ zh: c4a02ddeef3a5dff
diff --git a/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.md b/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.md
new file mode 100644
index 0000000000..77fffc7813
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.md
@@ -0,0 +1,29 @@
+# Agent Note: One-minute fixed-rate floor
+
+Status: implemented
+
+English | [中文](2026-09-24-fixed-rate-floor-one-minute.zh.md)
+
+## Problem
+
+`every_seconds` refused every interval below 300 seconds, and the Web client restated that bound as its own per-unit floors of 1 hour, 5 minutes, or 300 seconds. The same product already offered a `cron` selector whose finest granularity is one minute, so two selectors that both describe a repeating cadence disagreed about the shortest one a person could ask for: a reminder that repeats every minute was expressible as `cron` and rejected as `every_seconds`. The floor also made a shipped default Web surface awkward to try, because the first repeating reminder anyone creates cannot come back in under five minutes.
+
+## Decision
+
+`MIN_EVERY_INTERVAL_SECONDS` in `packages/schedule/schedule/src/domain.ts` is 60. The Host decoder, the create and update validation, the `every_seconds` tool parameter description, and its refusal message all read that constant, so one change moves the bound everywhere the Host states it.
+
+The client keeps its own `MIN_INTERVAL_SECONDS = 60`, which the Run time card uses for the seconds row and to derive the per-unit floors: 1 hour, 1 minute, or 60 seconds. The localized hint and refusal copy states 1 minute and 60 seconds for those rows.
+
+One minute is the shortest cadence that does not need a new protocol decision: it matches the cron selector's granularity, and every accepted value remains a whole-second interval the durable record already stores. A stored record keeps whatever the Host accepted when it was created; a record written under the old bound (300 seconds or more) stays valid and replayable, and no migration runs because the previous bound rejected anything below it.
+
+## Alternatives considered
+
+**Keep the 300-second floor.** The two selectors would keep disagreeing about the shortest repeating cadence, and a person could still reach a one-minute cadence only through `cron`.
+
+**Lower the bound to one second.** Sub-minute intervals multiply durable follow-up messages and timer re-anchoring per reminder without a request behind them; the minute granularity already covers the shortest cadence the product names elsewhere.
+
+## Consequences
+
+- A fixed-rate reminder may now repeat every minute; the smallest accepted value is model-visible in the `every_seconds` description and in the client's hint copy.
+- Delivery behavior, batching, catch-up, and the interval arithmetic are unchanged: an accepted interval still anchors to creation or to the accepted save, and each overdue record still contributes only its latest occurrence.
+- A deployment cannot raise the floor through configuration; the bound is a protocol constant, so a deployment that needs a longer minimum enforces it in the interface that creates tasks.
diff --git a/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.zh.md b/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.zh.md
new file mode 100644
index 0000000000..6d103be09b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-24-fixed-rate-floor-one-minute.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: 固定速率下限改为 1 分钟
+
+Status: implemented
+
+[English](2026-09-24-fixed-rate-floor-one-minute.md) | 中文
+
+## Problem
+
+`every_seconds` 会拒绝所有低于 300 秒的间隔,Web 客户端也把该下限复述为自己按单位计算的下限:1 小时、5 分钟或 300 秒。同一产品已经提供粒度为 1 分钟的 `cron` 选择器,于是两个都在描述重复节奏的选择器对「最短能填多少」给出了不同答案:每分钟重复一次的提醒用 `cron` 可以表达,用 `every_seconds` 会被拒绝。该下限也让默认发布 Schedule 的 Web 界面难以试用——任何人创建的第一条重复提醒都不可能在五分钟内回到。
+
+## Decision
+
+`packages/schedule/schedule/src/domain.ts` 的 `MIN_EVERY_INTERVAL_SECONDS` 为 60。宿主解码器、创建与更新校验、`every_seconds` 工具参数描述及其拒绝消息都读取该常量,因此一处改动即可移动宿主陈述该下限的所有位置。
+
+客户端保留自己的 `MIN_INTERVAL_SECONDS = 60`,「运行时间」卡片用它校验秒数行,并据此推导各单位的数量下限:1 小时、1 分钟或 60 秒。对应行的本地化提示与拒绝文案改为 1 分钟与 60 秒。
+
+1 分钟是无需新增协议决策的最短节奏:它与 cron 选择器的粒度一致,且每个被接受的值仍是以整秒表示的间隔,持久记录本来就以整秒存储。已存储的记录保留宿主当初接受的值:按旧下限(300 秒及以上)写入的记录仍然有效、仍可回放;由于旧下限拒绝了低于它的值,因此不存在需要迁移的数据。
+
+## Alternatives considered
+
+**保留下限 300 秒。** 两个选择器会继续对最短重复节奏给出不同答案,而每分钟一次的节奏仍只能通过 `cron` 达到。
+
+**把下限降到 1 秒。** 低于分钟级的间隔会让每条提醒产生更多持久 follow-up 消息与 timer 重新对齐,而背后没有这样的需求;分钟粒度已经覆盖产品在别处命名的最短节奏。
+
+## Consequences
+
+- 固定速率提醒现在可以每分钟重复一次;最小可接受值对模型可见,出现在 `every_seconds` 的描述与客户端的提示文案中。
+- 投递行为、批处理、补跑与间隔运算法则不变:被接受的间隔仍以创建时刻或接受保存的时刻为起算点,每条逾期记录仍只贡献最新一次发生时点。
+- 部署无法通过配置提高该下限;它是协议常量,需要更长最小间隔的部署应在创建任务的界面上自行约束。
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
index 42a4a26dc0..c161ba26e8 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml
@@ -9,11 +9,11 @@
en: 4fb1c27a631b32d3
zh: 9dbe9a4b9aadfca9
/agent-note-serial-cross-platform-ci-reference/decision:
- en: f547014d316c1385
- zh: f13b09a4a778adaa
+ en: 7c58d88d4c002f19
+ zh: a3b961be49f2a96b
/agent-note-serial-cross-platform-ci-reference/alternatives-considered:
- en: bc72a1e194e3b0e2
- zh: 9a92cbac88adc7a7
+ en: 3610152c5c4e93af
+ zh: f4c09d327716f750
/agent-note-serial-cross-platform-ci-reference/consequences:
en: 7042125ce6a209d4
zh: ef2be4fa8a4d6320
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
index 24022fea27..ca2b47c1d0 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
@@ -24,7 +24,7 @@ Platform ownership remains explicit inside that complete aggregate. `terminal-ba
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
-The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
+The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
@@ -35,7 +35,7 @@ The active serial references run on the self-hosted `vm-backup` (`serial / linux
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and native Windows checks, and the other native jobs supply the complete Windows result.
-- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
+- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
index c9fbc84d8f..4feb6c3ae8 100644
--- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
+++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md
@@ -24,7 +24,7 @@ Status: implemented
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也涵盖从交互式子进程继承而来的标记。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
-独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
+独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
@@ -35,7 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和原生 Windows 检查,而其它原生作业提供完整的 Windows 结果。
-- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
+- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
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/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
index dbe4c5aaab..f5e57341df 100644
--- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml
@@ -9,8 +9,8 @@
en: dc43ba84345e1405
zh: dbc03572ab7710d2
/agent-note-ship-optional-bundles-with-the-installation/decision:
- en: 84777110aa68ecf8
- zh: b109275cfddb73b0
+ en: d64020c4ca4957e1
+ zh: 241b3b851234b6f0
/agent-note-ship-optional-bundles-with-the-installation/alternatives-considered:
en: 05c19f616009bee8
zh: af318640481351f0
diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
index c9f560f5c0..059fe5bcce 100644
--- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
+++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md
@@ -14,7 +14,7 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts
Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed.
-The [single-bundle decision](../architecture/2026-09-18-agent-teams-single-bundle.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle.
+The [single-bundle decision](../architecture/2026-09-18-agent-teams-single-bundle.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy; the [optional-bundle decision for experimental capabilities](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md) names the current list and the display metadata each entry carries.
## Alternatives considered
diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
index e91b6580e2..9847095601 100644
--- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
+++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md
@@ -14,7 +14,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样
默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。
-[单组合包决策](../architecture/2026-09-18-agent-teams-single-bundle.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。
+[单组合包决策](../architecture/2026-09-18-agent-teams-single-bundle.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略;[实验能力的可选 bundle 决策](../architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)列出当前名单以及每一项携带的显示元数据。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml
new file mode 100644
index 0000000000..c8ae9617c4
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-28-dual-release-install-layout-work-budget.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/process/2026-09-28-dual-release-install-layout-work-budget.md
+/agent-note-dual-release-install-layout-work-budget:
+ en: 808f7c1d11ce15b5
+ zh: 861c63dd2de66452
+/agent-note-dual-release-install-layout-work-budget/problem:
+ en: 46916488210ba493
+ zh: 98107c3805a22b2c
+/agent-note-dual-release-install-layout-work-budget/decision:
+ en: c432b68c4ad1b3c1
+ zh: a11e958fa68dc2f3
+/agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs:
+ en: 36d3e3ef39cbba2f
+ zh: 2480acf6260817b3
+/agent-note-dual-release-install-layout-work-budget/alternatives-considered:
+ en: 29987b7634c84d28
+ zh: a1469e4b6aaa46e2
+/agent-note-dual-release-install-layout-work-budget/consequences:
+ en: 8dffc0c3fa699980
+ zh: 99ec25001e5aa716
diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md
new file mode 100644
index 0000000000..24e2ae8fe7
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md
@@ -0,0 +1,50 @@
+# Agent Note: Dual-release install layout work budget
+
+Status: implemented
+
+English | [中文](2026-09-28-dual-release-install-layout-work-budget.zh.md)
+
+## Problem
+
+The `Dependency layout` lane packs two incompatible synthetic DSH releases into a temporary consumer, runs one real npm resolution over them, and asserts the physical placement npm chose. It decided pass or fail with `TIMEOUT_MS = 300_000` on the npm child process. That deadline measures the runner, not the graph: the same graph resolves in 137-205 s on an idle developer host and in 288.59 s on the CI runner, 4% below the deadline, while the placement assertion reported no error. The diagnostic named only elapsed time, so a slow runner and a graph that had grown were indistinguishable.
+
+## Decision
+
+`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
+
+The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
+
+This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it.
+
+## What the resolution costs
+
+Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`:
+
+| Phase | Cost |
+|---|---|
+| Registry synthesis (`buildRegistryIndex`, 3952 installed manifests plus 319 DSH workspace manifests into 1544 names) | 1.67 s |
+| npm child process | 180.66 s of 185.07 s end to end |
+| `idealTree:buildDeps` inside that child | 153.30 s, and nearly all of it lands in 14 of 877 per-node timers |
+| `checkCanPlace` / `canPlacePeers` peer-conflict analysis | 117.3 s, of which `new URL()` 65.9 s and `SemVer` 27.9 s |
+| Registry traffic | 682 requests, 383 KB of packument bodies |
+| Layout assertion and process startup | 4.4 s |
+
+The same graph resolved in 1.12 s with `--legacy-peer-deps`, so peer placement is 152.7 s of the 153.8 s. `canPlacePeers` re-checks each internal edge against the incoming edges of its peer target, which makes the cost grow with placements times edge count; the nested release's copy of `@deepseek-ai/dsh-base` alone takes 48.8 s against 1.3 s for the identical package at the root of the same tree. About three quarters of the verified internal edges are peer edges (1874 of 2524), so this work is the assertion, not overhead around it.
+
+Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time, a factor of 1.5 with no input change, which is why a wall-clock threshold cannot separate a slow host from a larger graph.
+
+## Alternatives considered
+
+**Reuse one npm cache directory across runs.** The lane's `npm_config_cache` lives inside the temporary consumer, and the registry listens on an ephemeral port, so `make-fetch-happen` keys every cache entry as `http://127.0.0.1:/` and a persisted cache cannot hit. Re-running the full graph warm with `--prefer-offline` over the cold run's 4.4 MB cache produced a byte-identical package lock in 189.8 s, against 153.8 s cold: correct, and slower. The profile's 10.2 s of idle time is the ceiling on any network saving, because npm spends the rest of the run in its own placement code.
+
+**Merge or parallelize the two releases.** The lane already resolves both releases in one npm invocation, which is what makes the co-existence claim checkable, and one release cannot reuse the other's peer analysis because `canPlacePeers` examines the whole tree. Splitting the graph into two independent resolutions would remove the shared-Cordis and cross-release placement facts the assertion exists to check.
+
+**Raise `TIMEOUT_MS`.** A larger deadline leaves the failure mode in place: the verdict still depends on runner speed, and a graph that grows until it is slow on every machine still fails with no measurement of what grew.
+
+**`--prefer-dedupe`.** It resolves the same graph in 126.8 s but chooses a different placement (809 lock entries against 815, eight differing entries), so the lane would assert a non-default npm placement.
+
+**`--legacy-peer-deps`.** It resolves in 1.12 s and drops the 52 peer-installed packages, along with the peer edges that make up most of the assertion's coverage.
+
+## Consequences
+
+The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md
new file mode 100644
index 0000000000..03380bca7a
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md
@@ -0,0 +1,50 @@
+# Agent Note: 双发布安装布局的工作量预算
+
+Status: implemented
+
+[English](2026-09-28-dual-release-install-layout-work-budget.md) | 中文
+
+## Problem
+
+`Dependency layout` lane 把两个互不兼容的合成 DSH 发布打进临时消费者目录,对它们跑一次真实 npm 解析,并断言 npm 选定的物理摆放。它用 npm 子进程上的 `TIMEOUT_MS = 300_000` 判定通过或失败。这个期限度量的是 runner,而不是图:同一张图在空闲开发机上用 137–205 秒解析完成,在 CI runner 上用 288.59 秒,比期限低 4%,而摆放断言没有报告任何错误。诊断信息只给出耗时,因此 runner 慢和依赖图变大无法区分。
+
+## Decision
+
+`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
+
+npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
+
+这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。
+
+## What the resolution costs
+
+2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得:
+
+| 阶段 | 开销 |
+|---|---|
+| Registry 合成(`buildRegistryIndex`,3952 个已安装 manifest 加 319 个 DSH workspace manifest,合成 1544 个包名) | 1.67 s |
+| npm 子进程 | 端到端 185.07 s 中的 180.66 s |
+| 该子进程内的 `idealTree:buildDeps` | 153.30 s,且几乎全部落在 877 个按节点计时器中的 14 个上 |
+| `checkCanPlace` / `canPlacePeers` peer 冲突分析 | 117.3 s,其中 `new URL()` 65.9 s、`SemVer` 27.9 s |
+| Registry 流量 | 682 次请求、383 KB packument 响应体 |
+| 布局断言与进程启动 | 4.4 s |
+
+同一个依赖图加上 `--legacy-peer-deps` 后 1.12 s 解析完成,因此 peer 摆放占 153.8 s 中的 152.7 s。`canPlacePeers` 会把每条内部边与其 peer 目标的入边重新核对一遍,这使开销随「摆放次数 × 边数」增长;嵌套发布的 `@deepseek-ai/dsh-base` 副本单独耗时 48.8 s,而同一棵树上根位置的同一包只耗时 1.3 s。被校验的内部边约有四分之三是 peer 边(2524 条中的 1874 条),所以这部分工作是断言本身,而不是断言之外的开销。
+
+同一台主机上对同一张图的重复运行,npm 耗时在 137.8 s 到 204.8 s 之间波动,输入没有任何变化却有 1.5 倍差距,这正是墙钟阈值无法区分「主机慢」和「图更大」的原因。
+
+## Alternatives considered
+
+**跨次复用同一个 npm cache 目录。** lane 的 `npm_config_cache` 位于临时消费者目录内,registry 监听临时端口,因此 `make-fetch-happen` 把每个 cache 条目键为 `http://127.0.0.1:/`,持久化的 cache 无法命中。用冷启动那次留下的 4.4 MB cache 加 `--prefer-offline` 重跑整张图,产出的 package lock 逐字节相同,耗时 189.8 s,而冷启动为 153.8 s:结果正确,但更慢。profile 中 10.2 s 的空闲时间是任何网络节省的上限,因为 npm 把其余时间都花在自己的摆放代码里。
+
+**合并或并行两个发布。** lane 已经在一次 npm 调用里解析两个发布,这正是共存声明可被校验的原因;而 `canPlacePeers` 会检查整棵树,一个发布无法复用另一个的 peer 分析。把图拆成两次相互独立的解析会丢掉断言存在的理由:共享 Cordis 与跨发布摆放。
+
+**调大 `TIMEOUT_MS`。** 更大的期限保留了原有失效模式:判定仍取决于 runner 速度,而一个增长到在任何机器上都慢的图仍会失败,且不给出任何关于「什么变大了」的测量。
+
+**`--prefer-dedupe`。** 它用 126.8 s 解析同一张图,但选择了不同的摆放(809 条 lock 条目对 815 条,8 条条目不同),因此 lane 断言的将是非默认的 npm 摆放。
+
+**`--legacy-peer-deps`。** 它 1.12 s 完成解析,但丢掉了 52 个由 peer 安装的包,也丢掉了构成断言大部分覆盖面的 peer 边。
+
+## Consequences
+
+lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml
new file mode 100644
index 0000000000..8f91e16be8
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-26-web-conversation-clock-fixture-day.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/testing/2026-09-26-web-conversation-clock-fixture-day.md
+/agent-note-web-conversation-clocks-on-the-fixture-day:
+ en: 13b5d9d0bb3626af
+ zh: 0bd01e1848284a35
+/agent-note-web-conversation-clocks-on-the-fixture-day/problem:
+ en: 6f334488a6d3a28d
+ zh: b33aed9b10d0cd84
+/agent-note-web-conversation-clocks-on-the-fixture-day/decision:
+ en: ba875c82795d58e1
+ zh: 13a7df6f8c4e0aa6
+/agent-note-web-conversation-clocks-on-the-fixture-day/alternatives-considered:
+ en: 9c0a2afb1473c1d1
+ zh: 36c9e72a70072f5e
+/agent-note-web-conversation-clocks-on-the-fixture-day/verification:
+ en: 5031ca866fed24dd
+ zh: c11f71af70c1f5ec
+/agent-note-web-conversation-clocks-on-the-fixture-day/consequences:
+ en: c5cdbbd3b2889df7
+ zh: 8458ef92443cf1a6
diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md
new file mode 100644
index 0000000000..8b572d43f3
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.md
@@ -0,0 +1,47 @@
+# Agent Note: Web conversation clocks on the fixture day
+
+Status: implemented
+
+English | [中文](2026-09-26-web-conversation-clock-fixture-day.zh.md)
+
+## Problem
+
+Run 36157253018 (job 108144715986, `node 24 / snapshots and artifacts`) failed four browser scenarios in one pass — [steering](../../../../apps/web/tests/steering.e2e.ts), [markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts), [message-actions](../../../../apps/web/tests/message-actions.e2e.ts), and [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts) — with one aria difference: a message rendered `… and stop. 9/25 23:58` where its golden records `… and stop. {{clock}}`.
+
+[`newEnglishPage`](../../../../apps/web/tests/support.ts) pins the browser to `Asia/Shanghai`, and [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) prefixes `clock.md` / `clock.ymd` to the clock as soon as the message's local calendar day or year differs from the renderer's. These scenarios left both clocks on the wall clock, and their message times are stamped during the run: a live composer send by the Host, a seed whose fixture header carries `createdAt: 0` anchored at `Date.now() - 60_000` by `seedSession`. The failing job started at 15:54Z, six minutes before local midnight, so rows stamped before it were captured after it and rendered a date prefix; the next run of the same scenario passed minutes after midnight.
+
+The lane already carried the remedy — [`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts), documented as the same-day anchor for seeded event times and the `Asia/Shanghai` browser clock, and used by seven other scenarios — but these four scenarios never adopted it.
+
+## Decision
+
+The four scenarios read the fixture day on both clocks.
+
+Live-stamped scenarios pin the Host clock with `pinHostClock()`, a `Date.now` spy advancing from the anchor with real elapsed time, and the browser clock with [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts). Seeded scenarios anchor the seed through `seedSession`'s `createdAt` argument and pin the browser clock the same way.
+
+The seed anchor is not only the rendered clock: the sidebar's relative ages are computed against the browser clock, so a pinned browser with a live-anchored seed reads `8mo` where the [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) fork golden asserts `now` and `1min`.
+
+`pinBrowserClock` re-applies one frozen instant from the anchor on an interval instead of leaving it at a single value, because product code reads two `Date.now()` values to decide how long a rendered value is held: the step process keeps its previous title for `PROCESS_TITLE_MINIMUM_MS` = 150 ms ([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)) and the composer keymap keeps a composition guard 10 ms past `compositionend` ([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts)). Both compute the remaining hold as `Date.now() - previousReading`, which a permanently frozen clock never advances, so the hold never elapses and the golden's settled text stays unrendered. CI run 36179748541 (job 108218939163) caught exactly that on the first revision that froze the clock: `Preparing questions` where the steering mid golden records `Waiting for your action · Ready to continue?`.
+
+The steering mid capture waits for that settled question title before it captures, and the [whole-queue scenario](../../../../apps/web/tests/steering.e2e.ts) calls the queue gesture only after the running turn's Stop control appears, so Enter's queue decision always resolves against an agent that already reports busy.
+
+## Alternatives considered
+
+**Collapse the rendered date prefix in the aria normalizer.** Collapsing `9/25 23:58` to `{{clock}}` in `normalizeAria` fixes the four scenarios with a two-line change, and it was measured: it passed the four files and then failed `cordis-tool-round`, `stats-paged-history`, and `navigation-panes` in one `DSH_WEB_SNAPSHOT_WORKERS=16` lane run, because their goldens deliberately record the dated form (`9/1 {{clock}}` and the year form in the Timing pane), and `skill-tool-row` already tokenizes that form as `{{date}} {{clock}}`. The two renderings state different facts — this row is from another day, this row is from today — so collapsing them deletes assertions instead of stabilizing them.
+
+**Leave the browser clock frozen.** Pinning one instant for the whole scenario is simpler and passed the four files, but it removes elapsed time from every two-reading hold in the page, which is how the `Preparing questions` capture above happened. Re-applying the anchor keeps the calendar pinned without changing how long a held value is held.
+
+**Pin the clocks for the whole lane.** Pinning inside `launchWebScaffold` and `newEnglishPage` would cover every scenario at once, including the ones that fail this way in future. It loses on blast radius: 83 goldens carry a clock and session-tree goldens assert literal relative ages, so a lane-wide now moves every scenario away from its own session and fixture times simultaneously, and each would still need its own anchor to read the buckets it was recorded with.
+
+**Keep scenarios clear of local midnight.** Scheduling or refusing runs near the boundary leaves the dependency in place and fails the run instead of the code under test. The [CI readiness and completion decision](2026-09-08-ci-readiness-and-completion.md) already rejects budgets and retries as substitutes for a controlled observation.
+
+## Verification
+
+Two negative controls pin one clock and leave the other on the wall clock, reproducing the recorded signature exactly: `steering` with the browser clock pinned and the Host clock live fails its two golden tests with `… and stop. 9/26 {{clock}}`, and `message-actions` with the browser clock pinned and a live-anchored seed fails with `9/26 {{clock}}` on every clock line plus `now` where the fork golden asserts `1min`. Pinning both clocks passes both files, and their goldens are unchanged.
+
+The queue scenario's start race is measured, not inferred: with the clocks pinned and no busy observation it failed 10 times in 61 runs of `vitest run … -t "queues two messages"`, all at the same `2 queued messages` wait; `origin/master` without the pins failed 0 of 41; with the observation added, 20 of 20 passed.
+
+CI run 36179748541 (job 108218939163) shows the calendar fix holding under the lane's own topology: the prompt line matches `{{clock}}` where run 36157253018 rendered `9/25 {{clock}}`.
+
+## Consequences
+
+The four scenarios render the same aria line whenever they run, and their goldens keep asserting a clock without a date prefix. The calendar fact is owned per scenario: a scenario that types messages, or seeds a fixture whose header carries `createdAt: 0`, must read the fixture day on both clocks, or it inherits the midnight exposure. Product clock rendering, the `Asia/Shanghai` browser timezone, every committed golden, and the assertions of the scenarios not listed here are unchanged.
diff --git a/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md
new file mode 100644
index 0000000000..4631d36b8a
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-26-web-conversation-clock-fixture-day.zh.md
@@ -0,0 +1,47 @@
+# Agent Note: Web conversation clocks on the fixture day
+
+Status: implemented
+
+[English](2026-09-26-web-conversation-clock-fixture-day.md) | 中文
+
+## Problem
+
+运行 36157253018(作业 108144715986,`node 24 / snapshots and artifacts`)在一次执行中让四个浏览器场景失败——[steering](../../../../apps/web/tests/steering.e2e.ts)、[markdown-images](../../../../apps/web/tests/markdown-images.e2e.ts)、[message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 与 [reference-composer](../../../../apps/web/tests/reference-composer.e2e.ts)——且是同一处 aria 差异:消息渲染为 `… and stop. 9/25 23:58`,而 golden 记录为 `… and stop. {{clock}}`。
+
+[`newEnglishPage`](../../../../apps/web/tests/support.ts) 将浏览器时区固定为 `Asia/Shanghai`,而 [`formatMessageClock`](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 一旦消息的本地日历日或年份与渲染时不同,就会在时钟前拼接 `clock.md` / `clock.ymd`。这些场景把两个时钟都留在墙上时钟上,而它们的消息时间是在运行期盖章的:实时输入由 Host 盖章,fixture 头部为 `createdAt: 0` 的种子由 `seedSession` 以 `Date.now() - 60_000` 为锚点。失败的作业于 15:54Z 启动,距本地午夜还有六分钟,于是午夜前盖章的行在午夜后捕获并渲染出日期前缀;同一场景的下一次运行在午夜后数分钟通过。
+
+通道本已具备对策——[`WEB_FIXTURE_TIME`](../../../../apps/web/tests/support.ts),其文档写明它是种子事件时间与 `Asia/Shanghai` 浏览器时钟的同日锚点,另有七个场景在使用它——但这四个场景从未采用。
+
+## Decision
+
+这四个场景让两个时钟都读取 fixture 日。
+
+实时盖章的场景用 `pinHostClock()` 固定 Host 时钟(一个从锚点起按真实流逝时间推进的 `Date.now` spy),并用 [`pinBrowserClock(page)`](../../../../apps/web/tests/support.ts) 固定浏览器时钟。种子场景通过 `seedSession` 的 `createdAt` 参数锚定种子,并用同样方式固定浏览器时钟。
+
+种子锚点不只影响渲染出的时钟:侧边栏的相对时间也以浏览器时钟为基准计算,因此固定浏览器却用实时锚点的种子会读出 `8mo`,而 [message-actions](../../../../apps/web/tests/message-actions.e2e.ts) 的 fork golden 断言的是 `now` 与 `1min`。
+
+`pinBrowserClock` 按固定间隔从锚点重新施加同一个冻结时刻,而不是让时钟停在单一数值上,因为产品代码要靠读取两个 `Date.now()` 值来决定一个已渲染值要保持多久:步骤进程会把上一个标题保持 `PROCESS_TITLE_MINIMUM_MS` = 150 ms([ChatGroupSeat](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx)),输入框 keymap 会在 `compositionend` 后再保持 10 ms 的合成守卫([keymap](../../../../packages/client/ui-conversation/src/client/input/editor/keymap.ts))。两者都把剩余保持时间算作 `Date.now() - previousReading`,而永久冻结的时钟永远不会让它推进,于是保持期不会结束,golden 里那行稳定文案始终不出现。CI 运行 36179748541(作业 108218939163)在第一个冻结时钟的修订上正是抓到了这一点:steering 的 mid golden 记录的是 `Waiting for your action · Ready to continue?`,实际捕获到 `Preparing questions`。
+
+steering 的 mid 捕获在截图前等待该稳定标题出现;[整队场景](../../../../apps/web/tests/steering.e2e.ts) 则只在运行中回合的停止控件出现后才做排队手势,因此 Enter 的排队判定总是相对一个已上报忙碌的 agent 解析。
+
+## Alternatives considered
+
+**在 aria normalizer 中折叠渲染出的日期前缀。** 在 `normalizeAria` 中把 `9/25 23:58` 折叠为 `{{clock}}` 只需两行即可修好这四个场景,且已实测:它让四个文件通过,随后在一次 `DSH_WEB_SNAPSHOT_WORKERS=16` 的通道运行中让 `cordis-tool-round`、`stats-paged-history` 与 `navigation-panes` 失败,因为这些 golden 有意记录带日期的形式(`9/1 {{clock}}`,以及 Timing 面板中的年份形式),而 `skill-tool-row` 早已把该形式 token 化为 `{{date}} {{clock}}`。两种渲染陈述的是不同事实——此行来自另一天,此行来自今天——因此折叠它们是在删除断言,而不是在稳定断言。
+
+**让浏览器时钟保持冻结。** 整个场景固定单一时刻更简单,也能让四个文件通过,但它抽掉了页面中所有“读两次”保持逻辑的时间流逝,上面的 `Preparing questions` 捕获就是这样发生的。重新施加锚点既保持日历固定,又不改变已渲染值的保持时长。
+
+**为整条通道固定时钟。** 在 `launchWebScaffold` 与 `newEnglishPage` 内固定时钟可以一次覆盖所有场景,包括将来以同样方式失败的场景。它因影响面而落选:83 个 golden 带有时钟,且会话树 golden 断言字面相对时间,因此通道级的 now 会同时把所有场景移离各自的会话与 fixture 时间,而每个场景仍需自己的锚点才能读出录制时的分桶。
+
+**让场景避开本地午夜。** 在边界附近调度或拒绝运行会把依赖留在原处,并让运行而非被测代码失败。[CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 已经拒绝用预算或重试替代受控观察。
+
+## Verification
+
+两个负向对照各固定一个时钟、把另一个留在墙上时钟,精确复现了记录到的特征:`steering` 固定浏览器时钟而 Host 时钟实时时,两个 golden 用例以 `… and stop. 9/26 {{clock}}` 失败;`message-actions` 固定浏览器时钟但仍用实时锚点的种子时,每行时钟都以 `9/26 {{clock}}` 失败,且本应断言 `1min` 的 fork golden 读出 `now`。两个时钟都固定后两个文件均通过,且它们的 golden 未改动。
+
+排队场景的启动竞态是实测而非推断:固定时钟且不等待忙碌状态时,`vitest run … -t "queues two messages"` 在 61 次中失败 10 次,全部停在同一个 `2 queued messages` 等待上;未加固定时钟的 `origin/master` 在 41 次中失败 0 次;加上该观察后 20 次全部通过。
+
+CI 运行 36179748541(作业 108218939163)显示日历修复在通道自身的拓扑下成立:提示行匹配 `{{clock}}`,而运行 36157253018 渲染的是 `9/25 {{clock}}`。
+
+## Consequences
+
+这四个场景无论何时运行都渲染同一行 aria,其 golden 继续断言不带日期前缀的时钟。日历事实现在按场景归属:自行输入消息、或种子 fixture 头部为 `createdAt: 0` 的场景必须让两个时钟都读取 fixture 日,否则会继承午夜暴露。产品时钟渲染、`Asia/Shanghai` 浏览器时区、所有已提交的 golden,以及本说明未列出的场景的断言均不变。
diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml
new file mode 100644
index 0000000000..2bf0fcc7a9
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-observation-waits-on-observed-state.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/testing/2026-09-27-observation-waits-on-observed-state.md
+/agent-note-windows-lane-observations-wait-on-the-state-they-read:
+ en: 8949f81c9d585fc0
+ zh: 34e1bdbca1cf2126
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/problem:
+ en: 270b015afc8de8ac
+ zh: 65aeb1e270d19a27
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/decision:
+ en: 479cb25421102304
+ zh: 9e23396728001e2c
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/alternatives-considered:
+ en: 00e59c561085e081
+ zh: 4f6ac4dfc3259f58
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/consequences:
+ en: 46517270dd3b22b1
+ zh: 94e300066237cece
+/agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred:
+ en: 18dc81f98375fb04
+ zh: e30413cd3d201b80
diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md
new file mode 100644
index 0000000000..e73e93f063
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md
@@ -0,0 +1,37 @@
+# Agent Note: Windows-lane observations wait on the state they read
+
+Status: implemented
+
+English | [中文](2026-09-27-observation-waits-on-observed-state.zh.md)
+
+## Problem
+
+Two self-hosted Windows lane cases observed state through a wall-clock window instead of the state they assert on.
+
+`packages/boot/plugin-manager/tests/operations.spec.ts` armed a 100 ms timer when the case started and read the profile's `run.json` when it fired, to observe the run record while the run was still in flight. The repaired installation it observes runs only after a refused installation, a `pnpm view` lookup, the restoration of the profile files, and a second `launch`, so the timer measured that whole prefix rather than the record write. Under the loaded runner the repair began after the deadline and the case failed with `expected null to deeply equal { pid: 4343, grouped: false }` (PR #4595 `windows node 24 / coverage`, job 108402945554, 2026-09-26; the case took 345 ms, and its sibling single-run cases, which need no such prefix, passed in 113 ms and 116 ms).
+
+`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` counts each send's settlement tier and requires the controlled-prompt fast path, but reported only the count, so a failure could not say which tier settled which send — the one fact that separates a degraded readiness path from a mis-sized window.
+
+## Decision
+
+`observingChild` stays in flight until the test has read the run record, so the read cannot race the run's end, and its bounded wait starts at the mocked launcher's hand-over instead of at test start: the recorded run is one atomic write that follows the launch, so the bound only has to cover that write. `RECORD_WAIT_MS` (2 s) is deliberately below the case budget, so a record that never appears reports as the assertion that names it rather than as a runner timeout. Both the repair-install case and the single-run case hand the child over through the same `spawned` hook.
+
+The loader-composition settle-reason assertion carries `JSON.stringify(settleReasons)` as its failure message. The threshold and the assertions are unchanged.
+
+## Alternatives considered
+
+**Raise the fixed timer, or poll for the record from test start.** Rejected: both keep the window anchored before the operation reached the run it observes, so the bound has to cover the whole prefix and grows with whatever the runner does before the repair run — the same mistake at a larger size. Anchoring on the hand-over measures only the state transition being waited for.
+
+**Wait for the record without a bound.** Rejected: a record the operation never writes would hang the case until the runner's own timeout and report nothing about the record.
+
+**Record only the settlements that matter, or assert a subset of sends.** Rejected: it weakens what the suite pins — that no send falls back to the silence tier — to make the failure go away.
+
+## Consequences
+
+Both plugin-manager record cases now observe the record while the run is in flight on a loaded runner, and a genuinely missing record still fails the same assertion. The bounded wait costs 2 s only when the operation never writes the record.
+
+[The task-detail close note](2026-09-28-detail-close-waits-on-observed-state.md) applies the same rule to a web client case, where the sampled state lands in a passive effect one commit after the list the case already awaited.
+
+## Deferred
+
+The loader-composition failure of 2026-09-25/26 is an environment-sensitive readiness window, not a product logic error, and the product now owns a documented tolerance for it: the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md). On Windows `isStdinWaiting` returns false and `foregroundPgid` returns the shell pid, so `stdin_read` can only come from the controlled-prompt tail. A native Windows probe (Windows 11 ARM64 guest, 8 vCPU, with the readiness probe enabled) shows the fast path is deterministic when delivery is normal: every prompt render is the OSC marker alone in one pty chunk followed by a five-byte `dsh> ` chunk 2-28 ms later (56 renders), nothing printable follows any prompt, the output carries no terminal queries, and 21 runs (baseline, six CPU burners, emulated x64, four concurrent pty-heavy instances, and Windows PowerShell 5.1 as the shell) settled all seven sends on the prompt, six `stdin_read` plus the `exit` command's `session_exit`. Withholding each session's prompt tail for four seconds reproduces the reported failure exactly: `expected 4 to be greater than or equal to 6` with `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`, the degraded settles carrying `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3301/3313 ms (exactly the configured bound), 22.1/21.9 s against 17.5/20.9 s in CI, and every output assertion still passing. The CI trigger itself was not reproduced locally, so this case keeps its assertion and supplies `promptTailGraceMs` in its composition: it measures the controlled-prompt path rather than the host's delivery timing, and it still fails when no prompt marker appears at all.
diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md
new file mode 100644
index 0000000000..097a45b34b
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: Windows lane 的观测等待它所读取的状态
+
+Status: implemented
+
+[English](2026-09-27-observation-waits-on-observed-state.md) | 中文
+
+## Problem
+
+自托管 Windows lane 的两个用例用墙钟窗口观测状态,而不是等待它们真正断言的那个状态。
+
+`packages/boot/plugin-manager/tests/operations.spec.ts` 在用例开始时启动一个 100 毫秒定时器,并在它触发时读取 profile 的 `run.json`,以此在该 run 仍在进行时观测 run 记录。它要观测的「修复安装」只有在一次被拒绝的安装、一次 `pnpm view` 查询、profile 文件恢复以及第二次 `launch` 之后才会发生,因此该定时器度量的是这整段前缀,而不是记录写入本身。在负载较高的 runner 上修复安装晚于该截止时刻才开始,用例因而以 `expected null to deeply equal { pid: 4343, grouped: false }` 失败(PR #4595 的 `windows node 24 / coverage`,job 108402945554,2026-09-26;该用例耗时 345 毫秒,而相邻的单 run 用例不需要这段前缀,分别以 113 毫秒和 116 毫秒通过)。
+
+`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 统计每次 send 的结算层级并要求受控提示符快路径,但只报告数量,失败时无法说明哪一层级结算了哪一次 send——而这正是区分「就绪路径退化」与「窗口取值不当」的唯一事实。
+
+## Decision
+
+`observingChild` 会保持该 run 在进行中,直到测试读到 run 记录,因此读取不可能与 run 结束竞争;其有界等待从被 mock 的启动器交接处开始,而不是从用例开始处开始:被记录的 run 只是紧随启动之后的一次原子写入,因此等待上界只需覆盖这次写入。`RECORD_WAIT_MS`(2 秒)刻意低于用例预算,于是一条始终没有出现的记录会以指名它的断言报告,而不是以 runner 超时报告。修复安装用例与单 run 用例都通过同一个 `spawned` 钩子交接子进程。
+
+loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作为失败信息。阈值与断言本身不变。
+
+## Alternatives considered
+
+**提高固定定时器的时长,或从用例开始处轮询记录。** 不采纳:两者都把窗口锚定在操作尚未到达它所观测的 run 之前,于是上界必须覆盖整段前缀,并随 runner 在修复 run 之前的行为一起增长——同样的错误,只是更大。锚定在交接处则只度量真正要等待的那个状态转换。
+
+**无上界地等待记录。** 不采纳:若操作永不写入记录,用例会挂到 runner 自身的超时,且对记录本身不给出任何信息。
+
+**只记录关键的那几次结算,或断言 send 的子集。** 不采纳:这会把该套件所固定的内容——任何 send 都不得回退到静默层——削弱成让失败消失。
+
+## Consequences
+
+在负载较高的 runner 上,两个插件管理器记录用例现在都能在该 run 仍在进行时观测到记录,而真正缺失的记录仍会以同一条断言失败。只有在操作始终不写记录时,这段有界等待才会付出 2 秒。
+
+[任务详情关闭断言](2026-09-28-detail-close-waits-on-observed-state.zh.md) 把同一条规则用在 web 客户端用例上:被采样的状态落在该用例已经等待过的那个列表之后一个 commit 的被动 effect 里。
+
+## Deferred
+
+loader-composition 于 2026-09-25/26 的失败是一个对投递时序敏感的就绪窗口,而不是 product 逻辑错误;product 现在为它拥有一个文档化的容忍度:[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)。在 Windows 上 `isStdinWaiting` 返回 false、`foregroundPgid` 返回 shell 自身的 pid,因此 `stdin_read` 只能来自受控提示符尾部。原生 Windows 探测(Windows 11 ARM64 guest,8 vCPU,启用就绪探测)表明只要投递正常,快路径就是确定的:每次提示符渲染都是 OSC 标记独占一个 pty 分块,紧随其后的下一个分块是 5 字节的 `dsh> `,间隔 2–28 毫秒(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询;21 次运行(基线、6 个 CPU burner、模拟 x64、4 个并发 pty 实例、以及以 Windows PowerShell 5.1 作为 shell)全部七次 send 都按提示符结算——六次 `stdin_read` 加 `exit` 命令的 `session_exit`。把每个 session 的提示符尾部扣留 4 秒可以精确复现上报的失败:`expected 4 to be greater than or equal to 6`,`settleReasons` 为 `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`,退化的结算带着 `promptSeen` 为 true、`promptTextSeen` 为 false、空尾部与 `idleFor` 3301/3313 毫秒(正是配置的上界),耗时 22.1/21.9 秒对照 CI 的 17.5/20.9 秒,且所有输出断言仍然通过。CI 的触发条件本身未能在本地复现,因此该用例保留其断言并在组合中提供 `promptTailGraceMs`:它度量受控提示符路径而不是宿主机的投递时序,且在完全没有提示符标记时仍然失败。
diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml
new file mode 100644
index 0000000000..f50d88bce9
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-27-oxlint-contract-spawn-case-budgets.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/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md
+/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling:
+ en: 152f1e58efbf506e
+ zh: 889504ebfe5783ca
+/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/problem:
+ en: 08d6430c0b4e868f
+ zh: b98c15541bcea4ee
+/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/decision:
+ en: 34bd113b8c9228fb
+ zh: 99843bf33b703b5e
+/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/alternatives-considered:
+ en: 2b317170890802f7
+ zh: 2a5221dcfa8b9116
+/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/consequences:
+ en: e484a10680ca20b8
+ zh: 8fd9fcf13e3dbcd7
diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md
new file mode 100644
index 0000000000..fc4e47fdf8
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md
@@ -0,0 +1,27 @@
+# Agent Note: Oxlint contract cases that spawn a process carry a 90 s ceiling
+
+Status: implemented
+
+English | [中文](2026-09-27-oxlint-contract-spawn-case-budgets.zh.md)
+
+## Problem
+
+Twelve of the fourteen cases in [`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) spawn a real child process: `runOxlint` starts the oxlint CLI, and `runRepositoryOxlint` boots [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) under tsx, which runs oxlint and, after `--fix`, runs it once more. The root `vitest.config.ts` sets no `testTimeout`, so a case without its own budget inherits Vitest's 5000 ms default; the coverage lanes raise it through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` and the Windows native lane passes `--testTimeout 90000`, while the `unit tests (darwin parity, macos-latest)` job in `sandbox.yml` and a developer's `pnpm run test` run plain. Seven cases (five `it` declarations, one of them a three-way `it.each`) carried the explicit `90_000` that #3115 aligned the contended Windows spawn budgets to; the other seven ran on the default, and five of those spawn. Two runner classes exposed it: the self-hosted Windows pool, where process creation spikes to several seconds (#2581), and the hosted macos-latest runner of Sandbox run 36332053320, which ran the whole file 2.2× slower than the passing run 36320394290 and timed out `prints only the final diagnostics when a fix retry still fails` at 6309 ms, a case that took 1122 ms on the passing run. `Test timed out in 5000ms` names neither a contract violation nor the spawn that overran.
+
+## Decision
+
+Every case in the file that spawns oxlint or the repository lint entrypoint carries the same explicit per-case `90_000` ceiling. The two configuration-only cases, `keeps the complete stylistic contract in Oxlint` and `keeps repository lint workflows Oxlint-only`, spawn nothing and keep the default. The value is a completion ceiling for a hang, not a latency target: the slowest case on the slow darwin runner took 9507 ms, and the file under six-fold CPU oversubscription on a 12-core host peaked at 20767 ms, both under a quarter of the ceiling. Assertions, the absence of retries, and Vitest's global `testTimeout` are unchanged.
+
+## Alternatives considered
+
+**A `describe`-level `{ timeout: 90_000 }`.** Rejected: case-level values already bound seven cases and take precedence over a `describe` value, so a suite-level number would govern only the remaining seven, including the two configuration-only cases that spawn nothing, and the file would carry two budget styles. Per-case values keep one style, one place to read each case's ceiling, and the default on the cases that spawn nothing. Either form overrides the lane's `--testTimeout` rather than yielding to it (#2677). The archived [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) and [Lefthook budget](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md) notes chose the `describe` form for files whose cases carried no constants of their own, because a case added later without an allowance inherits the default; this file already carried per-case constants, so that risk is accepted here and named under Consequences.
+
+**Passing `--testTimeout 90000` to the darwin parity job and letting the cases inherit the lane budget**, the form the [subagent teardown budgets](2026-09-07-subagent-teardown-test-budgets.md) note prefers for suites bound by process creation. Rejected: the cost is the file's own tsx boot plus two oxlint runs, not a darwin property, so a developer's plain `pnpm run test` would keep the default; the parity job exists to run the same command developers run; and seven cases already carry constants, so a lane flag would split the file's budget between two sources.
+
+**Raising Vitest's global `testTimeout`.** Rejected: the whole unit inventory runs on the default; widening it to absorb one file's process spawns removes the hang detection the default provides everywhere else.
+
+**A tighter value sized to the observed 6.3 s.** Rejected: the Windows contention fires as multi-second spikes that rotated across cases under 15–30 s budgets before the 90 s alignment; a smaller number moves the flake rather than removing it.
+
+## Consequences
+
+A contract violation still fails through its own assertion well inside the ceiling; the five cases bounded here settle in 0.5–1.7 s on an idle host and no spawning case in the file exceeded 3.8 s there, so only a genuine hang waits the full 90 s. The Windows self-hosted lane, the Linux coverage lanes, the darwin parity job, and a local `pnpm run test` read the same ceiling for this file regardless of `DSH_COVERAGE_TEST_TIMEOUT_MS`. A spawning case added later without its own `90_000` runs on the default until the file's inventory is checked. Under 72 busy-loop processes on a 12-core host the unbounded fix-retry case reproduced `Test timed out in 5000ms` at 7084 ms before the change and passes with the ceiling after it.
diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md
new file mode 100644
index 0000000000..2b04cfe058
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md
@@ -0,0 +1,27 @@
+# Agent Note: 会 spawn 进程的 Oxlint 契约用例携带 90 s 上限
+
+Status: implemented
+
+[English](2026-09-27-oxlint-contract-spawn-case-budgets.md) | 中文
+
+## 问题
+
+[`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) 的 14 个用例中有 12 个会启动真实子进程:`runOxlint` 启动 oxlint CLI,`runRepositoryOxlint` 在 tsx 下引导 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts),后者运行 oxlint,并在 `--fix` 之后再运行一次。根 `vitest.config.ts` 没有设置 `testTimeout`,因此没有自带预算的用例继承 Vitest 的 5000 ms 默认值;coverage 通道通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 抬高它,Windows 原生通道传入 `--testTimeout 90000`,而 `sandbox.yml` 中的 `unit tests (darwin parity, macos-latest)` job 与开发者本机的 `pnpm run test` 都直接运行。7 个用例(5 个 `it` 声明,其中一个是三路 `it.each`)携带 #3115 把有争抢的 Windows spawn 预算对齐到的显式 `90_000`;另外 7 个跑在默认值上,其中 5 个会 spawn。两类 runner 暴露了这一点:自托管 Windows 池,其进程创建会出现数秒尖峰(#2581);以及 Sandbox run 36332053320 的托管 macos-latest runner,它跑整个文件比通过的 run 36320394290 慢 2.2 倍,并让 `prints only the final diagnostics when a fix retry still fails` 在 6309 ms 时超时,而该用例在通过的 run 中耗时 1122 ms。`Test timed out in 5000ms` 既不指出契约违规,也不指出超时的那次 spawn。
+
+## 决策
+
+文件中每个会 spawn oxlint 或仓库 lint 入口的用例都携带同一个显式的用例级 `90_000` 上限。两个只检查配置的用例 `keeps the complete stylistic contract in Oxlint` 与 `keeps repository lint workflows Oxlint-only` 不 spawn 任何进程,保持默认值。该值是针对挂起的完成上限,不是延迟目标:慢 darwin runner 上最慢的用例耗时 9507 ms,12 核宿主机上六倍 CPU 超订下整个文件的峰值为 20767 ms,两者都不到上限的四分之一。断言、不加重试、以及 Vitest 全局 `testTimeout` 均不变。
+
+## 曾考虑的替代方案
+
+**`describe` 级 `{ timeout: 90_000 }`。** 拒绝:用例级取值已经约束 7 个用例并且优先于 `describe` 级取值,因此套件级数字只会作用于其余 7 个用例,包括两个不 spawn 的纯配置用例,而且文件会同时带两种预算风格。用例级取值保持一种风格,每个用例的上限只在一处可读,不 spawn 的用例保持默认值。两种形式都覆盖通道的 `--testTimeout` 而不是让位于它(#2677)。已归档的 [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) 与 [Lefthook 预算](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md)两篇 Note 为用例本身不带常量的文件选择了 `describe` 形式,理由是之后新增而未带余量的用例会继承默认值;本文件已经带有用例级常量,因此这里接受该风险并在「后果」中点名。
+
+**给 darwin parity job 传入 `--testTimeout 90000`,让用例继承通道预算**,这是[子代理清理预算](2026-09-07-subagent-teardown-test-budgets.zh.md)一篇对进程创建受限套件偏好的形式。拒绝:成本来自本文件自身的 tsx 引导加两次 oxlint 运行,不是 darwin 的属性,因此开发者直接运行的 `pnpm run test` 仍会停留在默认值;parity job 的存在意义就是运行与开发者相同的命令;而且 7 个用例已带常量,通道旗标会把本文件的预算拆到两个来源。
+
+**抬高 Vitest 全局 `testTimeout`。** 拒绝:整个单元测试清单都跑在默认值上;为吸收一个文件的进程 spawn 而放宽它,会移除默认值在其他所有地方提供的挂起检测。
+
+**按观测到的 6.3 s 收紧取值。** 拒绝:Windows 争抢表现为数秒尖峰,在对齐到 90 s 之前曾在 15–30 s 预算下于各用例间轮转出现;更小的数字只是移动 flake 而不是消除它。
+
+## 后果
+
+契约违规仍通过其自身断言在上限之内早早失败;本次加边界的 5 个用例在空载宿主机上 0.5–1.7 s 内结算,文件中任何会 spawn 的用例在那里都不超过 3.8 s,因此只有真正的挂起才会等满 90 s。Windows 自托管通道、Linux coverage 通道、darwin parity job 与本机 `pnpm run test` 对本文件读到同一个上限,与 `DSH_COVERAGE_TEST_TIMEOUT_MS` 无关。之后新增而未自带 `90_000` 的 spawn 用例会跑在默认值上,直到有人核对本文件的清单。在 12 核宿主机上以 72 个忙循环进程加载时,原本无边界的 fix-retry 用例在改动前于 7084 ms 复现 `Test timed out in 5000ms`,改动后在上限内通过。
diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.i18n.yaml
new file mode 100644
index 0000000000..7de0fe8b38
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-plugin-manager-git-child-environment.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/testing/2026-09-27-plugin-manager-git-child-environment.md
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration:
+ en: ebb8f6a97dcc126a
+ zh: 4f7fbb487136f218
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/problem:
+ en: 67f7316995be5f48
+ zh: fbb8dda3946ff1f3
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/decision:
+ en: 7723360996b8b09e
+ zh: 78d8dfa61b1711ec
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/alternatives-considered:
+ en: 3f4e5b0bc9a8a961
+ zh: 5f39ffd20e076914
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/verification:
+ en: 7c8b5b8f4002c71a
+ zh: 59493bd968ba34bf
+/agent-note-plugin-manager-git-children-isolate-the-host-s-command-line-configuration/consequences:
+ en: b81437323c8ae6e2
+ zh: 79f8d0926218ae2e
diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md
new file mode 100644
index 0000000000..ded480eacb
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.md
@@ -0,0 +1,29 @@
+# Agent Note: Plugin-manager Git children isolate the host's command-line configuration
+
+Status: implemented
+
+English | [中文](2026-09-27-plugin-manager-git-child-environment.zh.md)
+
+## Problem
+
+The self-hosted Windows serial lane failed 13 cases in run 36227311113 (job 108363684565); 12 share one cause: [github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 (the six install-address forms and three proxy cases) and [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 real-Git install cases. The host carried Git's indexed command-line configuration — `GIT_CONFIG_COUNT` with `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`, the group Git hands to `git -c` calls and their hook children. The shared subprocess scrub (`scrubbedParentEnv`) removes credential-shaped names, so it stripped the key and kept the counter; Git reads the counter first, and every child died with `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config` (exit 128) before opening any configuration file.
+
+## Decision
+
+Both spec files delete Git's command-line configuration group from their own process environment before they spawn Git, through [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts). These specs judge profile behavior — profile Git configuration, real installs — rather than host state, so a host that exports the group no longer changes their outcome. The helper restores the removed entries after the file with `vi.stubEnv(name, undefined)` and `vi.unstubAllEnvs()`.
+
+## Alternatives considered
+
+**Remove the group whole in `scrubbedParentEnv`.** The product-side fix treats the counter and its keys as one unit, but it changes shared subprocess behavior for every consumer, which the review directive for this change excludes. It is preserved on the branch `deferred/git-command-line-config-scrub-20260927` for a future product PR with its own justification.
+
+**Pin `GIT_CONFIG_COUNT: '0'` at the plugin-manager call sites.** `packages/deliverables/workspace-changes/src/git.ts` does exactly that for its children, so the same two lines in `github-connection.ts` and `operations.ts` would cover this class. That is still a product behavior change, so it stays out of scope here.
+
+**Clear the group in the lane's workflow.** The group reaches the job from the host image rather than from a step this repository owns, and other Git-spawning specs would stay exposed.
+
+## Verification
+
+`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` fails 12 cases at the parent revision with the lane's assertion set and passes 95/95 with the isolation in place.
+
+## Consequences
+
+The two specs pass on a host that exports Git command-line configuration. The product's treatment of such hosts is unchanged, so the same environment still breaks any other consumer of the shared scrub; a future product change to `scrubbedParentEnv` must revisit this note.
diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.zh.md b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.zh.md
new file mode 100644
index 0000000000..66d667af50
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-plugin-manager-git-child-environment.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: plugin-manager 的 Git 子进程隔离宿主机的命令行配置
+
+Status: implemented
+
+[English](2026-09-27-plugin-manager-git-child-environment.md) | 中文
+
+## Problem
+
+自托管 Windows 串行 lane 在运行 36227311113(作业 108363684565)中失败 13 个用例,其中 12 个同一成因:[github-connection.spec.ts](../../../../packages/boot/plugin-manager/tests/github-connection.spec.ts) 9 个(六种安装地址形式与三个代理用例)与 [manager.spec.ts](../../../../packages/boot/plugin-manager/tests/manager.spec.ts) 3 个真实 Git 安装用例。该宿主机携带 Git 的索引式命令行配置——`GIT_CONFIG_COUNT` 加上 `GIT_CONFIG_KEY_0` / `GIT_CONFIG_VALUE_0`,即 Git 交给 `git -c` 调用及其 hook 子进程的那一组变量。共享的子进程清洗(`scrubbedParentEnv`)按凭据形态的名字移除变量,于是移除了键却保留了计数;Git 先读计数,每个子进程都在打开任何配置文件之前以 `error: missing config key GIT_CONFIG_KEY_0` / `fatal: unable to parse command-line config`(退出码 128)失败。
+
+## Decision
+
+这两个 spec 文件在派生 Git 之前,从自身进程环境里删除 Git 的命令行配置组,由 [isolateGitCommandLineConfig](../../../../packages/boot/plugin-manager/tests/git-environment.ts) 完成。这些 spec 判定的是 profile 行为——profile 的 Git 配置、真实安装——而不是宿主机状态,因此导出该组的宿主机不再改变它们的结果。辅助函数在文件结束后用 `vi.stubEnv(name, undefined)` 与 `vi.unstubAllEnvs()` 还原被移除的条目。
+
+## Alternatives considered
+
+**在 `scrubbedParentEnv` 中整体移除该组。** 产品侧修复把计数与它的键当作一个整体,但它会改变所有消费者共享的子进程行为,而本次改动的 review 指令排除了这一点。该修复保留在分支 `deferred/git-command-line-config-scrub-20260927`,留待将来带自身论证的产品 PR。
+
+**在 plugin-manager 的调用点固定 `GIT_CONFIG_COUNT: '0'`。** `packages/deliverables/workspace-changes/src/git.ts` 正是这样对待自己的子进程,因此在 `github-connection.ts` 与 `operations.ts` 加同样两行即可覆盖这一类。但那仍属产品行为改动,故不在此范围内。
+
+**在 lane 的工作流里清除该组。** 该组来自宿主机镜像,而不是本仓库拥有的某个步骤,且其他会派生 Git 的 spec 仍会暴露。
+
+## Verification
+
+`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.bare GIT_CONFIG_VALUE_0=true pnpm exec vitest run packages/boot/plugin-manager/tests/github-connection.spec.ts packages/boot/plugin-manager/tests/manager.spec.ts` 在父修订上以 lane 的断言集合失败 12 个用例,加入隔离后 95/95 通过。
+
+## Consequences
+
+这两个 spec 在导出 Git 命令行配置的宿主机上通过。产品对此类宿主机的处理未变,因此同一环境仍会破坏共享清洗的其他消费者;将来若改动 `scrubbedParentEnv`,必须回头审视本记录。
diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml
new file mode 100644
index 0000000000..2297d06fee
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.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/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md
+/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions:
+ en: 015053d7e4cc8b74
+ zh: 8afd22975bcb9814
+/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/problem:
+ en: 27ce4e01ca063f29
+ zh: cd8c872750c725bc
+/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/decision:
+ en: 49b7f008e97f05cb
+ zh: 00571e867ba82057
+/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/alternatives-considered:
+ en: 51c1315b1d424915
+ zh: 2f2f537cfdde2469
+/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/verification:
+ en: 316dbc106810d68f
+ zh: 8771fd22b885e4ab
+/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/consequences:
+ en: 03eedcc3cfdad180
+ zh: 8d4843e01448fe83
diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md
new file mode 100644
index 0000000000..73b3f9e219
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md
@@ -0,0 +1,37 @@
+# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions
+
+Status: implemented
+
+English | [中文](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md)
+
+## Problem
+
+[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) opens `meeting.xlsx`, clicks the sheet overlay at `{ x: 60, y: 40 }` to select `A1`, then widens and narrows the sidebar four times and asserts after each reflow that the name box still reads `A1`. Ten of the eleven `CI master` runs of the serial self-hosted Linux lane between 2026-09-24 and 2026-09-27 failed one or both locale cases of that scenario with `expected 'B1' to be 'A1'` at the first reflow, and hosted `node 24 / snapshots and artifacts` runs failed the same way (runs 36110572677 and 36213089675). The failure is not locale-bound: en-US failed alone in four master runs, both locales in three, zh-CN alone in three. Polling the read (#5243) changed nothing because `B1` is the committed selection, not a stale sample.
+
+`A1` is the workbook's initial selection, so the polls that follow the click are satisfied before the click commits and never verify what the click selected. FortuneSheet's `SheetOverlay` handles the press as `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))`, and `handleCellAreaMouseDown` resolves the cell from `cellArea.getBoundingClientRect()` and the event's page coordinates at the moment the updater runs. React runs `useState` updaters during render and runs them again when the hook queue holds a lower-priority update ahead of them; the freshly mounted workbook still has such updates pending, and Playwright precedes the press with a `mousemove` whose FortuneSheet handler is a continuous-priority update on the same state. An instrumented verbatim copy of the scenario on the Blacksmith runner recorded four executions of `handleCellAreaMouseDown` for the one click: two before the click returned reading `left=969.5`, and two after the scenario had already set the panel to `1000px`, reading `left=725.5`. The stored pointer x of 985 then resolves to 259.5 inside the grid, and because the meeting sheet has two columns `colLocation` clamps it to the last one: `B1`. On a machine that commits the workbook's initial selection normalization before the click, the same point lies on A1's own selection box, whose `onMouseDown` stops propagation; the click then selects nothing, no geometry-bound updater is queued, and the scenario passes without having tested the click.
+
+## Decision
+
+The meeting-sheet step selects by keyboard. The scenario focuses the sheet overlay (`tabindex="-1"`), asserts the focus landed, presses `ArrowRight` and asserts `B1` with an empty formula bar, then presses `ArrowLeft` and asserts `A1` with `会议纪要`, and only then starts the reflow loop. `handleArrowKey` → `moveHighlightCell` computes the target from committed state — the current selection and `visibledatacolumn` — so a re-executed updater yields the same cell whatever the grid's geometry is by then, and the two moves make the interaction observable because `B1` differs from the initial selection. The reflow loop, its `A1` assertion, the sheet-tab scroll-control counts, and the canvas identity check are unchanged.
+
+The budget-sheet click at the start of the scenario stays a pointer click: it targets a cell other than the initial selection, its result is verified by the `46281` formula read, and keyboard, clipboard, and layout reads separate it from the first reflow by far more than one deferred render. The `values.csv` click followed by a narrowing stays too: a re-execution against the narrowed grid resolves a negative x, which `colLocation` clamps to column A, the same cell.
+
+## Alternatives considered
+
+**Poll the name box after each reflow.** #5243 did this. The re-executed updater's result is the final committed state, so the poll observes `B1` for its whole budget; there is no later state to wait for.
+
+**Wait for the selection box to render before clicking.** That restores the passing path in which the click lands on A1's own drag handle and selects nothing, so the assertions would verify the initial selection rather than an interaction. Clicking a cell other than the initial selection instead keeps the geometry-bound updater and its re-execution window open across the reflow that follows.
+
+**Wait for the renderer to go idle before the first reflow.** No product state names "React has no re-execution pending"; an idle or fixed wait sized to the runner is the flake-masking wait [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) rejects.
+
+**Extend the `@fortune-sheet/react` patch to resolve the cell outside the updater.** `handleCellAreaMouseDown` lives in `@fortune-sheet/core`, and the window a user would need to hit is a pointer press and a sidebar reflow inside one deferred render. It stays an upstream concern; the product change is out of this test fix.
+
+## Verification
+
+The instrumented verbatim copy of the shipped scenario, run on the hosted Linux lane of PR #5291 while it was a draft, reproduced `expected 'B1' to be 'A1'` at the first reflow in 7 of 38 runs across three lane runs (2 of 6, 1 of 6, 4 of 20). Every failure recorded `mousedown` targeting `#luckysheet-sheettable_0` rather than the drag handle and the `B1` commit preceding the deferred `cellInput.focus()` that only the press updater schedules; the five failures in the two lane runs that also hooked the cell area's `getBoundingClientRect` all carried the four-execution signature above. The keyboard-driven copy ran in those same two lane runs and passed 26 of 26. Locally, `Emulation.setCPUThrottlingRate` at 20× makes the click reach the cell area and records three executions of `handleCellAreaMouseDown` per click; the keyboard-driven copy passes 6 of 6 under that throttle and 6 of 6 with React's scheduler wake-ups deferred by 200 ms.
+
+`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` passed both locale cases in 10 of 10 local repetitions, and the whole file passed its 6 cases in 2 of 3 local runs; the third failed the unrelated `Coding Tools` switch poll at `:849` while documentation gates ran concurrently, with the failure screenshot showing the switch on.
+
+## Consequences
+
+The scenario verifies a keyboard-made selection surviving sidebar reflows and no longer depends on where React's deferred render lands relative to the reflow. A pointer click in this lane remains valid when its target differs from the current selection and its effect is asserted before any layout change, or when a re-execution against the changed layout provably resolves the same cell; a click whose result feeds a reflow assertion within a few round trips does not. FortuneSheet's geometry-bound updater is unchanged, so a real pointer press followed within one deferred render by a sidebar reflow can still move the selection in the product.
diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md
new file mode 100644
index 0000000000..b2d6a3c5d7
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions
+
+Status: implemented
+
+[English](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md) | 中文
+
+## Problem
+
+[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) 打开 `meeting.xlsx`,在表格 overlay 的 `{ x: 60, y: 40 }` 处点击以选中 `A1`,随后把侧栏加宽、收窄各两次,并在每次重排后断言名称框仍显示 `A1`。2026-09-24 到 2026-09-27 之间 `CI master` 的 serial 自托管 Linux lane 的 11 次运行中有 10 次在第一次重排处以 `expected 'B1' to be 'A1'` 失败该场景的一个或两个 locale 用例,托管的 `node 24 / snapshots and artifacts` 也以同样方式失败(run 36110572677 与 36213089675)。失败与 locale 无关:四次 master 运行只有 en-US 失败,三次两者都失败,三次只有 zh-CN 失败。把读取改为轮询(#5243)没有作用,因为 `B1` 是已提交的选区,不是过期采样。
+
+`A1` 是工作簿的初始选区,所以点击之后的两个轮询在点击提交前就已满足,从未验证点击选中了什么。FortuneSheet 的 `SheetOverlay` 以 `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))` 处理按下,而 `handleCellAreaMouseDown` 在 updater 执行的那一刻用 `cellArea.getBoundingClientRect()` 与事件的页面坐标解析目标单元格。React 在渲染期间运行 `useState` 的 updater,并且当 hook 队列里有更低优先级的更新排在它前面时会再次运行它;刚挂载的工作簿仍有这类更新待处理,而 Playwright 在按下之前先派发一次 `mousemove`,其 FortuneSheet 处理器是同一状态上的 continuous 优先级更新。在 Blacksmith runner 上运行的带探针的逐字副本记录到这一次点击触发了四次 `handleCellAreaMouseDown`:两次在点击返回前读到 `left=969.5`,两次在场景已把面板设为 `1000px` 之后读到 `left=725.5`。保存下来的指针 x 985 于是在网格内解析为 259.5,而会议表只有两列,`colLocation` 把它钳到最后一列:`B1`。在点击前就已提交工作簿初始选区归一化的机器上,同一位置落在 A1 自身的选区框上,其 `onMouseDown` 阻止了传播;点击于是什么也没选中,没有几何绑定的 updater 入队,场景在没有测试到点击的情况下通过。
+
+## Decision
+
+会议表这一步改为键盘选择。场景聚焦表格 overlay(`tabindex="-1"`),断言焦点已落在其上,按 `ArrowRight` 并断言 `B1` 且公式栏为空,再按 `ArrowLeft` 并断言 `A1` 且公式栏为 `会议纪要`,然后才开始重排循环。`handleArrowKey` → `moveHighlightCell` 只依据已提交状态(当前选区与 `visibledatacolumn`)计算目标,所以被重新执行的 updater 无论此时网格几何如何都得到同一单元格,而两次移动使交互可观察,因为 `B1` 与初始选区不同。重排循环、其中的 `A1` 断言、表页滚动控件计数与 canvas 身份检查保持不变。
+
+场景开头预算表的点击仍是指针点击:它的目标不是初始选区,其结果由 `46281` 公式读取验证,而键盘、剪贴板和布局读取把它与第一次重排隔开远超一次延迟渲染。`values.csv` 的点击及其后的收窄也保持不变:针对收窄后的网格重新执行会解析出负的 x,`colLocation` 把它钳到 A 列,即同一单元格。
+
+## Alternatives considered
+
+**每次重排后轮询名称框。** #5243 这样做了。被重新执行的 updater 的结果就是最终提交状态,所以轮询在整个预算内都观察到 `B1`;没有更晚的状态可等。
+
+**点击前等待选区框渲染。** 这会恢复点击落在 A1 自身拖拽手柄上、什么也不选的通过路径,断言于是验证的是初始选区而不是一次交互。改为点击初始选区以外的单元格则会保留几何绑定的 updater,其重新执行窗口跨越随后的重排。
+
+**第一次重排前等待渲染器空闲。** 没有产品状态能表达“React 没有待重新执行的更新”;按 runner 调校的空闲等待或固定等待正是 [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) 拒绝的掩盖式等待。
+
+**扩展既有的 `@fortune-sheet/react` 补丁,在 updater 之外解析单元格。** `handleCellAreaMouseDown` 位于 `@fortune-sheet/core`,而用户需要命中的窗口是一次指针按下与一次侧栏重排落在同一次延迟渲染内。它仍是上游问题;产品改动不在本次测试修复范围内。
+
+## Verification
+
+PR #5291 处于草稿阶段时在托管 Linux lane 上运行的带探针逐字副本,在三次 lane 运行共 38 次中有 7 次(2/6、1/6、4/20)在第一次重排处复现 `expected 'B1' to be 'A1'`。每次失败都记录到 `mousedown` 的目标是 `#luckysheet-sheettable_0` 而不是拖拽手柄,且 `B1` 的提交先于只有按下 updater 才会安排的延迟 `cellInput.focus()`;在同时给 cell area 的 `getBoundingClientRect` 加了钩子的两次 lane 运行里,5 次失败全部带有上述四次执行的特征。键盘驱动的副本在这两次 lane 运行中 26 次全部通过。本地以 20× 的 `Emulation.setCPUThrottlingRate` 运行可让点击到达 cell area,并记录到每次点击三次 `handleCellAreaMouseDown` 执行;键盘驱动的副本在该限速下 6 次全部通过,在 React 调度器唤醒被推迟 200 ms 时也 6 次全部通过。
+
+`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` 在本地 10 次重复中 10 次都通过两个 locale 用例;整个文件的 6 个用例在 3 次本地运行中 2 次通过,第 3 次在文档 gate 并行运行时失败于无关的 `:849` `Coding Tools` 开关轮询,失败截图显示开关已打开。
+
+## Consequences
+
+场景验证的是键盘做出的选区在侧栏重排后仍然保留,不再依赖 React 的延迟渲染落在重排之前还是之后。此 lane 中的指针点击在目标不同于当前选区、且其效果在任何布局变化之前被断言时仍然有效,或者在针对变化后布局重新执行可证明解析为同一单元格时也有效;而结果在几个往返之内就被送入重排断言的点击则无效。FortuneSheet 的几何绑定 updater 未改动,所以产品中一次真实的指针按下若在一次延迟渲染内紧跟着侧栏重排,仍可能移动选区。
diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml
new file mode 100644
index 0000000000..18a8de664b
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-27-web-lane-assertions-name-their-input-state.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/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
+/agent-note-web-lane-assertions-name-their-input-state:
+ en: 6b04734ce8fbeb4a
+ zh: 73db3bd3ac5985dc
+/agent-note-web-lane-assertions-name-their-input-state/problem:
+ en: 12391509553e0152
+ zh: 86d02884ae9c1b09
+/agent-note-web-lane-assertions-name-their-input-state/decision:
+ en: c6a8e79707975429
+ zh: 4fe1f6abd6fd4c1e
+/agent-note-web-lane-assertions-name-their-input-state/alternatives-considered:
+ en: 6cd5940f55500c3d
+ zh: ded68b931ed81e10
+/agent-note-web-lane-assertions-name-their-input-state/verification:
+ en: 6d11abb7a60f81ae
+ zh: 42bc43777a3b02b7
+/agent-note-web-lane-assertions-name-their-input-state/consequences:
+ en: ad3d8ec4b171b04a
+ zh: e254cc9515b33e0e
diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
new file mode 100644
index 0000000000..954689b326
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md
@@ -0,0 +1,51 @@
+# Agent Note: Web lane assertions name their input state
+
+Status: implemented
+
+English | [中文](2026-09-27-web-lane-assertions-name-their-input-state.zh.md)
+
+## Problem
+
+Three browser-lane scenarios captured an aria region while a state the golden records was only implied by whatever the pointer, the keyboard, or an in-flight Host read happened to be doing at that instant, so each capture could land on the other state.
+
+[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) captures two goldens of one sidebar. `sidebar.expected.md` records the Workspace row with its row-actions cell revealed, which [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) does under `.projectRow:hover`; `first-batch.expected.md` records the sixth session that way under `.sessionRow:hover`, the first row the batch reveals. Both captures therefore depend on where the pointer sits, and the scenario's own "Show 11 more sessions" click reflows the list under a stationary pointer. The serial self-hosted master lane failed this scenario with both goldens' requirements unmet — `treeitem "{{workspace}}"` without the actions, and the golden's hovered row rendering its plain relative time — in every one of the nine failing runs observed between 2026-09-24 and 2026-09-27.
+
+[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) captures the Copy footer focused twice: `running.expected.md` records a `tooltip "Copy"` beside it, `settled.expected.md` records none. [Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) ignores focus while the last input came from a pointer, and only a keydown clears that flag, so whether the first capture shows the bubble depends on whether the harness pressed a key after the scenario's last click.
+
+[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) read the enabled plugin card's `data-plugin-status` once after the highlighted card appeared. The store's `enableInstalled` marks the card and starts the directory reload the Host answers, so the highlight is observable before the reload commits the enabled bundle into the list: the status flipped `disabled` → `running` 61 ms after the highlight in three local runs, and the run that failed read it inside that window.
+
+## Decision
+
+Each capture names the state its golden records before it captures.
+
+The folding scenario hovers the Workspace row before the sidebar capture and the first row the batch reveals before the first-batch capture, so both hovered rows are established rather than inherited from the layout around the Show-more click. The goldens, the folding quota, and the row rendering are unchanged; the scenario now performs the hover a reader of the golden would perform.
+
+The turn-tail scenario clears the pointer flag with a key that moves nothing, focuses the Copy button, and waits for the tooltip the running golden records. The settled capture keeps the plain focus, which is what its golden records once the Stop click has left the pointer owning the last input.
+
+The install-cancel scenario polls the card's status instead of sampling it, so the assertion observes the directory reload that the highlight precedes. The highlight stays the locator that finds the card, because the later `2400 ms` clock advance still asserts the card detaches with it.
+
+## Alternatives considered
+
+**Collapse the hovered and unhovered renderings in the aria normalizer.** The two renderings state different facts — one row is hovered, the others are not — and both goldens deliberately record the row-actions cell and the relative time. Collapsing them would delete the assertion rather than stabilize it, the same trade every lane golden that deliberately records a dated or stateful form already rejects.
+
+**Deliver a blank event and keep the pointer where the click leaves it.** The button moves as the batch inserts, so the row under the pointer still follows layout: the folding negative control below reproduces the failure with the pointer inside the sidebar and no row hovered.
+
+**Disable hover styling or the tooltip modality rule in the scenario.** Both are shipped rendering and interaction behavior the goldens are the evidence for; a scenario-local override would test a page the product does not serve.
+
+**Raise the capture timeout or retry the comparison.** Neither establishes which state was captured; `captureStableAria` already waits for two equal consecutive snapshots, and it was satisfied by the wrong state — the limit the [CI completion decision](2026-09-08-ci-readiness-and-completion.md) and the [connection and compaction fixture preconditions](2026-09-12-connection-and-compaction-fixture-preconditions.md) already record.
+
+## Verification
+
+Two negative controls reproduce the recorded signatures with the shipped assertions. Hovering the New Session row instead of the Workspace row before the folding scenario's first capture fails `sidebar.expected.md` with `treeitem "{{workspace}}"` where the golden records `treeitem "{{workspace}} Workspace actions …"`. A bare mouse press with no key before the turn-tail scenario focuses the Copy button raises no bubble, and the running capture then fails with the missing `tooltip "Copy"` the serial lane recorded. Both controls pass after the change.
+
+Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs.
+
+The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator.
+
+The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass.
+
+## Consequences
+
+The three scenarios render the aria their goldens record whatever state the harness arrives in, and no golden, product surface, or timeout changed. The input state is now owned by each capture: a scenario that captures a hovered row, a focused Tooltip anchor, or a value published by a later Host read must establish that state itself. `document-preview.e2e.ts`'s spreadsheet-selection read has the same class of defect and stays with its own change.
+
+[The task-detail close note](2026-09-28-detail-close-waits-on-observed-state.md) records the same class in the client unit lane, where the sampled state arrived through a post-commit effect rather than through an unsettled input.
diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md
new file mode 100644
index 0000000000..7e92b71b98
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md
@@ -0,0 +1,51 @@
+# Agent Note: Web lane assertions name their input state
+
+Status: implemented
+
+[English](2026-09-27-web-lane-assertions-name-their-input-state.md) | 中文
+
+## Problem
+
+浏览器通道的三个场景在捕获 aria 区域时,golden 记录的状态只是被指针、键盘或一次进行中的 Host 读取所隐含,因此每次捕获都可能落到另一种状态上。
+
+[workspace-new-session-folding](../../../../apps/web/tests/workspace-new-session-folding.e2e.ts) 捕获同一侧边栏的两份 golden。`sidebar.expected.md` 记录的是 Workspace 行显示出行操作单元格的状态,该行为由 [Rows.module.css](../../../../packages/client/ui-workspace/src/client/rows/Rows.module.css) 的 `.projectRow:hover` 提供;`first-batch.expected.md` 则以 `.sessionRow:hover` 记录第六个会话行,即该批次展开出的第一行。两次捕获因此都取决于指针所在位置,而场景自身的 “Show 11 more sessions” 点击会在指针静止时重排列表。串行自托管 master 通道在这个场景上失败时,两份 golden 的要求都没有满足——`treeitem "{{workspace}}"` 不带操作按钮,golden 记录为悬停的那一行渲染出普通的相对时间——2026-09-24 至 2026-09-27 观察到的九次失败运行全部如此。
+
+[turn-tail-actions](../../../../apps/web/tests/turn-tail-actions.e2e.ts) 两次捕获聚焦后的 Copy 页脚:`running.expected.md` 记录其旁有一个 `tooltip "Copy"`,`settled.expected.md` 则不记录。只要最后一次输入来自指针,[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) 就忽略聚焦,而只有 keydown 会清除该标记,因此第一次捕获是否出现气泡,取决于场景最后一次点击之后测试框架是否按过键。
+
+[plugin-install-cancel](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在带高亮的插件卡片出现后只读一次 `data-plugin-status`。store 的 `enableInstalled` 先标记卡片,再启动由 Host 应答的目录重载,因此高亮先于重载把已启用的 bundle 写入列表即可观察:三次本地运行中状态在 `disabled` → `running` 之间用了 61 ms,失败的那次运行正是在这个窗口内读取的。
+
+## Decision
+
+每次捕获都先确立其 golden 所记录的状态。
+
+folding 场景在侧边栏捕获前悬停 Workspace 行,在 first-batch 捕获前悬停该批次展开出的第一行,两个被悬停的行都由场景确立,而不再继承 Show-more 点击周围的布局。golden、折叠配额与行渲染均不变;场景现在执行的是 golden 读者本会执行的那次悬停。
+
+turn-tail 场景先用一个不移动焦点的按键清除指针标记,再聚焦 Copy 按钮,并等待 running golden 记录的气泡。settled 捕获保留普通聚焦,这正是 Stop 点击让指针拥有最后一次输入之后、其 golden 所记录的状态。
+
+install-cancel 场景改为轮询卡片状态,而不再单次采样,因此该断言观察到的是高亮所先于的目录重载。高亮仍保留在定位卡片的 locator 中,因为随后推进 `2400 ms` 时钟的那步仍以它断言卡片会脱离。
+
+## Alternatives considered
+
+**在 aria normalizer 中折叠悬停与非悬停两种渲染。** 两者陈述不同事实——一行被悬停,其余没有被悬停——且两份 golden 都在有意记录行操作单元格与相对时间。折叠它们是在删除断言而非稳定断言,这与通道中每一份有意记录带日期或带状态形式的 golden 所拒绝的取舍相同。
+
+**投递空白事件并让指针停留在点击后的位置。** 批次插入时按钮随之移动,指针下的行仍由布局决定:下面的 folding 负向对照在指针位于侧边栏内、没有任何行被悬停时复现了该失败。
+
+**在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。
+
+**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
+
+## Verification
+
+两个负向对照用已发布的断言复现了记录到的特征。folding 场景第一次捕获前改为悬停 New Session 行而非 Workspace 行时,`sidebar.expected.md` 失败:golden 记录 `treeitem "{{workspace}} Workspace actions …"`,实际得到 `treeitem "{{workspace}}"`。turn-tail 场景在聚焦 Copy 按钮前只做一次鼠标按下而不按键时不会出现气泡,running 捕获随后失败并缺少串行通道记录到的 `tooltip "Copy"`。两个对照在改动之后均通过。
+
+本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。
+
+install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。
+
+串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。
+
+## Consequences
+
+三个场景无论测试框架以何种状态进入,都渲染其 golden 记录的 aria,且没有任何 golden、产品界面或超时被改动。输入状态现在由每次捕获自己拥有:凡是捕获悬停行、聚焦的 Tooltip 锚点,或由后续 Host 读取发布的值的场景,都必须自行确立该状态。`document-preview.e2e.ts` 的电子表格选区读取属于同一类缺陷,仍留给它自己的改动。
+
+[任务详情关闭断言](2026-09-28-detail-close-waits-on-observed-state.zh.md) 记录了同一类缺陷在客户端单测 lane 上的实例:被采样的状态来自一个提交后 effect,而不是来自尚未结算的输入。
diff --git a/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.i18n.yaml
new file mode 100644
index 0000000000..304ae39497
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-28-detail-close-waits-on-observed-state.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/testing/2026-09-28-detail-close-waits-on-observed-state.md
+/agent-note-the-task-detail-close-waits-on-the-observed-state:
+ en: ffb94d33eb1c162c
+ zh: 32845a4113ab27c1
+/agent-note-the-task-detail-close-waits-on-the-observed-state/problem:
+ en: 0a3fa08f242a0f7e
+ zh: fb8c39531405952b
+/agent-note-the-task-detail-close-waits-on-the-observed-state/decision:
+ en: 3e4c20e32e3e9445
+ zh: 157750a7e1f1fd59
+/agent-note-the-task-detail-close-waits-on-the-observed-state/alternatives-considered:
+ en: 8ee7951bdf933b45
+ zh: 4736d42c1d52c9b0
+/agent-note-the-task-detail-close-waits-on-the-observed-state/consequences:
+ en: c0993f90f7af9c41
+ zh: 6a877cb1287000e3
+/agent-note-the-task-detail-close-waits-on-the-observed-state/related:
+ en: f35c1fe1c2ff83b3
+ zh: 56a3357ab5a67822
diff --git a/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md
new file mode 100644
index 0000000000..662782fd4b
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md
@@ -0,0 +1,44 @@
+# Agent Note: The task-detail close waits on the observed state
+
+Status: implemented
+
+English | [中文](2026-09-28-detail-close-waits-on-observed-state.zh.md)
+
+## Problem
+
+`packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx`, case "keeps details after a refresh failure and closes them after a successful retry", failed intermittently on the `node 24 / coverage` lane's `thread-safe` partition with `AssertionError: expected