diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
index ca2693afef..d3c4c85807 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
@@ -15,8 +15,8 @@
en: 4921a060c657b790
zh: 2f9632469c6f0b60
/agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/explicit-pi-ai-provider-profiles:
- en: e24610c474a94bc4
- zh: 96a89035cb8eabf9
+ en: 1346315312eb35e2
+ zh: 3555f91c3afb49f8
/agent-note-provider-routed-llm-adapters-and-a-generic-pi-ai-backend/decision/recorded-assistant-route-and-replay-state:
en: ed60d078eacc67df
zh: a22631faf93deee2
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
index b205104489..d06af266ec 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
@@ -36,6 +36,8 @@ The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its reg
pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` rejects a defined Harness `stop` option with `UNSUPPORTED_OPTION` rather than silently ignoring it or growing a second provider-specific payload implementation. `dsh-llm-deepseek` continues to support `stop` through its native request serializer.
+pi-ai owns protocol-specific system-message placement and the concatenation fallback for models without in-history system support. Harness preserves later system messages in transcript order and reports catalog capabilities to prompt admission and shared tool-history projection. Duplicating pi-ai's fallback or applying Anthropic's placement rules to every protocol would create a second serialization policy that can drift when pi-ai changes. Capability reporting remains a Harness responsibility because the loop needs it before serialization.
+
### Recorded assistant route and replay state
Assistant messages carry the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records those fields and `deriveMessages()` returns them with the assistant message. User, system, context, and tool-result messages carry no assistant route fields. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
index b838d7255a..d07312ce99 100644
--- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
@@ -36,6 +36,8 @@ Status: implemented
pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定义,`dsh-llm-pi-ai` 会以 `UNSUPPORTED_OPTION` 拒绝请求,不会静默忽略,也不会增加第二套提供方专用 payload 实现。`dsh-llm-deepseek` 继续通过原生请求序列化器支持 `stop`。
+pi-ai 负责各协议的系统消息位置,以及不支持历史内系统消息的模型所需的拼接回退。Harness 按对话记录顺序保留后续系统消息,并向提示词准入和共享工具历史投影报告目录能力。重复实现 pi-ai 的回退,或将 Anthropic 的位置规则应用于所有协议,会形成第二套序列化策略,并可能随 pi-ai 的变化而偏离。能力报告仍由 Harness 负责,因为循环在序列化之前就需要这些信息。
+
### 已记录的助手路由与回放状态
助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml
index a102c752b0..fefceef44e 100644
--- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml
@@ -9,11 +9,11 @@
en: c25db2ef111b2276
zh: 557891ca9e030ae5
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/decision:
- en: 1a1af1e8125ba4b0
- zh: dd6c282651a06af5
+ en: ca8044c9e0013be6
+ zh: d60af7af291299c3
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/alternatives-considered:
en: 1a4b0ae8072e8e50
zh: d469553ad3b5d112
/agent-note-one-carrier-level-browser-trust-boundary-for-all-api-routes/consequences:
- en: 8acf5e6f55a747c2
- zh: bbd0b10927302965
+ en: e31df28a2e82a64d
+ zh: 4ca7097ebbc24cd2
diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
index 397c93e084..9b0c573210 100644
--- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
+++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md
@@ -13,9 +13,11 @@ The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080
Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves:
- **Media-type fence (dsh-client-connection)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers.
-- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence.
+- **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, a declared authority, or the listener's own bind literal that [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) admits. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence.
-Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
+Reachability is the webserver binding's policy (`host`; [Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md) owns which literals it accepts), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need.
+
+`HostConnectionHandle.allowsRemoteAuthorities` projects whether any validated `trustedHosts` entry names a non-loopback hostname: a loopback listener behind a proxy can still serve remote browsers, so the directory picker requires a policy without remote authorities before it picks the native chooser.
## Alternatives considered
@@ -26,6 +28,6 @@ Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), an
## Consequences
- Any future `/api` method is covered by construction; there is no per-route trust decision left to forget.
-- A custom non-loopback composition must trust its serving authorities or requests are refused, then satisfy browser authentication like every loopback request. The shipped CLI rejects `--host 0.0.0.0`; `--trusted-host` only extends the Host/Origin fence and grants no identity.
+- A non-loopback listener accepts its own bind IP directly ([Bind one concrete Web address](2026-09-19-concrete-web-bind-address.md)); other remote authorities require configured trust. Every accepted authority still requires browser authentication. `--trusted-host` extends only the Host/Origin fence and grants no identity.
- Clients must label POST bodies `application/json` (ours always did; raw-fetch tests gained the header).
- Host and Origin remain request-routing evidence only. The process token and signed cookie establish the browser identity used by every Host method.
diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md
index 6d3896fd2c..0721f0bf6b 100644
--- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md
@@ -13,9 +13,11 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI
在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——分为两部分:
- **媒体类型栅栏(dsh-client-connection)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。
-- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
+- **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、已声明的权威,或[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)所接纳的监听器自身绑定字面量通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。
-可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。
+可达性由 webserver 的绑定配置(`host`;[绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md)拥有它接受哪些字面量)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。
+
+`HostConnectionHandle.allowsRemoteAuthorities` 表示是否有通过校验的 `trustedHosts` 条目命名非回环主机名:代理后的回环监听器也能服务远程浏览器,因此目录选择器要求策略不接纳远程 authority,才会选择原生交互。
## 曾考虑的替代方案
@@ -26,6 +28,6 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI
## 后果
- 未来任何 `/api` 方法天然在覆盖范围内;不存在会被遗忘的按路由信任决定。
-- 自定义非 loopback 组合必须信任其服务 authority,否则请求会被拒绝;随后仍像每个 loopback 请求一样满足浏览器认证。随附 CLI 拒绝 `--host 0.0.0.0`;`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。
+- 非回环监听器直接接受自己的绑定 IP([绑定一个具体的 Web 地址](2026-09-19-concrete-web-bind-address.zh.md));其他远程 authority 需要配置的信任。每个被接受的 authority 仍须满足浏览器认证。`--trusted-host` 只扩展 Host/Origin 栅栏,绝不授予身份。
- 客户端必须给 POST 体标注 `application/json`(我们自己的客户端一向如此;裸 fetch 测试补上了该头)。
- Host 与 Origin 仍只是请求路由证据。进程令牌与签名 cookie 建立每个 Host 方法使用的浏览器身份。
diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml
index 7afdf2c22c..5be7e083a3 100644
--- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.i18n.yaml
@@ -72,5 +72,5 @@
en: 7433bdb522bb2083
zh: 545c17613b636f00
/agent-note-typert-gateway-targeted-method-calls/consequences:
- en: c3459afdb606cfbe
- zh: ec69f2bb39170a7c
+ en: abab48251550f584
+ zh: 2568c46d78dbefdf
diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
index 8e91598f7d..58135ff56e 100644
--- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
+++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
@@ -520,7 +520,7 @@ A consumer may import a Remote contract that is not currently mounted on the Hos
Connection's general channel API must suit both the current HTTP carrier and a future WebSocket carrier. If the Client Remote or Gateway exposes `fetch`, an HTTP request, or a route handle, WebSocket migration will pierce the Remote layer again. Those physical objects must therefore remain internal to Connection.
-Remote endpoints use Connection's `trusted-host` authority. Loopback is accepted by default and LAN callers require an explicit trusted-host configuration, but this layer adds no per-method caller authorization; every trusted host can invoke a mounted Remote endpoint.
+Remote endpoints use Connection's `trusted-host` authority. Loopback and the listener's own bind address are accepted by default; any other LAN authority requires an explicit trusted-host configuration. This layer adds no per-method caller authorization; every accepted authority can invoke a mounted Remote endpoint.
`hasSeen()` favors strict-definition safety over SRC availability. While a strict descriptor is withdrawn, such as during HMR, the Gateway continues to claim the endpoint and reports it unavailable instead of falling back to a weak SRC descriptor. Re-registration restores it; only a Typert registry restart forgets the historical strict definition.
diff --git a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md
index fd6cd2bcfc..21d51800ae 100644
--- a/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md
@@ -520,7 +520,7 @@ SRC 弱 descriptor 不验证普通 JSON 内部结构。Host Remote 签名变化
Connection 的通用 channel API 必须同时适合当前 HTTP carrier 和后续 WebSocket carrier。若 Client Remote 或 Gateway 暴露 `fetch`、HTTP request 或 route handle,WebSocket 迁移会再次穿透 Remote 层,因此这些物理对象必须留在 Connection 内部。
-Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback;LAN 调用方必须通过显式 trusted-host 配置接入,但本层不增加逐方法调用方授权,因此每个 trusted host 都能调用已挂载的 Remote endpoint。
+Remote endpoint 使用 Connection 的 `trusted-host` authority。系统默认接受 loopback 与监听器自身的绑定地址;其他 LAN authority 必须通过显式 trusted-host 配置接入。本层不增加逐方法调用方授权,因此每个被接受的 authority 都能调用已挂载的 Remote endpoint。
`hasSeen()` 优先保障 strict definition 的安全性,而非 SRC 可用性。strict descriptor 撤回时(例如 HMR 期间),Gateway 会继续认领 endpoint 并报告不可用,而不会回退到弱 SRC descriptor。重新注册即可恢复;只有重启 Typert 注册表才会忘记历史 strict definition。
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
index e33e7f556d..ac13880ddd 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
@@ -9,8 +9,8 @@
en: c11f54e24be6cc3b
zh: c1c274fbb161a57b
/agent-note-session-log-versioning-one-integer-an-upgrade-chain-and-a-per-event-ignorable-marker/decision:
- en: 084bcb4de484ef32
- zh: 1edf1445913ac7b1
+ en: f2c17cd6b83acdba
+ zh: 148162c0a8043947
/agent-note-session-log-versioning-one-integer-an-upgrade-chain-and-a-per-event-ignorable-marker/decision/writer-and-publication-authority:
en: 461eaa03c6a4f441
zh: 7e5b131326dc702c
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
index 9cb0940f76..c2407b2afd 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
@@ -10,15 +10,17 @@ Session logs must be upgradable after release, and the runtime that ships first
## Decision
+The [reader-compatibility review decision](../process/2026-10-08-session-reader-compatibility-review.md) supersedes the structural bump criterion. This note owns the integer, unknown-event marker, direction-aware refusal, and generation-preservation decisions.
+
**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance; design time rarely reveals whether the next change will turn out "major".
-**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
+**Bump when existing discriminators cannot prevent unsafe interpretation.** Successful parsing alone does not establish compatibility: silently skipping content required for reconstruction is a wrong read. Same-version changes require safe old-reader handling and preservation of historical meaning by new readers. The linked reader-compatibility decision defines the review obligations.
**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: every event-body operation first runs the complete adjacent chain in memory and leaves the source path, bytes, and inode unchanged. Read handles may consume that current logical result directly; a write open exclusively publishes the final current generation under its canonical versioned filename before append. Header-only listing remains non-mutating and reports the numerically highest canonical generation. Catalog generation and module initialization reject a missing adjacent step, so a published first-party build never exposes a partial historical chain. Retained lower generations are not automatic fallback or a downgrade compatibility promise.
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the four `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
-The [accepted compatibility checkpoint](../../../../docs/session-format-status.md#finalization-record) prevents an accepted transition from reusing its header increase for later breaking changes. Backward-compatible evolution uses new acknowledgement records at the same Session version; breaking changes require a higher writer. The checkpoint does not change the unknown-event protocol.
+**Accepted machine records remain immutable.** The [accepted compatibility checkpoint](../../../../docs/session-format-status.md#finalization-record) preserves accepted declarations and schemas. Later changes receive new acknowledgements, and a version-bump decision needs its own increasing header transition. The checkpoint does not change the unknown-event protocol.
### Writer and publication authority
diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
index e1424f24e5..9a9b4365d1 100644
--- a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
@@ -10,15 +10,17 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
## 决定
+[读取器兼容性评审决策](../process/2026-10-08-session-reader-compatibility-review.zh.md)取代结构性升版本标准。本说明负责整数版本、未知事件标记、区分方向的拒绝与代际保留决策。
+
**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺;设计时很少能预知下一个变更算不算"大"。
-**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
+**既有判别信息无法阻止不安全解释时升版本。** 仅解析成功不能证明兼容:静默跳过重建所需内容属于错误读取。同版本变更要求旧读取器安全处理,并由新读取器保留历史含义。所链接的读取器兼容性决策定义评审义务。
**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:每个事件正文操作先在内存中运行完整相邻链,并保持源路径、字节与 inode 不变。读句柄可以直接使用该 current 逻辑结果;写 open 则在 append 前把最终 current generation 排他发布到其规范版本文件名。仅 header 的列表保持不变更,并报告数值最高的规范 generation。catalog 生成与模块初始化会拒绝缺失的相邻步骤,因此已发布第一方 build 绝不会暴露不完整历史链。保留的低 generation 不是自动 fallback,也不构成 downgrade compatibility 承诺。
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经四种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
-[已接受兼容性检查点](../../../../docs/session-format-status.zh.md#finalization-record)阻止已接受转换复用原来的头版本递增来授权后续破坏性变更。向后兼容的演进在相同 Session 版本上使用新的确认记录;破坏性变更要求更高的写入器版本。检查点不改变未知事件协议。
+**已接受的机器记录保持不可变。** [已接受兼容性检查点](../../../../docs/session-format-status.zh.md#finalization-record)保留已接受的声明与 schema。后续变更获得新确认,升版本决策需要自身的递增头版本转换。检查点不改变未知事件协议。
### 写入器与发布真源
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 8cc0c37e4d..ea55baad51 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,8 +9,8 @@
en: 4945ce54f66df55c
zh: 9fbd833c1d8122a7
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/decision:
- en: 2388519a1afaac92
- zh: 6bf758f0560e29a5
+ en: d2452e4a39db0b6b
+ zh: 87f7e0be1f613285
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/incremental-session-log-field:
en: 4e3138fbdd94259b
zh: 8fda801522045b88
@@ -21,7 +21,7 @@
en: eb69576e756f4aa1
zh: 5c1923159da3f7a2
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/verification:
- en: 9935338a0970d09d
+ en: ed730f117365f007
zh: a9a36702bc72a9f7
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/alternatives-considered:
en: 5faf7699272f99b4
@@ -33,5 +33,5 @@
en: b6eff63f49220dcc
zh: 8e4a6c785ed2256a
/agent-note-deepseek-llm-api-request-extensions-for-session-logs-and-plugin-packages/consequences:
- en: 853cd7b21db6208b
- zh: bfaf459f006fe2db
+ en: fed545acefb91f2a
+ zh: c474a94bb956bbf6
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 c2ad167f14..7a6aa226b4 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,7 +14,7 @@ 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. 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.
+`@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 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. Extension fields are auxiliary and best effort: the registry logs and omits a contributor whose preparation throws, and the adapter logs an acceptance failure without failing the model request; 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.
@@ -50,7 +50,7 @@ The process-lifetime manifest-identity cache remains separate because in-process
## Verification
-Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin default-on and explicit-off policies, full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, and out-of-order acceptance. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation failure, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata and Session upload plus explicit upload disablement, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests.
+Registry tests pin duplicate ownership, effect-scoped disposal, detached field values, concurrent and abortable preparation, receiver-preserving acceptance, one acceptance settlement, and failure aggregation. Session tests pin default-on and explicit-off policies, full-first/suffix-later delivery, direct complete event envelopes independent of base-body messages, incremental watermark folding, persisted restart recovery, fork identity fencing, and out-of-order acceptance. Package-inventory tests pin default-on and explicit-off policies, host and standing-preset discovery, conflicting Loader resolution bases, manifest resolution, lifecycle filtering, and exact name/version ordering. The direct adapter mock proves pre-HTTP preparation rejection, cancellation, non-2xx non-acceptance, 2xx acceptance before a later stream failure, and field collision. Keyless replay pins post-2xx extension acceptance, and the TypeScript JSON-RPC plus Python packaged-runtime snapshots project the acceptance event through both SDKs. Real Loader composition pins default package metadata and Session upload plus explicit upload disablement, one real-API request mounts both shipped extensions and proves the official endpoint accepts them, and pi-ai tests retain their unchanged wire requests.
## Alternatives considered
@@ -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 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.
+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. Field collision or provider schema rejection fails the model request. A contributor preparation failure, an acceptance failure, or a merged body that fails to serialize logs a warning and lets the request proceed; a package without a usable version reports only its name.
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 91d44f59e0..608661bd5e 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,7 +14,7 @@ Status: implemented
## 决策
-`@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 后扩展副作用,而非字段字节。
+`@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)不带扩展字段发送,也不执行接受。即使贡献方忽略信号,注册表也会在取消后停止等待准备。扩展字段是辅助信息,按尽力而为处理:贡献方准备时抛错,注册表会记录告警并省略该字段;接受失败时,适配器记录告警而不使模型请求失败;传输失败与非 2xx 失败绝不会接受贡献。未挂载注册表的组合会保留可复用基础适配器。随附组合会挂载注册表与两个贡献方:插件包元数据和会话日志上传均默认开启;按[默认上传决策](2026-09-14-session-log-upload-default.zh.md),设置 `session-log-deepseek.enabled: false` 可关闭日志上传。无密钥 `deepseek-official` 回放会使用合成的空基础正文执行准备,并在第一个已记录分片前调用同一接受事务;它保持的是 2xx 后扩展副作用,而非字段字节。
提供方无关的 `llm` 包与 `llm-pi-ai` 不包含任何扩展类型、服务查找、字段合并或接受调用。
@@ -85,7 +85,7 @@ Status: implemented
## 后果
-DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带新的未接受会话事件,每次最多 `maxBytes`。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。Manifest 解析、字段冲突、接受记录或提供方 schema 拒绝会使模型请求失败,而不会静默丢弃元数据。合并后的正文无法序列化是例外:请求不带扩展字段继续发送,并记录一条告警。
+DeepSeek 官方请求会把存活包版本发送到解析后的 `baseURL`,包括已配置 gateway。除非关闭会话日志上传,否则符合条件的请求还会携带新的未接受会话事件,每次最多 `maxBytes`。这些字段对模型不可见,不增加提示词 token,也不改变 KV Cache,但可能显著增大 HTTP 正文。字段冲突或提供方 schema 拒绝会使模型请求失败。贡献方准备失败、接受失败或合并后的正文无法序列化时,系统记录告警并让请求继续;没有可用版本的包只报告名称。
`delivery-accepted` 事件会成为权威日志的一部分,并在后续请求中自行交付。崩溃恢复可能重复后缀,但不会根据 assistant 输出推断接受,也不会创建第二份本地游标存储。缺少存活会话的直接调用会省略会话字段;宿主包清单仍然可用。
diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml
index a67b37efe1..a48e1e3ffb 100644
--- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml
@@ -9,8 +9,8 @@
en: 01293500639c9d79
zh: 6d19e4843955c811
/agent-note-browser-launch-token-authentication/decision:
- en: 063f9698b8a94287
- zh: 848b2c1134b7a100
+ en: 54efa59339f75256
+ zh: 0f53ead3844151c6
/agent-note-browser-launch-token-authentication/verification:
en: 189b36d0fc5f3a17
zh: 08993cb585204fc0
@@ -18,5 +18,5 @@
en: 5275c87f19fe6eee
zh: ccb922367c4bb3e2
/agent-note-browser-launch-token-authentication/consequences:
- en: 0714a8a7cc74918a
- zh: 0f3878b09463ab2e
+ en: af55274a2f345cdd
+ zh: 4d1810141ef03bae
diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
index 47d6afe1bf..0038ca7b9b 100644
--- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
+++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md
@@ -14,13 +14,13 @@ The Web Host runs tool-capable Sessions with the current operating-system user's
Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public.
-The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation.
+The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. The HTTP listener omits `Secure`. There is no logout operation.
The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority.
The in-page Web Worker preview exposes no network socket. Its page-owned `postMessage` tunnel enters the real route first, then retries a 401 or 403 through the worker-local fetch handler. This keeps Connection interceptors while limiting the authentication bypass to the page that created the Host worker.
-The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not imply supported network deployment, TLS, forwarding-header interpretation, or proxy configuration.
+The shipped CLI accepts concrete local bind addresses but rejects wildcard addresses such as `--host 0.0.0.0`. Authentication does not configure TLS, forwarding-header interpretation, or proxies.
## Verification
@@ -42,6 +42,8 @@ Unit coverage pins process-token retention across Connection reloads, one secret
Possession of the browser cookie authorizes the complete tool-capable Host API, matching the authority the Web application exposes after Session creation. `Host` does not grant a higher method tier, and a method migration between API Proxy and Typert Remote cannot change its caller set.
-The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. Omitting `Secure` preserves loopback HTTP and permits plaintext transmission if an operator makes the same cookie authority reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it.
+The persistent secret makes cookies survive restarts but gives a stolen cookie up to the configured absolute lifetime. Deleting the record and restarting the process is the global revocation mechanism; the active Connection intentionally avoids credential-provider work on each request. An HTTP listener permits plaintext cookie transmission if its authority is reachable over an unencrypted network. The startup URL contains a process credential and must be treated as sensitive output; runtime diagnostics do not repeat it.
The decision partially supersedes the authentication deferral and unauthenticated non-loopback consequences in the [browser trust note](2026-07-28-api-browser-trust-boundary.md). That note remains active authority for media-type, Host, Origin, Fetch-Metadata, and configured-authority validation. No active Agent Note is archived: the overlap is partial and both security rules retain future decision value.
+
+The [native HTTPS listener decision](2026-09-23-native-https-web-listener.md) supersedes the HTTP-only transport and omitted-`Secure` assumptions here. Listener protocol now determines `Secure`; the token exchange, signed-cookie authority, and credential lifetime remain owned by this note.
diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md
index 13ecf55e41..a08323b629 100644
--- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md
@@ -14,13 +14,13 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session
每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。
-cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。
+cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。HTTP 监听器不设置 `Secure`。这里没有 logout 操作。
HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。
页内 Web Worker preview 不暴露网络 socket。其由页面持有的 `postMessage` tunnel 先进入真实 route,收到 401 或 403 后再经 worker 本地 fetch handler 重试。这样既保留 Connection interceptor,又把认证绕过限制在创建 Host worker 的页面内。
-随附 CLI 继续拒绝 `--host 0.0.0.0`。认证不代表支持网络部署、TLS、转发 header 解释或代理配置。
+随附 CLI 接受本机具体绑定地址,但拒绝 `--host 0.0.0.0` 等通配地址。认证不会配置 TLS、转发 header 解释或代理。
## 验证
@@ -42,6 +42,8 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session`
持有浏览器 cookie 就能调用完整的工具型 Host API,这与 Web 应用在创建 Session 后暴露的 authority 一致。`Host` 不授予更高的方法层级,方法在 API Proxy 与 Typert Remote 之间迁移也不会改变调用者集合。
-持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期。删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。不设置 `Secure` 保留 loopback HTTP,但如果操作者让同一 cookie authority 经未加密网络可达,cookie 会以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。
+持久密钥使 cookie 跨重启生效,也让被盗 cookie 最多保有配置的绝对有效期。删除记录并重启进程是全局撤销机制;当前 Connection 刻意避免在每个请求上访问凭据提供方。如果 HTTP listener 的 authority 经未加密网络可达,cookie 就可能以明文传输。启动 URL 含进程凭据,必须视为敏感输出;运行时诊断不会重复它。
本决策部分取代[浏览器信任说明](2026-07-28-api-browser-trust-boundary.zh.md)中的认证延期与未认证非 loopback 后果。该说明仍是媒体类型、Host、Origin、Fetch-Metadata 和配置 authority 校验的有效权威。没有 active Agent Note 被归档:重叠只发生在局部,两条安全规则都保有未来决策价值。
+
+[原生 HTTPS 监听器决策](2026-09-23-native-https-web-listener.zh.md)取代本说明仅支持 HTTP 传输及不设置 `Secure` 的前提。`Secure` 由监听器协议决定;令牌交换、签名 cookie 的 authority 与凭据有效期仍由本说明定义。
diff --git a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml
index 423de99127..6c236fcf0f 100644
--- a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.i18n.yaml
@@ -9,8 +9,11 @@
en: f67f6add03a36e85
zh: 656be288f8b2e1d2
/agent-note-posix-ssh-execution-providers/decision:
- en: b2d1e370d008f0f3
- zh: 3e98919538d3fb78
+ en: bc22949098fc3028
+ zh: 76618180bc0bac56
+/agent-note-posix-ssh-execution-providers/decision/helper-deployment:
+ en: c479fdb58ed617f1
+ zh: 17cfefa778e3fd71
/agent-note-posix-ssh-execution-providers/alternatives-considered:
en: 4efffb3edba91689
zh: 40100a70e5f55a42
@@ -18,8 +21,8 @@
en: f29359ab89feb084
zh: db7505b8d4a5351a
/agent-note-posix-ssh-execution-providers/verification:
- en: 822c7c029a77f3c0
- zh: 4a2eaaf3372cba8b
+ en: ee72e88fd40d6769
+ zh: 56f0e0c8f3365973
/agent-note-posix-ssh-execution-providers/deferred-work:
en: f300f112c9a09f7e
zh: b4708a0b7f596420
diff --git a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
index d54600faa6..3bd1c340d1 100644
--- a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
+++ b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.md
@@ -22,12 +22,20 @@ Each stream receives a fresh 256-bit TLS pre-shared key through private administ
Collected stdout and stderr carry bounded tail snapshots through handlers that only update output observations. Capture continues while a snapshot is waiting for transport. Final snapshots preserve raw-byte offsets, and completed spill files use the local provider’s retained-output storage after connection disposal.
-Readiness verifies the installed helper digest and, when PTC is configured, the installed Node bootstrap digest. These checks pin expected deployment artifacts; they do not authenticate a malicious remote operating system. The helper disables Node debugger activation through `SIGUSR1`: file-effect confinement can still permit same-user signals, which must not expose the helper's unrestricted filesystem and process services. File-effect confinement delegates to the remote local sandbox provider and retains its full/partial disclosure and platform limitations.
+Readiness verifies the installed helper script or executable digest and, when a script deployment supplies a PTC bootstrap, that bootstrap's digest. Executable deployments embed the PTC worker and require no separate bootstrap. These checks pin expected deployment artifacts; they do not authenticate a malicious remote operating system. The helper disables Node debugger activation through `SIGUSR1`: file-effect confinement can still permit same-user signals, which must not expose the helper's unrestricted filesystem and process services. File-effect confinement delegates to the remote local sandbox provider and retains its full/partial disclosure and platform limitations.
-Path canonicalization belongs where the files exist. The shared policy resolver preserves absolute execution-world spelling; enforcing providers resolve symlinks and `..` on their own filesystem. Headless records and validates cwd through `ctx.fs`. Host-path projection remains unavailable for SSH, so Node execution requires an explicitly installed remote bootstrap.
+Path canonicalization belongs where the files exist. The shared policy resolver preserves absolute execution-world spelling; enforcing providers resolve symlinks and `..` on their own filesystem. Headless records and validates cwd through `ctx.fs`. Host-path projection remains unavailable for SSH; PTC uses an explicitly installed remote bootstrap for script deployments or the helper executable's embedded worker.
A lost connection invalidates pending operations without reconnect or replay. Helper EOF, signals and a heartbeat lease initiate remote native cleanup. The client cannot turn lease expiry into an observed successful termination: an interrupted mutation or launch can have an unknown outcome. Administrative request deadlines, consumer execution deadlines and cleanup remain separate owners.
+### Helper deployment
+
+The explicit `launch.kind` selects remote `node-script` or `executable` invocation independently of the local Harness carrier. A local `process.pkg` flag cannot describe the remote installation. Protocol 2 includes the remote runtime kind in its readiness response and rejects protocol or launch-kind mismatches, so a client cannot apply script bootstrap rules to an embedded worker. Client and helper protocol upgrades must be deployed together.
+
+The executable embeds Node, the helper, managed subprocess runners and the PTC worker. It reuses the [SDK executable packaging decision](2026-07-10-single-file-executable-sdk-runtime-distribution.md): `pkg --sea` preserves a package tree and dynamic module resolution instead of maintaining a separate static plugin registry. That record owns the comparison with bare Node SEA. The embedded runtime serves these private entry points; project commands still depend on the remote machine's development tools.
+
+The four release targets cover Linux and macOS on x64 and arm64, matching the POSIX providers and their supported native resources. Packaging does not add Windows or musl support. Landlock and macOS PTY launchers remain real files beside the executable because the operating system cannot execute paths inside pkg's virtual filesystem. The installation unit is therefore the complete archive directory, with native resources and integrity metadata, rather than the executable alone. The [helper runtime reference](../../../../packages/ssh/ssh-helper-runtime/README.md) owns the build matrix, deployment baselines and archive verification commands.
+
## Alternatives considered
**Frame every stream over SSH exec stdout/stdin.** This avoids Unix-socket forwarding, but requires per-stream credits, queue bounds and independent EOF semantics inside the application protocol. Independent SSH channels use the transport’s existing flow control and keep program output away from administrative framing.
@@ -48,7 +56,7 @@ The portable-consumer decision remains active; this note supplies its SSH realiz
## Verification
-Required protocol and lifecycle evidence covers malformed frames, bounds, reservation cancellation, authenticated stream publication and disconnect errors. Native SSH acceptance must exercise remote file guards and symlink identity, Bash confinement, fd 7 binary traffic, control progress under paused output, terminal operations, LSP and Node execution. Live checks require an explicitly configured disposable remote workspace; keyless tests do not provision one.
+Required protocol and lifecycle evidence covers malformed frames, bounds, reservation cancellation, authenticated stream publication and disconnect errors. Native SSH acceptance must exercise remote file guards and symlink identity, Bash confinement, fd 7 binary traffic, control progress under paused output, terminal operations, LSP and Node execution. External-host checks require an explicitly configured disposable remote workspace. Packaged Linux acceptance provisions a disposable OpenSSH container without Node; native-host checks own confinement evidence.
Security evidence requires both a same-user connector and a replaced socket listener that cannot claim a reserved stream, learn its key, alter another run or receive its plaintext output. Process-lifecycle evidence distinguishes direct exit from managed-range quiescence and tests cancellation both before launch publication and after the payload starts. Source and built profile checks verify the installed helper/bootstrap arrangement without transferring host credentials to program environments.
diff --git a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md
index a6b3a84c37..5ad2d3c471 100644
--- a/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-11-posix-ssh-runtime.zh.md
@@ -22,12 +22,20 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec
收集的 stdout 与 stderr 通过仅更新输出观测的处理器传递有界尾部快照。快照等待传输时,输出捕获继续进行。最终快照保留原始字节偏移,已完成的 spill 文件在连接释放后使用本地提供方的保留输出存储。
-就绪流程验证已安装辅助程序的摘要,并在配置 PTC 时验证已安装 Node 引导程序的摘要。这些检查固定预期部署产物,不用于认证恶意远端操作系统。辅助程序禁用通过 `SIGUSR1` 启动 Node 调试器:文件效果限制仍可能允许同用户信号,这些信号不得暴露辅助程序不受限的文件系统和进程服务。文件效果限制委托给远端本地沙箱提供方,保留其完整/部分执行披露及平台限制。
+就绪流程验证已安装辅助脚本或可执行文件的摘要;脚本部署提供 PTC 引导程序时,还验证该引导程序的摘要。可执行文件部署内嵌 PTC 工作进程,无需单独的引导程序。这些检查固定预期部署产物,不用于认证恶意远端操作系统。辅助程序禁用通过 `SIGUSR1` 启动 Node 调试器:文件效果限制仍可能允许同用户信号,这些信号不得暴露辅助程序不受限的文件系统和进程服务。文件效果限制委托给远端本地沙箱提供方,保留其完整/部分执行披露及平台限制。
-路径规范化属于文件实际存在的位置。共享策略解析器保留执行环境中的绝对路径写法;执行限制的提供方在自己的文件系统上解析符号链接与 `..`。headless 通过 `ctx.fs` 记录和验证 cwd。SSH 不提供主机路径投影,因此 Node 执行需要显式安装的远端引导程序。
+路径规范化属于文件实际存在的位置。共享策略解析器保留执行环境中的绝对路径写法;执行限制的提供方在自己的文件系统上解析符号链接与 `..`。headless 通过 `ctx.fs` 记录和验证 cwd。SSH 不提供主机路径投影;PTC 在脚本部署中使用显式安装的远端引导程序,在可执行文件部署中使用辅助程序内嵌的工作进程。
连接丢失会使待处理操作失效,不进行重连或重放。辅助进程 EOF、信号及心跳租期会启动远端原生清理。客户端不能把租期到期当作观察到的成功终止:被中断的修改或启动可能结果未知。管理请求截止时限、消费方执行截止时限及清理分别由各自归属方负责。
+### 辅助程序部署
+
+显式的 `launch.kind` 选择远端的 `node-script` 或 `executable` 调用方式,与本地 Harness 的运行载体相互独立。本地 `process.pkg` 标志无法描述远端安装。协议 2 在就绪响应中包含远端运行时类型,并拒绝协议版本或启动类型不匹配,避免客户端把脚本引导规则用于内嵌工作进程。客户端与辅助程序的协议升级必须一起部署。
+
+可执行文件内嵌 Node、辅助程序、托管子进程运行器与 PTC 工作进程。它复用 [SDK 可执行文件打包决策](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md):`pkg --sea` 保留包目录树与动态模块解析,无需维护独立的静态插件注册表。该记录负责与裸 Node SEA 的方案比较。内嵌运行时服务于这些私有入口;项目命令仍依赖远端机器的开发工具。
+
+四个发布目标覆盖 Linux 和 macOS 的 x64 与 arm64,对应 POSIX 提供方及其支持的原生资源。打包不会增加 Windows 或 musl 支持。Landlock 与 macOS PTY 启动器保留为可执行文件旁的真实文件,因为操作系统无法执行 pkg 虚拟文件系统中的路径。因此,安装单元是包含原生资源与完整性元数据的完整归档目录,而非单个可执行文件。[辅助程序运行时参考](../../../../packages/ssh/ssh-helper-runtime/README.zh.md)负责构建矩阵、部署基线及归档验证命令。
+
## 考虑过的替代方案
**在 SSH exec stdout/stdin 上为所有流分帧。** 这避免了 Unix 套接字转发,但要求应用协议自行实现逐流额度、队列上限及独立 EOF 语义。独立 SSH 通道使用传输层既有流控,并将程序输出与管理帧分开。
@@ -48,7 +56,7 @@ SSH 提供经过认证的字节通道及逐通道流量控制,但普通 exec
## 验证
-必要的协议及生命周期证据覆盖畸形消息、上限、预留取消、经过认证的流发布及断连错误。原生 SSH 验收必须覆盖远端文件保护与符号链接身份、Bash 限制、fd 7 二进制流、输出暂停时的控制进展、终端操作、LSP 及 Node 执行。实时检查要求显式配置的可丢弃远端工作区;无密钥测试不创建远端环境。
+必要的协议及生命周期证据覆盖畸形消息、上限、预留取消、经过认证的流发布及断连错误。原生 SSH 验收必须覆盖远端文件保护与符号链接身份、Bash 限制、fd 7 二进制流、输出暂停时的控制进展、终端操作、LSP 及 Node 执行。外部主机检查要求显式配置的可丢弃远端工作区。打包产物的 Linux 验收会创建未安装 Node 的可丢弃 OpenSSH 容器;原生主机检查负责限制执行的证据。
安全证据要求同时尝试同用户连接与替换套接字监听端点,并确认其无法占用预留流、获知流密钥、修改另一执行或收到其明文输出。进程生命周期证据区分直接退出与托管进程范围静止,并测试启动发布前及程序启动后的取消。源码与构建后配置组合检查验证已安装辅助程序/引导安排,不向程序环境传递主机凭据。
diff --git a/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.i18n.yaml
new file mode 100644
index 0000000000..f729428b68
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-19-concrete-web-bind-address.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-19-concrete-web-bind-address.md
+/agent-note-bind-one-concrete-web-address:
+ en: f70f2b6b24ef16dd
+ zh: 64100dbc55eee33d
+/agent-note-bind-one-concrete-web-address/problem:
+ en: ffc265e61eadc31b
+ zh: fcc70f0a8817914c
+/agent-note-bind-one-concrete-web-address/decision:
+ en: 5fe4f2ddba2ee2da
+ zh: bb140a16f056d4b4
+/agent-note-bind-one-concrete-web-address/alternatives-considered:
+ en: 0e4d475347796821
+ zh: e621e606bc5b1045
+/agent-note-bind-one-concrete-web-address/consequences:
+ en: ba7a839bca09992d
+ zh: a11171005405d1e2
+/agent-note-bind-one-concrete-web-address/testing:
+ en: 4bb8f4bf4ad943c3
+ zh: 2e24d56d01b52574
diff --git a/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.md b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.md
new file mode 100644
index 0000000000..3f6be5132e
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.md
@@ -0,0 +1,35 @@
+# Agent Note: Bind one concrete Web address
+
+Status: implemented
+
+English | [中文](2026-09-19-concrete-web-bind-address.zh.md)
+
+## Problem
+
+The Web listener accepted `127.0.0.1` or `0.0.0.0`. A wildcard bind exposed every interface of the machine, so the bundle sampled the LAN IPv4 addresses at bind time into a `webRuntime` service and appended them to `trustedHosts`, granting authorities for interfaces the operator never named and mixing inferred reachability into configured trust. A container or workstation that should expose exactly one address had no way to say so, IPv6 addresses were not accepted at all, and a browser dialing the exposed address sent it as `Host` only to be refused unless the sampled list happened to contain it.
+
+## Decision
+
+`webserver.config.host` accepts one concrete IPv4 or IPv6 address literal, loopback by default; the unspecified address in every spelling (`0.0.0.0`, `::`, `::ffff:0.0.0.0`, and their expansions) is rejected when the config loads, and the shipped `dsh web` command refuses the same spellings for `--host` before any consumer activates. An IPv6 `%zone` is kept for `listen`; classification through the exported `isWildcardHost` and `isLoopbackHost` reads the address alone.
+
+The browser Host/Origin fence accepts the listener's own bind IP literal as `Host` on any port, independently of `trustedHosts` and ignoring the zone. A browser dialing that literal presents it as `Host`, the literal is no rebinding target, and the Origin and cross-site checks still apply. DNS names resolving to the address, other interfaces, and proxy authorities still require `--trusted-host`. Connection reads the invocation authorities directly from the `webStartup` provider; the `webRuntime` service and LAN sampling no longer exist.
+
+The advertised URL uses IPv4 for mapped loopback binds, brackets other IPv6 binds, drops a loopback zone, and requires `--public-url` for a zoned non-loopback bind because a zone cannot appear in a URL. A non-loopback bind serves plain HTTP and warns at startup even when an HTTPS `--public-url` fronts it. `web-runtime` is a required startup entry, so a bind failure fails startup instead of leaving a silent listener-less process. Desktop pins `--host 127.0.0.1`, the address Electron dials.
+
+## Alternatives considered
+
+**Keep sampling LAN addresses into `trustedHosts`.** Rejected: the listener already owns the concrete bind address. Sampling grants authorities for other interfaces and mixes inferred reachability with configured trust; direct bind-IP acceptance preserves that distinction without another runtime service.
+
+**Accept the bind IP only on the bound port.** Rejected: a port-less `trustedHosts` entry already matches any port, a proxy on the same address may listen elsewhere, and the port adds nothing to the rebinding defense the Host check provides.
+
+**Keep `0.0.0.0` with a warning.** Rejected: binding every interface exposes remote code execution to every network the machine joins, and the operator can name each address that should be exposed.
+
+## Consequences
+
+An operator exposes exactly the interfaces named by `--host` and reaches the listener through that literal without further configuration; every other remote authority is a `--trusted-host` decision. Compositions that bound `0.0.0.0` fail at load with a message naming the rejected spelling. IPv6-only hosts are supported end to end, including the printed and opened URL.
+
+The [native HTTPS listener decision](2026-09-23-native-https-web-listener.md) supersedes this note's HTTP-only transport assumption. The non-loopback warning applies only without TLS; bind-address validation and authority admission remain unchanged.
+
+## Testing
+
+`packages/host/webserver/tests/webserver.spec.ts` classifies wildcard and loopback spellings and rejects wildcard config; `packages/bundle/web-app/tests/startup.spec.ts` refuses wildcard `--host` before consumers activate; `packages/client/connection/tests/api-request-trust.host.spec.ts` and `node-half.host.spec.ts` admit the bind literal on any port without admitting other authorities; `packages/bundle/web-app/tests/web-app.spec.ts` covers the bracketed IPv6 URL and the plain-HTTP warning; `apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` boots the built profile on a non-loopback and a zoned loopback address; `web-failure-matrix.expected.e2e.ts` and `packages/boot/app-boot/tests/app-boot.spec.ts` report the required `web-runtime` entry.
diff --git a/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.zh.md b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.zh.md
new file mode 100644
index 0000000000..4df6b8384f
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-19-concrete-web-bind-address.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 绑定一个具体的 Web 地址
+
+Status: implemented
+
+[English](2026-09-19-concrete-web-bind-address.md) | 中文
+
+## 问题
+
+Web 监听器只接受 `127.0.0.1` 或 `0.0.0.0`。通配绑定会暴露机器的每个接口,因此 bundle 在绑定时把 LAN IPv4 地址采样进 `webRuntime` 服务并追加到 `trustedHosts`,为操作者从未点名的接口授予 authority,并把推断的可达性混入配置的信任。应当只暴露一个地址的容器或工作站无法表达这一点,IPv6 地址完全不被接受,浏览器拨号已暴露地址时把它作为 `Host` 发送,却只在采样列表恰好包含它时才被接受。
+
+## 决策
+
+`webserver.config.host` 接受一个具体的 IPv4 或 IPv6 地址字面量,默认回环;未指定地址的所有拼写(`0.0.0.0`、`::`、`::ffff:0.0.0.0` 及其展开形式)在配置加载时被拒绝,随附的 `dsh web` 命令在任何消费者激活前对 `--host` 拒绝同样的拼写。IPv6 的 `%zone` 保留给 `listen`;通过导出的 `isWildcardHost` 与 `isLoopbackHost` 分类时只读取地址本身。
+
+浏览器 Host/Origin 栅栏接受监听器自身的绑定 IP 字面量作为任意端口上的 `Host`,独立于 `trustedHosts` 并忽略 zone。拨号该字面量的浏览器把它作为 `Host` 呈现,该字面量不是重绑目标,Origin 与跨站检查仍然生效。解析到该地址的 DNS 名称、其他接口与代理 authority 仍需 `--trusted-host`。Connection 直接从 `webStartup` 提供方读取调用 authority;`webRuntime` 服务与 LAN 采样不再存在。
+
+公告 URL 对 mapped 回环绑定使用 IPv4,为其他 IPv6 绑定加方括号、剥离回环 zone,并因 zone 不能出现在 URL 中而要求带 zone 的非回环绑定显式提供 `--public-url`。非回环绑定提供明文 HTTP,即使前置 HTTPS 的 `--public-url` 也会在启动时警告。`web-runtime` 是必需的启动条目,因此绑定失败会使启动失败,而不是留下一个没有监听器的静默进程。Desktop 固定 `--host 127.0.0.1`,即 Electron 拨号的地址。
+
+## 备选方案
+
+**继续把采样的 LAN 地址加入 `trustedHosts`。** 否决:监听器已经持有具体绑定地址。采样会授权其他接口的 authority,并把推断的可达性混入配置的信任;直接接受绑定 IP 可以保留两者的区分,无需另设运行时服务。
+
+**只在绑定端口上接受绑定 IP。** 否决:不带端口的 `trustedHosts` 条目本就匹配任意端口,同一地址上的代理可能监听其他端口,端口对 Host 检查提供的重绑防御没有增益。
+
+**保留 `0.0.0.0` 并给出警告。** 否决:绑定所有接口会向机器加入的每个网络暴露远程代码执行,而操作者可以逐个点名应暴露的地址。
+
+## 影响
+
+操作者只暴露 `--host` 点名的接口,并无需额外配置即可通过该字面量访问监听器;其他每个远程 authority 都是一个 `--trusted-host` 决定。曾绑定 `0.0.0.0` 的组合在加载时失败,并给出点名被拒绝拼写的消息。纯 IPv6 主机得到端到端支持,包括打印与打开的 URL。
+
+[原生 HTTPS 监听器决策](2026-09-23-native-https-web-listener.zh.md)取代本说明仅支持 HTTP 传输的前提。非回环警告只适用于未启用 TLS 的监听器;绑定地址校验与 authority 接纳保持不变。
+
+## 测试
+
+`packages/host/webserver/tests/webserver.spec.ts` 分类通配与回环拼写并拒绝通配配置;`packages/bundle/web-app/tests/startup.spec.ts` 在消费者激活前拒绝通配 `--host`;`packages/client/connection/tests/api-request-trust.host.spec.ts` 与 `node-half.host.spec.ts` 在任意端口上接纳绑定字面量而不接纳其他 authority;`packages/bundle/web-app/tests/web-app.spec.ts` 覆盖带方括号的 IPv6 URL 与明文 HTTP 警告;`apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts` 在非回环地址与带 zone 的回环地址上启动已构建的 profile;`web-failure-matrix.expected.e2e.ts` 与 `packages/boot/app-boot/tests/app-boot.spec.ts` 报告必需的 `web-runtime` 条目。
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 604c1fe195..41376c7c73 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
@@ -30,8 +30,8 @@
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
+ en: dd1cc7ac96a5a25d
+ zh: bdea08d20f166e65
/agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/what-stays-untouched:
en: 1cefba02bbfb2647
zh: 5a96942565ab9d72
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 5ef27cb076..f0235d8040 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
@@ -97,7 +97,7 @@ Writer classification:
| the `projections` block of an `api-session/added` summary (`manager.handleSessionAdded`) | by the block's `kind`; the summary comes from a live session, so in practice `sequenced` |
| the history first page's `projections` (`projections.seed` in `session.ts`) | sequenced |
| the control baseline, live sessions only (`manager.replaceControlBaseline`) | sequenced |
-| the `session.projections` result of `refreshProjections` (a body observation) | sequenced |
+| the `session.projections` result of `refreshProjections` | sequenced for an exact read; cached only when [migration is required](2026-09-29-explicit-session-history-preparation.md) |
| push frames (the `projection` frame handled by `manager`) | sequenced |
| the `title` after a successful rename (`session.ts`) | sequenced |
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 e69db37e7e..326e6f028b 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
@@ -97,7 +97,7 @@ seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `cl
| `api-session/added` 摘要的 `projections` block(`manager.handleSessionAdded`) | 按 block 的 `kind`;该摘要来自活会话,实际为 `sequenced` |
| history 首页 `projections`(`session.ts` 的 `projections.seed`) | sequenced |
| control 基线,仅活会话(`manager.replaceControlBaseline`) | sequenced |
-| `refreshProjections` 的 `session.projections` 结果(正文观察) | sequenced |
+| `refreshProjections` 的 `session.projections` 结果 | 准确读取为 sequenced;仅[需要迁移](2026-09-29-explicit-session-history-preparation.zh.md)时为 cached |
| 推送 frame(`manager` 处理 `projection` frame) | sequenced |
| rename 成功后的 `title`(`session.ts`) | sequenced |
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
index e7aef9adc0..448629299d 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.i18n.yaml
@@ -9,11 +9,11 @@
en: 2ba6c43b20a3d347
zh: 5647179ddaa38ad9
/agent-note-ship-experimental-capabilities-as-optional-bundles/decision:
- en: e6ffc783859d7509
- zh: 8678f323985a24f4
+ en: 6c8583bdefc5e36c
+ zh: 0928b4f2876bbfe4
/agent-note-ship-experimental-capabilities-as-optional-bundles/alternatives-considered:
- en: 9ff0511566948d8b
- zh: 4e1e37b79f63d390
+ en: d6f40bf02df37ab7
+ zh: e5b85ce9d41e8754
/agent-note-ship-experimental-capabilities-as-optional-bundles/consequences:
- en: 9d2b313f15fc9ce0
- zh: 522341c83565ac33
+ en: 38d27583533cd028
+ zh: 9bf11f5489c1aa43
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
index d529b27542..dbdd104da0 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.md
@@ -14,7 +14,7 @@ The Web plugin page offered two optional bundles, Agent Teams and voice input. A
An optional bundle's dependencies are downloaded with every `dsh` installation. [Reasoning translation](2026-10-05-anonymous-reasoning-translation.md) uses three experimental packages without adding third-party runtime dependencies. Admission therefore considers installation cost and whether a switch supplies a usable composition without an additional configuration form. Browser and computer providers remain explicit compositions because they add substantial runtimes or require an external executable. Stagehand requires model credentials; Python PTC replaces a core provider and conflicts with TypeScript workflow consumers. Demonstrative hooks, webhooks, and mods stay outside the list. [Session Inspector](../feature/2026-09-24-session-inspector.md) owns the Inspector's distinct activation behavior.
-Bundles add scoped rows with the [profile patch operation](../../../../packages/boot/app-boot/README.md): `preset` names a declaration row, and the ordinary patch operates on its child list. Full Web presets receive the optional model tools; minimal retains its current shell and working-directory tools. Search, Ralph, and terminals supply their own isolated services. Search opens one in-memory index on demand per retained preset revision; the Host search policy stays unchanged. Badge skills and title providers act on the Host. Existing Agents retain their preset generation, and later user patch layers retain precedence. Title replacement waits for disposal of the previous provider.
+Bundles insert ordinary Host rows; optional model tools register in the global tool layer, so every preset, including minimal, receives them. Plugin management, profile composition, and reload need no bundle-specific behavior. Search, Ralph, and terminals supply their own isolated services in a Host group. Search opens one in-memory index on demand; the Host search policy stays unchanged. Badge skills and title providers act on the Host. Switching a bundle registers or removes its tools for live Agents, and later user patch layers retain precedence. A replacement title provider may register once the previous provider's disposal starts; the previous provider's late results never commit.
Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES` catalog; the [on-demand native-bundle decision](2026-10-05-official-on-demand-bundles.md) owns the cost, version, and offline-discovery trade-offs.
@@ -26,6 +26,8 @@ Claude Code and Codex install on demand through the separate `ON_DEMAND_BUNDLES`
**Register preset extensions at runtime.** A separate registration path would need its own ordering, module resolution, generation updates, and inspection behavior. Profile patches keep one effective composition for boot, dumps, validation, and user overrides.
+**Insert rows into named presets with a `preset` patch operation.** Limiting tools to full presets required a second patch language across profile composition, reload, plugin management, and the plugin page, plus retained-generation removal checks. Global tools reach the same Agents through the existing Host composition.
+
## Consequences
-Official discovery does not imply experimental status or a different plugin lifecycle. Switching a bundle off keeps its installed files; failed activation retains the existing saved-selection behavior. Scoped rows appear read-only under their bundle, and removal respects modules retained by live preset generations. Individual and combined composition tests cover the supported presets; live switching and recorded Sessions cover activation and model-visible behavior.
+Official discovery does not imply experimental status or a different plugin lifecycle. Switching a bundle off keeps its installed files; failed activation retains the existing saved-selection behavior. Bundle rows are ordinary Host rows with their usual switches. Individual and combined composition tests cover the Host rows and every preset's tool catalog; live switching and recorded Sessions cover activation and model-visible behavior.
diff --git a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
index 13abff45a0..6c9df974e8 100644
--- a/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-21-experimental-capabilities-as-optional-bundles.zh.md
@@ -14,7 +14,7 @@ Web 插件页只提供两个可选 bundle:Agent Teams 与语音输入。Auto r
可选 bundle 的依赖随每次 `dsh` 安装下载。[思考过程翻译](2026-10-05-anonymous-reasoning-translation.zh.md)使用三个实验包,不增加第三方运行时依赖。因此准入需要考虑安装成本,以及开关能否在不增加配置表单的情况下提供可用组合。浏览器与电脑提供方仍使用显式组合,因为它们会增加较大的运行时或要求外部可执行文件。Stagehand 需要模型凭据;Python PTC 替换核心提供方,并与 TypeScript workflow 消费方冲突。演示性钩子、webhook 与模组不进入名单。[Session Inspector](../feature/2026-09-24-session-inspector.zh.md) 管理 Inspector 特有的激活行为。
-Bundle 通过 [profile patch 操作](../../../../packages/boot/app-boot/README.zh.md)添加作用域行:`preset` 指定声明行,普通 patch 对其子列表生效。完整 Web 预设获得可选模型工具;minimal 保持当前的 shell 与工作目录工具。搜索、Ralph 与终端提供自己的隔离服务。搜索在每个保留的预设修订中按需打开一个内存索引;Host 搜索策略保持不变。徽章技能与标题提供方作用于 Host。已有 Agent 保留其预设代际,后续用户 patch 层继续优先。标题替换等待前一个提供方完成清理。
+Bundle 插入普通 Host 行;可选模型工具注册到全局工具层,因此包括 minimal 在内的所有预设都会获得它们。插件管理、profile 组合与重载无需针对 bundle 的特殊行为。搜索、Ralph 与终端在 Host 组内提供自己的隔离服务。搜索按需打开一个内存索引;Host 搜索策略保持不变。徽章技能与标题提供方作用于 Host。切换 bundle 会为运行中的 Agent 注册或移除其工具,后续用户 patch 层继续优先。前一个标题提供方开始清理后,替代提供方即可注册;前一个提供方的迟到结果不会提交。
Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装,其成本、版本与离线发现取舍由[按需原生 bundle 决策](2026-10-05-official-on-demand-bundles.zh.md)管理。
@@ -26,6 +26,8 @@ Claude Code 与 Codex 通过独立的 `ON_DEMAND_BUNDLES` 目录按需安装,
**在运行时注册预设扩展。** 独立注册路径需要自己的排序、模块解析、代际更新与检查行为。Profile patch 为启动、配置导出、校验和用户覆盖保留同一个有效组合。
+**用 `preset` patch 操作向指定预设插入行。** 只把工具限定在完整预设中,需要在 profile 组合、重载、插件管理和插件页中引入第二套 patch 语言,并为保留代际增加删除检查。全局工具通过现有 Host 组合即可到达同样的 Agent。
+
## 后果
-官方发现不表示实验性状态,也不引入不同的插件生命周期。关闭 bundle 保留已安装文件;激活失败保留现有的已保存选择行为。作用域行在 bundle 内只读展示,删除操作尊重 live 预设代际仍持有的模块。独立与组合测试覆盖受支持的预设;实时切换与已记录 Session 覆盖激活和模型可见行为。
+官方发现不表示实验性状态,也不引入不同的插件生命周期。关闭 bundle 保留已安装文件;激活失败保留现有的已保存选择行为。Bundle 行是普通 Host 行,使用常规开关。独立与组合测试覆盖 Host 行及每个预设的工具目录;实时切换与已记录 Session 覆盖激活和模型可见行为。
diff --git a/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.i18n.yaml
new file mode 100644
index 0000000000..0aad921cce
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.i18n.yaml
@@ -0,0 +1,22 @@
+# Bilingual-pair consistency record for 2026-09-23-native-https-web-listener.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-native-https-web-listener.md
+/agent-note-native-https-on-the-web-listener:
+ en: bdb6e7794af113f0
+ zh: b9f325b0cbe8d34d
+/agent-note-native-https-on-the-web-listener/problem:
+ en: 685a00e6f84f642d
+ zh: 404e463f56ec5a1f
+/agent-note-native-https-on-the-web-listener/decision:
+ en: 7f9ea8a4f2577d2f
+ zh: e9b2a1fc6944428e
+/agent-note-native-https-on-the-web-listener/alternatives-considered:
+ en: 44c051936dd2529e
+ zh: 7588669ec4c302ef
+/agent-note-native-https-on-the-web-listener/consequences:
+ en: 6a8be07b24f957ba
+ zh: fbf48476e092dfc8
+/agent-note-native-https-on-the-web-listener/testing:
+ en: f92f7ec224dba94d
+ zh: 0f0e0304c834f0af
diff --git a/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md
new file mode 100644
index 0000000000..123f762559
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.md
@@ -0,0 +1,41 @@
+# Agent Note: Native HTTPS on the Web listener
+
+Status: implemented
+
+English | [中文](2026-09-23-native-https-web-listener.zh.md)
+
+## Problem
+
+A directly reached Web listener needs to encrypt launch tokens and session cookies without requiring a separate reverse proxy. Advertising an HTTPS URL does not encrypt the listener or establish certificate trust.
+
+## Decision
+
+WebServer owns optional TLS through `tls: { certFile, keyFile }`, exposed by `dsh web` as paired `--tls-cert` and `--tls-key` flags. Certificate and unencrypted private-key PEM files are loaded before binding; unreadable, empty, invalid, or mismatched material fails activation without an HTTP fallback. HTTP remains the default. Both transports share routing, upgrades, and disposal; disposal also owns incomplete TLS handshakes.
+
+`webServer.protocol` is the source for the bind-address URL, the non-loopback plain-HTTP warning, and Connection's `Secure` cookie attribute. Neither forwarded headers nor `publicUrl` control encryption or that attribute. Explicit Host-port grants normalize under the listener's scheme, while Origin comparison uses the Origin's HTTP(S) scheme so HTTPS proxies can retain HTTP upstreams. TLS grants no additional Host authority and does not replace authentication.
+
+The API authority check does not equate a proxy's upstream transport with the browser page's scheme. An HTTPS listener can accept a matching HTTP Origin, but `sec-fetch-site: cross-site` still rejects the request and authentication remains required. Schemeful `SameSite=Strict` cookies prevent cross-scheme browser credential delivery; this is not support for serving a public browser frontend over HTTP with a TLS upstream.
+
+HTTP cookie audiences retain their existing spelling so enabling TLS support does not invalidate unrelated HTTP sessions. HTTPS cookie names and signed audiences include the `https:` scheme, preventing replay of an HTTP credential on any HTTPS port even if the cookie is renamed. Default ports follow URL normalization without a separate port-443 spelling.
+
+Certificate issuance, trust, renewal, and replacement belong to the operator. Files are read once per activation; replacement requires a listener reload or process restart. The certificate must cover the hostname or IP the TLS client dials. The default port remains 3080; `--port` selects another port, subject to operating-system permission.
+
+## Alternatives considered
+
+**Require a TLS-terminating proxy.** This remains supported, but requires another service for a directly reached listener.
+
+**Derive transport or cookie security from `publicUrl` or forwarding headers.** An advertisement does not describe the receiving socket, and request headers cannot establish trusted proxy identity. Native listener state keeps this decision independent of proxy policy.
+
+**Issue, renew, or generate certificates.** This would add certificate lifecycle and trust-distribution responsibilities. An automatically generated certificate would not establish browser trust.
+
+**Fall back to HTTP on certificate errors.** This would expose credentials despite an explicit request for encryption; activation must fail instead.
+
+## Consequences
+
+Direct HTTPS needs no TLS-terminating proxy. An HTTPS proxy in front of an HTTP listener still owns external cookie rewriting. An HTTPS listener emits `Secure` cookies, so public browser-facing deployments must retain HTTPS rather than depend on local-browser exceptions for HTTP. Deployment procedures belong to the [public deployments guide](../../../../docs/user/guide/public-deployments.md).
+
+This partially supersedes the HTTP-only assumptions in [browser launch-token authentication](2026-08-24-browser-token-authentication.md) and [concrete bind addresses](2026-09-19-concrete-web-bind-address.md). Those notes remain active for cookie payloads and lifetimes, and bind admission respectively.
+
+## Testing
+
+The WebServer suite covers verified HTTPS, invalid material, and disposal. Connection covers listener-owned cookie attributes, cross-protocol credential replay, and exact-port grants; Web startup rejects unpaired flags. The built CLI's `public-url.expected.e2e.ts` authenticates over verified TLS with a `Secure` cookie while retaining HTTP and proxy cases; its failure matrix verifies unreadable TLS material exits without opening a listener. Desktop's startup suite covers matching WS/WSS credentials and refusal on a different transport.
diff --git a/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.zh.md b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.zh.md
new file mode 100644
index 0000000000..c34ad19b31
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-23-native-https-web-listener.zh.md
@@ -0,0 +1,41 @@
+# Agent Note: Web 监听器原生 HTTPS
+
+Status: implemented
+
+[English](2026-09-23-native-https-web-listener.md) | 中文
+
+## Problem
+
+浏览器直接访问的 Web 监听器需要在不依赖独立反向代理的情况下加密启动 token 与会话 cookie。公告 HTTPS URL 不会加密监听器,也不会建立证书信任。
+
+## Decision
+
+WebServer 通过 `tls: { certFile, keyFile }` 拥有可选 TLS,由 `dsh web` 的成对 `--tls-cert` 与 `--tls-key` flag 暴露。证书与不带口令的私钥 PEM 文件在绑定前加载;材料不可读、为空、无效或不匹配时激活失败,绝不回退到 HTTP。HTTP 仍为默认值。两种传输共用路由、升级与释放;释放也负责未完成的 TLS 握手。
+
+`webServer.protocol` 决定绑定地址 URL、非 loopback 明文 HTTP 警告及 Connection 的 `Secure` cookie 属性。转发 header 与 `publicUrl` 都不控制加密或该属性。显式 Host 端口授权按监听器的 scheme 归一化,Origin 比较则按 Origin 的 HTTP(S) scheme 进行,以允许 HTTPS 代理保留 HTTP 上游。TLS 不授予额外的 Host authority,也不替代认证。
+
+API authority 校验不会把代理的上游传输等同于浏览器页面的 scheme。HTTPS 监听器可接受 authority 匹配的 HTTP Origin,但 `sec-fetch-site: cross-site` 仍会拒绝请求,且请求仍须认证。区分 scheme 的 `SameSite=Strict` cookie 阻止浏览器跨 scheme 携带凭据;这不表示支持以 HTTP 向公开浏览器前端提供页面、同时使用 TLS 上游。
+
+HTTP cookie audience 保留既有写法,避免启用 TLS 支持时使无关的 HTTP 会话失效。HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使修改 cookie 名称,HTTP 凭据也不能在任何 HTTPS 端口上重放。默认端口遵循 URL 归一化,不为 443 端口另设写法。
+
+证书签发、信任、续期与更换归操作者负责。每次激活只读取一次文件;更换后需重新加载监听器或重启进程。证书必须覆盖 TLS 客户端拨号的 hostname 或 IP。默认端口仍为 3080;`--port` 可选择其他端口,但受操作系统权限约束。
+
+## Alternatives considered
+
+**要求终止 TLS 的代理。** 仍然支持,但会为直接访问的监听器增加一个服务。
+
+**根据 `publicUrl` 或转发 header 推导传输或 cookie 安全属性。** 公告不描述接收请求的 socket,请求 header 也不能建立可信代理身份。原生监听器状态使这一决策独立于代理策略。
+
+**签发、续期或生成证书。** 这会增加证书生命周期与信任分发职责。自动生成证书本身不能建立浏览器信任。
+
+**证书错误时回退到 HTTP。** 这会在操作者明确要求加密时暴露凭据;激活必须失败。
+
+## Consequences
+
+直接 HTTPS 不需要终止 TLS 的代理。HTTP 监听器前的 HTTPS 代理仍负责外部 cookie 重写。HTTPS 监听器签发 `Secure` cookie,因此公开部署的浏览器侧必须保持 HTTPS,而不应依赖本地浏览器对 HTTP 的例外。部署步骤归[公开部署指南](../../../../docs/user/guide/public-deployments.zh.md)所有。
+
+本决策部分取代[浏览器启动 token 认证](2026-08-24-browser-token-authentication.zh.md)与[具体绑定地址](2026-09-19-concrete-web-bind-address.zh.md)中仅支持 HTTP 的前提。这些说明分别继续定义 cookie 载荷与有效期及绑定准入,保持活跃。
+
+## Testing
+
+WebServer 套件覆盖校验证书的 HTTPS、无效材料与释放。Connection 覆盖由监听器决定的 cookie 属性、跨协议凭据重放和精确端口授权;Web 启动拒绝未配对的 flag。已构建 CLI 的 `public-url.expected.e2e.ts` 使用 `Secure` cookie 经校验证书的 TLS 完成认证,并保留 HTTP 与代理用例;失败矩阵验证 TLS 材料不可读时进程退出且不打开监听器。Desktop 启动套件覆盖匹配的 WS/WSS 凭据及不同传输下的拒绝。
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
index 64f1ca3e65..cc7f108d4c 100644
--- 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
@@ -15,5 +15,5 @@
en: 88985b4e1f5c7a4f
zh: 32617f7ff75ee825
/agent-note-bounded-deepseek-session-log-upload/consequences:
- en: e19fb6136ffd2250
- zh: 01dacd7a44c3f837
+ en: 288834c514df377a
+ zh: a33a4af2b4d9ebf4
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
index b980765eed..27ae7e4117 100644
--- 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
@@ -25,6 +25,6 @@ This supersedes complete-suffix delivery and fail-closed serialization in the [r
## 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.
+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. Provider rejections still fail the model request; preparation and acceptance failures are logged and do not.
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
index 13dd90ed0a..8e2d6d4785 100644
--- 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
@@ -25,6 +25,6 @@ DeepSeek 适配器把合并后请求正文的序列化失败视为扩展失败
## 后果
-默认情况下,积压按每次已接受请求最多 8 MiB 补传。按那次 269 MiB 上传实测的端到端速度(约 8 MiB/s),补传期间 8 MiB 的字段让每次请求多约 1 秒,0.86 GB 的积压约 100 次请求补完。在同一个 96,341 条事件的会话中,每步追加量的中位数是 11 KiB,最大单条事件是 414 KiB,因此普通步骤远低于上限,只有异常事件或很小的配置上限才会走到超限事件路径。积压补传期间,端点收到的 `throughSeq` 会落后于会话当前末尾。准备失败、接受失败与提供方拒绝仍会使模型请求失败。
+默认情况下,积压按每次已接受请求最多 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-29-explicit-session-history-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml
new file mode 100644
index 0000000000..ddd6d80912
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-09-29-explicit-session-history-preparation.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-29-explicit-session-history-preparation.md
+/agent-note-open-the-session-when-migration-is-required:
+ en: d409772933407317
+ zh: ac258a6d1743965f
+/agent-note-open-the-session-when-migration-is-required/problem:
+ en: ab63c8127d68d294
+ zh: d3f99489c96ff1d4
+/agent-note-open-the-session-when-migration-is-required/decision:
+ en: 32627d744a6e7410
+ zh: 2836a95ee5390111
+/agent-note-open-the-session-when-migration-is-required/alternatives-considered:
+ en: baea781018a49252
+ zh: bcc452029bf229fd
+/agent-note-open-the-session-when-migration-is-required/consequences:
+ en: 979eebb53a6bf711
+ zh: 013743b0b7a000ce
diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md
new file mode 100644
index 0000000000..84b30b6cb4
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md
@@ -0,0 +1,43 @@
+# Agent Note: Open the Session when migration is required
+
+Status: implemented
+
+English | [中文](2026-09-29-explicit-session-history-preparation.zh.md)
+
+## Problem
+
+Expanding a subagent catalog or forking from the sidebar can trigger an old Session's format migration.
+
+## Decision
+
+The WorkspaceBrowser row-menu fork request carries `allowMigration: false`, which the Host handles by checking `stat` format information before calling the original fork flow. Only a source format requiring migration is refused, with an app-wide Toast offering to open the original Session. That action only navigates and does not retry fork. Neither an absent live Session nor unopened Client history justifies refusal.
+
+Other fork callers omit that option and allow automatic migration.
+
+`session.projections` also checks the stored format first; current-format cold history still uses the original observation flow for exact projections. Success returns `kind: sequenced` and its real `asOfSeq`; required migration returns `kind: migration-required` hints. The Client retains that read state, and cached values cannot replace sequenced values published by history or control.
+
+The `formatStatus` returned by `stat` carries the migration library's existing classification; `header` remains the current logical format. It describes the stored format, not migration-task progress or prepared in-memory history.
+
+When migration is required and `subagentCatalog` is absent, Web marks the Session's row “Migration required” and keeps its history-opening actions without descendant expansion or changing its subagent mode. Opening the Session supplies its catalog through history and control projections. Available catalogs remain usable, including empty catalogs. Session-list lineage resolves navigation addresses, not missing catalog membership. Ordinary loading, cancellation, and storage errors do not become this migration branch.
+
+Create/adopt, full-text search, and ordinary history opening allow automatic migration.
+
+This decision adds a migration exception to [catalog projection reads](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md). Exact reads and leases in [Session observations](2026-08-25-session-observations-and-projection-owned-client-state.md), and identity checks and cached/sequenced ordering in [projection-cache reads](2026-09-19-projection-cache-listing-identity-and-cached-rows.md), still apply.
+
+## Alternatives considered
+
+**Treat every cold Session as needing migration.** Residency and stored format are different; this would reject otherwise valid forks and discard catalog information available on disk.
+
+**Globally forbid forks that automatically migrate on the Host.** The sidebar's interaction choice does not govern other callers, so its request explicitly carries the restriction.
+
+**Automatically open and retry the sidebar fork.** Opening is an explicit user action, and fork does not run automatically after migration.
+
+**Derive catalogs from Session-list parent ids.** This adds a second membership derivation; explicit Session opening obtains the catalog.
+
+**Give cached hints `asOfSeq: -1`.** Hints have no event cut in current history and cannot participate in sequence comparisons.
+
+## Consequences
+
+Sessions requiring migration use the existing opening flow to prepare history; Sessions that need no migration remain cold-readable and forkable. Catalog hints may lag the log and yield to subsequent exact projections.
+
+A separate follow-redirection proposal discusses future single-Session migration progress; this decision does not implement that flow.
diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md
new file mode 100644
index 0000000000..31925f7a9f
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md
@@ -0,0 +1,43 @@
+# Agent Note: 需要迁移时引导打开会话
+
+Status: implemented
+
+[English](2026-09-29-explicit-session-history-preparation.md) | 中文
+
+## 问题
+
+展开 subagent 目录或从侧栏 fork 可能触发旧会话的格式迁移。
+
+## 决定
+
+WorkspaceBrowser 行菜单的 fork 请求携带 `allowMigration: false`,由 Host 在调用原 fork 流程前检查 `stat` 的格式信息。只有源格式需要迁移时才拒绝,并显示带“打开原会话”操作的应用级 Toast。该操作只导航,不自动重发 fork。没有 live Session 或 Client 尚未打开历史不构成拒绝理由。
+
+其他 fork 调用者省略该选项,允许自动迁移。
+
+`session.projections` 同样先检查存储格式,当前格式冷历史继续通过原观察流程读取准确投影。成功时返回 `kind: sequenced` 和真实 `asOfSeq`;需要迁移时返回 `kind: migration-required` 的缓存提示。Client 保留这一读取状态,缓存值不能覆盖 history 或 control 已发布的有序值。
+
+`stat` 返回的 `formatStatus` 携带迁移库已有的分类;`header` 仍是当前逻辑格式。它描述存储格式,不描述迁移任务进度或内存准备缓存。
+
+在需要迁移且缺少 `subagentCatalog` 时,Web 将该会话行标为“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode。打开会话后,历史与 control 投影提供其目录。已有目录仍可使用,包括空目录。会话列表的谱系用于解析导航地址,不用于补齐缺失的目录成员。普通加载、取消和存储错误不转换成这一迁移分支。
+
+create/adopt、全文搜索和普通历史打开允许自动迁移。
+
+本决策为[目录投影查询](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md)增加迁移例外。[会话观察](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)的准确读取和租约,以及[投影缓存读取](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md)的身份校验与缓存/有序分层继续适用。
+
+## 考虑过的替代方案
+
+**把所有冷会话都视作需要迁移。** 是否驻留与存储格式不同;这样会拒绝可以正常完成的 fork,并丢失能够从磁盘读取的目录信息。
+
+**Host 全局禁止自动迁移的 fork。** 侧栏的交互选择不适用于其他调用者,因此限制由该请求显式携带。
+
+**侧栏 fork 自动打开并重试。** 打开会话由用户明确选择,fork 不在迁移后自动执行。
+
+**按会话列表中的父 id 派生目录。** 这需要第二套成员派生逻辑;显式打开会话即可获取目录。
+
+**让缓存提示携带 `asOfSeq: -1`。** 提示没有当前历史的事件切点,不能参与序号比较。
+
+## 影响
+
+需要迁移的会话通过现有打开流程完成准备;无需迁移的会话仍可冷读和 fork。目录的缓存提示可能落后于日志,后续准确投影替换它们。
+
+后续单会话迁移进度由独立的 follow 重定向提案讨论,本决策不实现该流程。
diff --git a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml
index ee7758db34..c8cf21b92d 100644
--- a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.i18n.yaml
@@ -9,8 +9,8 @@
en: 5e10347fa87f98b5
zh: cf3569631af8d66f
/agent-note-anonymous-translation-remains-separate-from-model-history/decision:
- en: f13c134f53ed63c9
- zh: 868aacd453d26c82
+ en: 3c4745cc96d89ffd
+ zh: 939d5cd494346311
/agent-note-anonymous-translation-remains-separate-from-model-history/alternatives-considered:
en: 083d50f8564b0378
zh: 50af332e64ad06f3
diff --git a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md
index 0fa78afa4e..1e67961bf1 100644
--- a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md
+++ b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.md
@@ -14,7 +14,7 @@ The experimental translator owns explicit Google or Bing routing, provider-langu
Bing is the configured default. Enabling the bundle permits transmission of expanded reasoning to the chosen service, disclosed in plugin metadata and settings. Failure never selects another provider. Original remains available; translated text neither replaces persisted assistant output nor enters model input. The [optional-bundle admission policy](2026-09-21-experimental-capabilities-as-optional-bundles.md) keeps the feature disabled until selected.
-The reasoning-body chain carries original text and streaming status. Native Chat owns its original Markdown fallback; consumers do not replace the assistant renderer or alter Session projection. Only expanded bodies start translation. Collapsing, changing selection, or unloading aborts outstanding work and excludes late results.
+The GUI registers a translation wrapper in the standard `conversation.chat.reasoning.body` Slot and calls the official `conversation.chat.reasoning.content` Factory for original or translated text. The default body is also an ordinary registration; the host supplies no inline Markdown fallback or plugin action state. The translation wrapper owns its toolbar and passes its localized labels to the Factory, which supplies default labels when callers omit them and fixes compact typography. The [reasoning Body Slot and Content Factory design](2026-10-09-reasoning-content-slot-factory.md) supersedes this record's body composition mechanism; external requests remain separate from model history. Consumers do not replace the assistant renderer or alter Session projection. Only expanded bodies start translation. Collapsing, changing selection, or unloading aborts outstanding work and excludes late results.
## Alternatives considered
diff --git a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md
index 293f74a5ee..4c2bb2854c 100644
--- a/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-05-anonymous-reasoning-translation.zh.md
@@ -14,7 +14,7 @@ Status: implemented
配置的默认服务为 Bing。启用 Bundle 允许将展开的思考内容发送给所选服务,插件元数据与设置公开说明接收方。失败时不选择其他 Provider。原文始终可访问;译文不替换持久化 assistant 输出,也不进入模型输入。[可选 Bundle 准入规则](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md) 保证功能在显式选择前保持关闭。
-reasoning-body chain 传递原始文本与流式状态。原生 Chat 负责原始 Markdown fallback;消费者不替换 assistant 渲染器,也不改变 Session projection。只有展开内容才开始翻译。折叠、切换设置或卸载都会中止未完成工作并忽略迟到结果。
+GUI 向标准 `conversation.chat.reasoning.body` Slot 注册翻译包装,并调用官方 `conversation.chat.reasoning.content` Factory 展示原文或译文。默认正文同样是普通注册项;宿主不提供内联 Markdown fallback 或插件操作状态。翻译包装自行维护工具栏,并将自己的本地化 labels 传入 Factory;Factory 在调用者省略 labels 时提供默认文案,并固定紧凑排版。[思考正文的 Slot 与 Factory 设计](2026-10-09-reasoning-content-slot-factory.zh.md)取代本记录的正文组合方式,外部请求与模型历史隔离的决策保持不变。消费者不替换 assistant 渲染器,也不改变 Session projection。只有展开内容才开始翻译。折叠、切换设置或卸载都会中止未完成工作并忽略迟到结果。
## 考虑过的替代方案
diff --git a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml
index 4af09bcaae..3060e831f9 100644
--- a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.i18n.yaml
@@ -9,11 +9,11 @@
en: a590bcef6c9760c0
zh: 19e6f61c2c7d8d3b
/agent-note-discover-official-bundles-without-installing-their-runtimes/decision:
- en: 31e3c52841f31a11
- zh: ac2ad6e54ef9e150
+ en: f0fabc0d4fbd1178
+ zh: cf19938a7a7dea93
/agent-note-discover-official-bundles-without-installing-their-runtimes/alternatives-considered:
en: 42cb0f808a145871
zh: 225012615d1ecdf5
/agent-note-discover-official-bundles-without-installing-their-runtimes/consequences:
- en: 899b07d90024e019
- zh: 01d22ac4b2444feb
+ en: 293286dcd7e3325e
+ zh: c6c15212e85d050b
diff --git a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md
index 0b0ad0cf55..a67acd25f9 100644
--- a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md
+++ b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.md
@@ -12,13 +12,13 @@ Native subagent integrations bring their own runtimes. Shipping those dependenci
`ON_DEMAND_BUNDLES` admits the Claude Code and Codex bundles to an installation-owned catalog. The catalog embeds their package-owned localized metadata and icons, so discovery works offline without resolving or importing either package. Official identifies project maintenance; optional delivery and experimental maturity remain independent properties.
-Selection uses the ordinary third-party bundle installer, profile dependency, scoped patches, compatibility evaluation, progress, cancellation, registry fallback, and build-script approval. Native authentication and configuration remain provider-owned.
+Selection uses the ordinary third-party bundle installer, profile dependency, Host row patches, compatibility evaluation, progress, cancellation, registry fallback, and build-script approval. Native authentication and configuration remain provider-owned.
The running installation owns the target. Published applications offer the exact DSH release version and save an exact registry dependency pin. Source Web and development Desktop offer ordinary `link:` dependencies into the same checkout as the running CLI, using its prepared workspace dependencies and peers. The source target follows the actual installation, never the current working directory or a prerelease suffix; an incomplete checkout does not fall back to the registry. A development checkout can share a version string with different published code, so replacing an installed registry copy or another checkout’s link compares the source as well as the version. Replacement is explicit, preserves selection, and reports required restarts; startup does not download providers. [Plugin Manager](../../../../packages/boot/plugin-manager/README.md#use-this-package) owns the operational details.
Every Official package remains in the DSH release family. Packed DSH dependencies, optional dependencies, and peers use the exact family version. Publication prerequisites place catalog packages before the DSH package advertising them without adding their runtimes to DSH production dependencies. Catalog verification rejects missing metadata, unsupported membership, and stale generated content.
-The [shipped optional-bundle policy](../process/2026-09-15-shipped-optional-bundles.md) still owns lightweight installation-provided bundles. The [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns shared patch and preset-generation behavior. Native bundles require the full Web preset targets; custom profiles mount their providers and tools explicitly. The [identity compatibility decision](2026-10-06-web-desktop-plugin-identity-compatibility.md) owns retained names, selectors, and deferred naming cleanup.
+The [shipped optional-bundle policy](../process/2026-09-15-shipped-optional-bundles.md) still owns lightweight installation-provided bundles. The [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md) owns shared patch behavior and global optional tools. Native bundles insert ordinary Host rows, so any profile can select them; profiles that keep the package without its bundle mount the same rows explicitly. The [identity compatibility decision](2026-10-06-web-desktop-plugin-identity-compatibility.md) owns retained names, selectors, and deferred naming cleanup.
## Alternatives considered
@@ -34,4 +34,4 @@ The [shipped optional-bundle policy](../process/2026-09-15-shipped-optional-bund
## Consequences
-Registry delivery requires a reachable registry; development delivery requires a prepared, built checkout and normal rebuild/restart discipline. Equal source version strings do not certify identical build outputs. Off only deselects; Remove remains separate and respects retained preset modules. Activation failure can retain the saved dependency and selection; there is no rollback. Offline metadata, release pins, source-target selection, same-version source mismatches, clean installed artifacts, and release ordering have dedicated checks. Native-provider authentication is not part of catalog discovery.
+Registry delivery requires a reachable registry; development delivery requires a prepared, built checkout and normal rebuild/restart discipline. Equal source version strings do not certify identical build outputs. Off only deselects; Remove remains separate and refuses while the bundle's rows are in use. Activation failure can retain the saved dependency and selection; there is no rollback. Offline metadata, release pins, source-target selection, same-version source mismatches, clean installed artifacts, and release ordering have dedicated checks. Native-provider authentication is not part of catalog discovery.
diff --git a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md
index 6fbdaa6e8f..0949f6b72c 100644
--- a/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-05-official-on-demand-bundles.zh.md
@@ -12,13 +12,13 @@ Status: implemented
`ON_DEMAND_BUNDLES` 将 Claude Code 和 Codex bundle 纳入安装方维护的目录。目录内嵌包自身拥有的本地化元信息和图标,因此发现过程离线可用,无需解析或导入这两个包。Official 表示项目维护;可选交付与实验成熟度仍是独立属性。
-选择操作复用普通第三方 bundle 的安装器、profile 依赖、作用域 patch、兼容性判断、进度、取消、注册表回退和构建脚本审批。原生认证与配置仍由提供方拥有。
+选择操作复用普通第三方 bundle 的安装器、profile 依赖、Host 行 patch、兼容性判断、进度、取消、注册表回退和构建脚本审批。原生认证与配置仍由提供方拥有。
当前运行的安装决定目标。已发布应用提供精确的 DSH 发布版本,并保存精确的注册表依赖版本。源码 Web 和开发 Desktop 提供指向当前 CLI 所在 checkout 的普通 `link:` 依赖,使用其中已准备好的工作区依赖和 peer。源码目标跟随实际安装位置,不由当前工作目录或预发布版本后缀决定;不完整的 checkout 不回退到注册表。开发 checkout 可以与内容不同的已发布代码共用版本字符串,因此替换已安装的注册表副本或其他 checkout 的链接时,同时比较来源和版本。替换需显式触发,保留选择状态,并报告是否需要重启;启动时不下载提供方。[插件管理器](../../../../packages/boot/plugin-manager/README.zh.md#use-this-package)拥有操作细节。
所有 Official 包仍属于 DSH 发布家族。打包后的 DSH 依赖、可选依赖与 peer 均使用家族精确版本。发布前置关系要求先发布目录包,再发布宣传它们的 DSH 包,同时不把这些运行时加入 DSH 生产依赖。目录校验拒绝缺失的元信息、不受支持的成员和过期生成内容。
-[随附可选 bundle 策略](../process/2026-09-15-shipped-optional-bundles.zh.md)仍管理安装随附的轻量 bundle。[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)管理共享 patch 与预设代际行为。原生 bundle 要求完整 Web 预设目标存在;自定义 profile 显式挂载提供方和工具。[身份兼容决策](2026-10-06-web-desktop-plugin-identity-compatibility.zh.md)管理保留名称、选择器和暂缓的命名清理。
+[随附可选 bundle 策略](../process/2026-09-15-shipped-optional-bundles.zh.md)仍管理安装随附的轻量 bundle。[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)管理共享 patch 行为与全局可选工具。原生 bundle 插入普通 Host 行,因此任何 profile 都可以选择它们;保留包但不选择其 bundle 的 profile 显式挂载相同的行。[身份兼容决策](2026-10-06-web-desktop-plugin-identity-compatibility.zh.md)管理保留名称、选择器和暂缓的命名清理。
## 考虑过的替代方案
@@ -34,4 +34,4 @@ Status: implemented
## 后果
-注册表交付要求注册表可达;开发交付要求准备并构建 checkout,遵循正常的重新构建和重启流程。源码版本字符串相同不证明构建输出相同。关闭仅取消选择;移除保持独立,并尊重预设仍保留的模块。激活失败可以保留已保存的依赖和选择,不加入回滚。离线元信息、发布版本锁定、源码目标选择、同版本来源差异、干净安装产物和发布顺序有专门检查。原生提供方认证不属于目录发现过程。
+注册表交付要求注册表可达;开发交付要求准备并构建 checkout,遵循正常的重新构建和重启流程。源码版本字符串相同不证明构建输出相同。关闭仅取消选择;移除保持独立,并在 bundle 的行仍在使用时拒绝执行。激活失败可以保留已保存的依赖和选择,不加入回滚。离线元信息、发布版本锁定、源码目标选择、同版本来源差异、干净安装产物和发布顺序有专门检查。原生提供方认证不属于目录发现过程。
diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml
index bab272d8e1..b22fc768ec 100644
--- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.i18n.yaml
@@ -9,14 +9,14 @@
en: 8112c1c059d1cf10
zh: 852d23c56a93a555
/agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/decision:
- en: ac725c281d7dfb41
- zh: 5adcaa6ca6eab384
+ en: 90d74300f045bf6e
+ zh: 088e88944478ca2a
/agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/deferred-naming-cleanup:
en: 4c428f65ef611d07
zh: 1fdc5467b0203d08
/agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/alternatives-considered:
- en: 1bf03723fea6c9a4
- zh: 2d0d2fc997f7c022
+ en: 65af55b5775c9bc5
+ zh: 2ea75b3fd33ac0c2
/agent-note-preserve-web-and-desktop-plugin-identities-across-experimental-consolidation/consequences:
- en: 60f7c823b5d2ed6f
- zh: 75f375487cab3c56
+ en: e8e96b0354f1a3c6
+ zh: ad1bfe3600e14ff1
diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md
index def042e26a..c39f289a39 100644
--- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md
+++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.md
@@ -12,9 +12,9 @@ Moving a capability into the experimental directory changes its ownership and su
The moved packages retain the published names declared by the [experimental naming policy](../../../../scripts/experimental-package-policy.ts). That typed directory-to-name map is the sole maintained inventory of naming exceptions. Package manifests, consumers, installation checks, and bundler checks agree with it; a missing, moved, or duplicated exception is rejected. [Experimental status](../../../../packages/experimental/README.md#status) remains independent of spelling, Official discovery, and explicit selection.
-Existing badge, Ralph, and native delegation row IDs remain valid selectors inside their applicable Host or preset scope. New isolation groups and newly introduced capabilities retain their new IDs. Later user layers still replace configuration or disable rows, and a complete preset replacement remains authoritative. Selecting an optional bundle is still explicit: a patch targeting an absent row does not select its bundle.
+Existing badge, Ralph, and native delegation row IDs remain valid Host row selectors. New isolation groups and newly introduced capabilities retain their new IDs. Later user layers still replace configuration or disable rows. Selecting an optional bundle is still explicit: a patch targeting an absent row does not select its bundle.
-Compatibility protection covers Web and Desktop profiles, including existing native provider package identities, selections, configuration, and authentication. Headless, SDK, and ACP compositions may require changes. Native bundles use the ordinary shared patch operation and require the full preset targets; separate provider-only compatibility wrappers are unnecessary for the protected profiles. Public APIs remain pre-stable, and new output metadata stays required unless an actual consumer needs an accommodation.
+Compatibility protection covers Web and Desktop profiles, including existing native provider package identities, selections, configuration, and authentication. Native bundles insert ordinary Host rows, so Headless, SDK, and ACP compositions can select them too; separate provider-only compatibility wrappers are unnecessary. Public APIs remain pre-stable, and new output metadata stays required unless an actual consumer needs an accommodation.
## Deferred naming cleanup
@@ -31,10 +31,10 @@ These naming changes are deferred, not prerequisites for experimental classifica
**Publish aliases or keep duplicate compatibility rows.** Parallel identities need continuing resolution and removal rules and can register the same capability twice. Retaining the established identity avoids that additional mechanism.
-**Keep provider-only bundles through additional wrapper packages.** Web and Desktop already provide the required presets. Extra delivery packages would preserve compositions outside the selected compatibility scope and increase catalog and release maintenance.
+**Keep provider-only bundles through additional wrapper packages.** Native bundles insert ordinary Host rows that compose into any profile. Extra delivery packages would preserve compositions outside the selected compatibility scope and increase catalog and release maintenance.
## Consequences
-The repository carries a bounded naming-exception map and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides, disabled state, and preset replacement. No general alias or automatic profile migration mechanism is introduced.
+The repository carries a bounded naming-exception map and tests that classify retained names through direct imports, aliases, transitive dependencies, and packed or bundled artifacts. An unprefixed name does not grant admission to the default product. Existing row identities have Web/Desktop composition coverage for user overrides and disabled state. No general alias or automatic profile migration mechanism is introduced.
The [classification decision](../process/2026-10-05-product-package-classification.md), [optional-composition decision](2026-09-21-experimental-capabilities-as-optional-bundles.md), and [on-demand discovery decision](2026-10-05-official-on-demand-bundles.md) retain their separate responsibilities. Released Session generations and persisted hook event names remain protected independently of this profile compatibility scope.
diff --git a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md
index 1f513060ea..4b58743cb5 100644
--- a/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md
+++ b/.agents/notes/implemented/architecture/2026-10-06-web-desktop-plugin-identity-compatibility.zh.md
@@ -12,9 +12,9 @@ Status: implemented
迁移的包保留[实验包命名策略](../../../../scripts/experimental-package-policy.ts)声明的已发布名称。这个带类型的目录到包名映射是命名例外唯一手工维护的清单。包清单、消费者、安装检查和打包检查与其保持一致;缺失、迁移到其他位置或重复归属的例外会被拒绝。[实验状态](../../../../packages/experimental/README.zh.md#status)仍独立于名称拼写、Official 发现和显式选择。
-既有徽章、Ralph 和原生委派行 ID 在对应的 Host 或预设作用域中保持有效。新增隔离组和新引入的能力保留各自的新 ID。后续用户层仍可替换配置或禁用行,完整预设替换仍具有决定权。可选 bundle 仍须显式选择:指向缺失行的 patch 不会自动选择其 bundle。
+既有徽章、Ralph 和原生委派行 ID 作为 Host 行选择器保持有效。新增隔离组和新引入的能力保留各自的新 ID。后续用户层仍可替换配置或禁用行。可选 bundle 仍须显式选择:指向缺失行的 patch 不会自动选择其 bundle。
-兼容性保护覆盖 Web 和 Desktop profile,包括既有原生提供方的包身份、选择状态、配置和认证。Headless、SDK 和 ACP 组合可以要求修改。原生 bundle 使用普通的共享 patch 操作并要求完整预设目标存在;受保护的 profile 不需要单独的仅提供方兼容包装包。公共 API 仍处于稳定前阶段,新增输出元信息保持必填,除非实际消费者需要适配。
+兼容性保护覆盖 Web 和 Desktop profile,包括既有原生提供方的包身份、选择状态、配置和认证。原生 bundle 插入普通 Host 行,因此 Headless、SDK 和 ACP 组合也可以选择它们;不需要单独的仅提供方兼容包装包。公共 API 仍处于稳定前阶段,新增输出元信息保持必填,除非实际消费者需要适配。
## 暂缓的命名清理
@@ -31,10 +31,10 @@ Status: implemented
**发布别名或保留重复兼容行。** 并行身份需要持续维护解析和移除规则,还可能重复注册同一能力。保留既有身份可以避免增加这种机制。
-**通过额外包装包保留仅提供方 bundle。** Web 和 Desktop 已提供所需预设。额外交付包会保留选定兼容范围之外的组合,并增加目录和发布维护成本。
+**通过额外包装包保留仅提供方 bundle。** 原生 bundle 插入普通 Host 行,可组合到任何 profile。额外交付包会保留选定兼容范围之外的组合,并增加目录和发布维护成本。
## 后果
-仓库维护范围明确的命名例外映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置、禁用状态和预设替换的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。
+仓库维护范围明确的命名例外映射,并测试通过直接导入、别名、传递依赖及打包产物识别这些保留名称。没有实验前缀的名称不因此获准进入默认产品。既有行身份具有覆盖用户配置和禁用状态的 Web/Desktop 组合测试。不引入通用别名或自动 profile 迁移机制。
[分类决策](../process/2026-10-05-product-package-classification.zh.md)、[可选组合决策](2026-09-21-experimental-capabilities-as-optional-bundles.zh.md)和[按需发现决策](2026-10-05-official-on-demand-bundles.zh.md)继续承担各自独立的职责。已发布 Session 代际和持久化 hook 事件名的保护独立于此 profile 兼容范围。
diff --git a/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.i18n.yaml
new file mode 100644
index 0000000000..7bd206e1f8
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.i18n.yaml
@@ -0,0 +1,19 @@
+# Bilingual-pair consistency record for 2026-10-08-desktop-shell-configuration.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-10-08-desktop-shell-configuration.md
+/agent-note-desktop-shell-configuration-independent-of-the-host:
+ en: 860c8afa57227b89
+ zh: bec6a19319050aed
+/agent-note-desktop-shell-configuration-independent-of-the-host/problem:
+ en: 9f652292b62cd015
+ zh: 14a97b5a4884c008
+/agent-note-desktop-shell-configuration-independent-of-the-host/decision:
+ en: e58311d3d5ebb6b6
+ zh: b4c791fcfde7b986
+/agent-note-desktop-shell-configuration-independent-of-the-host/alternatives-considered:
+ en: 349210f3cf5f168b
+ zh: 019acfc3c3d71b4b
+/agent-note-desktop-shell-configuration-independent-of-the-host/consequences:
+ en: a523f43819051d4b
+ zh: 69926bc3761195ea
diff --git a/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md
new file mode 100644
index 0000000000..9531ff4bff
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md
@@ -0,0 +1,29 @@
+# Agent Note: Desktop shell configuration independent of the Host
+
+Status: implemented
+
+English | [中文](2026-10-08-desktop-shell-configuration.zh.md)
+
+## Problem
+
+Desktop updates and native recovery can run before the Host starts or while it is unavailable. Depending on Host configuration prevents these functions from resolving their preferences when needed. A shared settings page does not identify which process owns each preference.
+
+## Decision
+
+Electron main owns `userData/desktop/settings.json` independently of Host startup and Cordis evaluation. The test authentication popup switch uses this file; the [Desktop reference](../../../../apps/desktop/README.md#local-desktop-settings) owns its fields and behavior.
+
+Ownership follows runtime dependencies: Electron-owned functions needing configuration before Host startup or while the Host is unavailable use shell configuration. Host plugin settings, including shared Web/Desktop preferences, continue to use Cordis. Displaying a setting in Desktop alone does not justify shell ownership. A unified settings interface can use separate storage owners.
+
+The `desktop` subdirectory groups shell configuration without relocating existing files. The [shortcut decision](2026-09-20-device-local-shortcut-preferences.md) retains ownership of `userData/keybindings.json` and its persistence semantics. The [shared Web application decision](2026-09-10-desktop-web-wrapper.md) still governs Host composition and profile configuration.
+
+## Alternatives considered
+
+**Put every preference in Cordis.** This requires a working Host. Reading `cordis.patch.yml` directly in Electron duplicates configuration evaluation; a patch alone omits plugin defaults and other configuration layers.
+
+**Move every setting shown in Desktop.** Interface placement does not imply a Host-independent requirement. This splits shared Web/Desktop preferences and moves configuration away from its consuming plugins.
+
+## Consequences
+
+Shell and Host configuration have separate owners. Each setting needs documented ownership and effective timing. Independent shell configuration supports additional Host-independent functions without introducing a second application backend.
+
+Existing settings are candidates for individual evaluation when their functionality changes or a Host-independent use is required. This is an evaluation direction, not a scheduled migration or predefined list. A migration must identify a single authoritative source, transfer existing values and define when changes take effect, without ambiguous precedence between two stores. Shared Host-managed settings remain in Cordis unless their ownership requirements change.
diff --git a/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md
new file mode 100644
index 0000000000..3dac97f51a
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md
@@ -0,0 +1,29 @@
+# Agent Note: Desktop 壳层配置独立于 Host
+
+Status: implemented
+
+[English](2026-10-08-desktop-shell-configuration.md) | 中文
+
+## Problem
+
+Desktop 更新和原生恢复可以在 Host 启动前或不可用时运行。依赖 Host 配置会使这些功能无法在需要时解析偏好。统一设置页面不能决定每项偏好由哪个进程负责。
+
+## Decision
+
+Electron 主进程负责 `userData/desktop/settings.json`,不依赖 Host 启动或 Cordis 求值。测试鉴权弹窗开关使用此文件;[Desktop 参考文档](../../../../apps/desktop/README.zh.md#local-desktop-settings)统一说明字段和行为。
+
+配置归属按运行依赖划分:由 Electron 负责且需要在 Host 启动前或不可用时读取配置的功能,使用壳层配置。Host 插件设置,包括 Web/Desktop 共用偏好,继续使用 Cordis。仅仅展示在 Desktop 中,不构成归属壳层的理由。统一设置界面可以使用不同的存储归属。
+
+`desktop` 子目录集中存放壳层配置,不迁移已有文件。[快捷键决策](2026-09-20-device-local-shortcut-preferences.zh.md)继续负责 `userData/keybindings.json` 及其持久化语义。[共享 Web 应用决策](2026-09-10-desktop-web-wrapper.zh.md)仍约束 Host 组合和 profile 配置。
+
+## Alternatives considered
+
+**所有偏好都放入 Cordis。** 这需要可用的 Host。由 Electron 直接读取 `cordis.patch.yml` 会重复实现配置求值;单独的 patch 不包含插件默认值和其他配置层。
+
+**迁移所有在 Desktop 展示的设置。** 界面位置不意味着需要独立于 Host。这会拆分 Web/Desktop 共用偏好,并让配置脱离实际使用它的插件。
+
+## Consequences
+
+壳层与 Host 配置分别有自己的归属。每项设置需说明归属和生效时机。独立壳层配置支持更多不依赖 Host 的功能,但不引入第二套应用后端。
+
+现有设置在功能调整或需要独立于 Host 使用时,可逐项评估。这是评估方向,不是已排期的迁移计划或预设清单。实际迁移需明确唯一配置来源、迁移已有值并定义生效时机,避免两套存储间存在含糊的优先级。共用的 Host 设置在归属要求不变时继续使用 Cordis。
diff --git a/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.i18n.yaml
new file mode 100644
index 0000000000..ba86de4f69
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.i18n.yaml
@@ -0,0 +1,31 @@
+# Bilingual-pair consistency record for 2026-10-09-reasoning-content-slot-factory.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-10-09-reasoning-content-slot-factory.md
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory:
+ en: 55862b96f6731c81
+ zh: 425ab1ff7043a606
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/problem:
+ en: 91000d9dd4a47647
+ zh: 6146424ac5c3987b
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision:
+ en: 0a45affd1b125113
+ zh: 3af28a27b7721bc7
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision/registration-and-rendering:
+ en: 0af348d91eedc16e
+ zh: a06d6f59cc271e94
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision/data-state-and-lifecycle:
+ en: e220ebf28a113e0b
+ zh: dfedd85ac5d76ccb
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/decision/translation-plugin-integration:
+ en: 145a1ae617a49f43
+ zh: b2eb8443bb1515a7
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/alternatives-considered:
+ en: 89adfc6fbad716f3
+ zh: 098cca9e73c5eeaf
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/verification:
+ en: 23e6e98d176f08f4
+ zh: 2b89720b8914552b
+/agent-note-standard-reasoning-body-slot-and-reusable-content-factory/consequences:
+ en: 60a32f46ff947154
+ zh: 6498e791d4c2c166
diff --git a/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.md b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.md
new file mode 100644
index 0000000000..b1c3f5c7d6
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.md
@@ -0,0 +1,102 @@
+# Agent Note: Standard reasoning Body Slot and reusable Content Factory
+
+Status: implemented
+
+English | [中文](2026-10-09-reasoning-content-slot-factory.zh.md)
+
+## Problem
+
+Reasoning bodies need to let third parties replace or wrap their presentation while reusing the official Markdown renderer, localized labels, and compact typography. An optional renderer parameter that makes the host choose between direct rendering and a chain fallback leaves the default body outside the registration system and forces plugins to maintain another Markdown call.
+
+The render position and the base implementation serve different purposes: the position accepts plugin registrations and lets the framework select one, while any component can call the implementation directly. Calling the base implementation must not repeat plugin selection for that position or require a Session.
+
+## Decision
+
+The design uses existing [Component Factories](2026-09-10-component-factories-and-local-slots.md) and ordinary Slots without adding framework dispatch machinery.
+
+| Name | Type | Owner and responsibility |
+| --- | --- | --- |
+| `conversation.chat.reasoning.body` | `single`, `session` Slot | Body position declared by the Assistant node registration; receives only original text and streaming state when expanded. |
+| `conversation.chat.reasoning.content` | `root` Factory | Standard reasoning content provided by `ui-chat`; accepts text, streaming state, and optional labels, supplies default labels, and fixes `variant="compact"`. |
+
+The Factory receives no Session identity, reads no Session, and owns no translation state. The current Factory API requires `scope`; `root` expresses the absence of a Session dependency, while each render position still has an independent React instance. The Chat plugin registers the Factory independently of any Body occupant, so replacing a body does not remove the base implementation.
+
+### Registration and rendering
+
+1. `ui-chat` registers the Content Factory with component props derived through `FactoryComponentPropsOf`. When the caller omits labels, the Factory constructs stable defaults from Chat's locale seat.
+2. The Assistant node registration declares the Body Slot. An expanded `ReasoningRow` only calls `renderSlot`; it accepts no optional renderer, supplies no inline fallback, and does not check whether a plugin is enabled.
+3. The official default Body is an ordinary registration with `priority: 100`. It forwards only `text` and `running` to the Content Factory.
+4. Third parties wait for the Body declaration through `slots.inject` and register their Body with a smaller priority value. They may call the Content Factory directly and add their own controls around it without importing private official React components.
+5. When a third party unregisters, the framework selects the official default registration again. Multiple third-party registrations follow existing single-slot priorities; they do not form middleware or render multiple bodies together.
+
+```text
+ReasoningRow
+ └─ conversation.chat.reasoning.body
+ ├─ 官方默认 Body ───────────┐
+ └─ 第三方 Body → 自有控件 ─┤
+ └─ conversation.chat.reasoning.content
+ └─ MarkdownText
+```
+
+The body render position:
+
+```tsx ignore-check
+
+```
+
+The default Body and third-party wrappers use the same reusable implementation; a third party passes labels when it needs custom copy:
+
+```tsx ignore-check
+renderFactorySlot('conversation.chat.reasoning.content', {
+ text: displayText,
+ running,
+ labels,
+})
+```
+
+### Data, state, and lifecycle
+
+- `ReasoningBodyOwnerProps` contains only readonly `text: string` and `running: boolean`. `ReasoningContentInput` also accepts `labels?: MarkdownLabels`; supplied labels are used unchanged without field-level merging, and omission selects the official defaults. Callers own the localization and reference stability of custom labels.
+- The Body's `text` is always the original reasoning. A third party selects original or derived display text and passes that value to the Factory without rewriting the Session or Chat projection.
+- `ReasoningRow` retains its existing disclosure state, summary, shimmer, and title behavior. Plugin-specific selection state, buttons, and styles stay inside the plugin's Body wrapper; the official implementation supplies no `setHeaderAction`, header-action type, or corresponding state.
+- Registrations follow Cordis effect lifetimes. Collapsing the Body declaration removes its contributions through `slots.inject`; unloading the Chat plugin removes the Content Factory.
+- Factory components and the default Body have stable module-level identities. Text changes update props instead of rebuilding component types or keys. Plugin replacement or HMR may remount components according to existing framework semantics; unmounted components have no state-retention guarantee.
+
+### Translation plugin integration
+
+The [anonymous translation design](2026-10-05-anonymous-reasoning-translation.md) retains its external-request, privacy, failure, and cancellation decisions; this design supersedes its body composition mechanism. The translation plugin registers in the standard Body Slot, renders original/translation selection, status, and retry controls in its own toolbar, and wraps the official Content Factory with its own state and optional custom labels rather than calling `MarkdownText` directly.
+
+Translation requests, splitting, caching, persistence, and settings remain unchanged. The translation toggle belongs to the plugin body rather than the official Think title. The design adds no Slot outside the body, does not handle user-message masking, and does not change the general Markdown parser.
+
+## Alternatives considered
+
+**Translation-specific chain and host fallback.** The default content is not an ordinary registration, the host maintains a second render path, and third parties cannot directly reuse the complete official body implementation.
+
+**Expose only the Body Slot.** This permits replacement, but wrapping plugins still have to copy the official Markdown call, labels, and variant.
+
+**Provide only the Content Factory.** This permits implementation reuse but supplies no actual body position for third-party replacement registrations.
+
+**Export React components across plugins.** This bypasses existing Factory loading, lifecycle, and type derivation and adds feature-plugin runtime dependencies.
+
+**Name the Factory as general Markdown.** This implementation fixes reasoning-specific compact typography and supplies Chat defaults; it is not a general entry point for all Markdown uses.
+
+**Make the official implementation maintain plugin header actions.** The default body has no such requirement; a dedicated setter makes the host own plugin state, cleanup, and button styles.
+
+**Force official labels in the Factory.** Default copy must not prevent caller customization; an optional parameter provides an override without replacing the whole body implementation.
+
+## Verification
+
+- The body host only calls `conversation.chat.reasoning.body`, with no old `reasoning-body` chain, optional renderer, or inline Markdown fallback.
+- Both the default Body and translation Body render through `conversation.chat.reasoning.content`; omitted labels use official defaults, and custom labels reach Markdown unchanged.
+- The Factory works without a Session Provider, and two render positions do not share component-local state.
+- The official implementation has no header-action type, setter, state, or dedicated styles; the translation wrapper supplies its own selection and retry controls.
+- Enabling, disabling, or hot-reloading the translation plugin selects the corresponding Body; default output, original-text access, streaming, disclosure, and cancellation behavior remain consistent.
+- Unit and assembly tests cover registration disposal, a Session-independent Factory, default and custom labels, shared implementation reuse, and independent occurrences; existing browser replay covers default bodies and translation switching.
+
+## Consequences
+
+The old Slot name, chain-selector registrations, and header-action callback are unsupported, so third parties must migrate. Test fixtures and the Client inspect catalog must follow the new declarations rather than offer obsolete registration examples.
+
+The Body remains an exclusive replacement position. This design does not automatically compose multiple text-transformation plugins or turn display transformations into redaction or model-input modification.
diff --git a/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.zh.md b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.zh.md
new file mode 100644
index 0000000000..73986249ef
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-10-09-reasoning-content-slot-factory.zh.md
@@ -0,0 +1,102 @@
+# Agent Note: 思考正文的标准 Slot 与可复用 Content Factory
+
+Status: implemented
+
+[English](2026-10-09-reasoning-content-slot-factory.md) | 中文
+
+## 问题
+
+思考正文需要允许第三方替换或包装展示,同时复用官方的 Markdown、本地化文案和紧凑排版。将扩展做成可选的 renderer 参数,再由宿主选择直接渲染或 chain fallback,会让默认正文存在于注册体系之外,插件也只能另行维护 Markdown 调用。
+
+渲染位置与基础实现是两种不同的能力:位置允许多个插件注册并由框架选中一个,实现则应允许任何组件直接调用。调用基础实现不应再次触发该位置的插件选择,也不应要求 Session。
+
+## 决策
+
+沿用现有 [Component Factory](2026-09-10-component-factories-and-local-slots.zh.md) 和普通 Slot,不增加框架分发机制。
+
+| 名称 | 类型 | 所有者与职责 |
+| --- | --- | --- |
+| `conversation.chat.reasoning.body` | `single`、`session` Slot | Assistant 节点注册声明的正文位置;展开时只接收原始文本与流式状态。 |
+| `conversation.chat.reasoning.content` | `root` Factory | `ui-chat` 提供的标准思考正文;接收文本、流式状态及可选 labels,提供默认文案并固定 `variant="compact"`。 |
+
+Factory 不接收 Session 身份,不读取 Session,不持有翻译状态。现有 Factory API 要求填写 `scope`,使用 `root` 表达无 Session 依赖;每个调用位置仍有独立的 React 实例。Factory 由 Chat 插件注册,而非依附于某个 Body occupant,第三方替换正文不会撤掉基础实现。
+
+### 注册与调用
+
+1. `ui-chat` 注册 Content Factory,组件 props 由 `FactoryComponentPropsOf` 推导。调用者未传 labels 时,Factory 使用 Chat 的 locale seat 构造稳定的默认文案。
+2. Assistant 节点注册声明 Body Slot。`ReasoningRow` 展开时只调用 `renderSlot`,不接收可选 renderer,不提供内联 fallback,也不判断插件是否启用。
+3. 官方以 `priority: 100` 注册普通默认 Body。该组件只将 `text`、`running` 传给 Content Factory。
+4. 第三方通过 `slots.inject` 等待 Body 声明,以较小的 priority 注册自己的 Body。第三方可直接调用 Content Factory,并在外层增加自己的控件;无需导入官方私有 React 组件。
+5. 第三方注销后,框架重新选中官方默认注册项。多个第三方注册遵循现有 single 优先级,不形成 middleware,也不同时渲染多份正文。
+
+```text
+ReasoningRow
+ └─ conversation.chat.reasoning.body
+ ├─ 官方默认 Body ───────────┐
+ └─ 第三方 Body → 自有控件 ─┤
+ └─ conversation.chat.reasoning.content
+ └─ MarkdownText
+```
+
+正文调用位置:
+
+```tsx ignore-check
+
+```
+
+默认 Body 与第三方包装都使用同一个可复用实现;第三方需要自定义文案时传入 labels:
+
+```tsx ignore-check
+renderFactorySlot('conversation.chat.reasoning.content', {
+ text: displayText,
+ running,
+ labels,
+})
+```
+
+### 数据、状态与生命周期
+
+- `ReasoningBodyOwnerProps` 仅包含只读的 `text: string` 与 `running: boolean`。`ReasoningContentInput` 另外允许 `labels?: MarkdownLabels`;传入的文案原样使用,不做字段级合并,省略时才使用官方默认文案。调用者负责自定义文案的本地化与引用稳定性。
+- Body 的 `text` 始终是原始思考。第三方选择原文或派生显示文本后,只将该值交给 Factory,不改写 Session 或 Chat projection。
+- 折叠状态、摘要、流光和标题由 `ReasoningRow` 保持现有行为。插件自己的切换状态、按钮及样式留在其 Body 包装中;官方不提供 `setHeaderAction`、标题动作类型或相应状态。
+- 注册沿用 Cordis effect 生命周期。Body 声明撤销时,其贡献随 `slots.inject` 清理;Content Factory 在 Chat 插件卸载时撤销。
+- Factory 组件与默认 Body 使用模块级稳定身份,文本变化作为 props 更新,不以文本重建组件类型或 key。插件替换或 HMR 可按现有框架语义重新挂载,不承诺保留被卸载组件的本地状态。
+
+### 翻译插件接入
+
+[免登录翻译设计](2026-10-05-anonymous-reasoning-translation.zh.md)保留外部请求、隐私、失败与取消决策;其正文组合方式由本方案取代。翻译插件注册到标准 Body Slot,在自己的工具栏内渲染原文/译文切换、状态及重试按钮,使用自己的状态和可选自定义 labels 包装官方 Content Factory,不直接调用 `MarkdownText`。
+
+翻译请求、分片、缓存、持久化和设置保持不变。翻译切换按钮属于插件正文,不再由官方思考标题承载;不增加正文外的 Slot,不处理用户消息遮挡,也不改动通用 Markdown 解析器。
+
+## 考虑过的替代方案
+
+**翻译专用 chain 与宿主 fallback。** 默认内容不是普通注册项,宿主需要维护第二条渲染路径,第三方无法直接复用完整的官方正文实现。
+
+**只开放 Body Slot。** 可以替换正文,但第三方包装时仍需复制官方 Markdown 调用、labels 与 variant。
+
+**只提供 Content Factory。** 可以复用实现,但缺少允许第三方注册替换的实际正文位置。
+
+**跨插件导出 React 组件。** 绕过现有 Factory 的加载、生命周期与类型推导,增加 feature-plugin 运行时依赖。
+
+**将 Factory 命名为通用 Markdown。** 该实现固定思考正文的紧凑排版,并提供 Chat 默认文案,不是所有 Markdown 场景的通用入口。
+
+**官方替插件维护标题动作。** 官方默认正文没有该需求;保留专用 setter 会让宿主承担插件的状态、清理与按钮样式。
+
+**Factory 强制使用官方 labels。** 默认文案不应阻止调用者定制;可选参数即可表达覆盖,无需替换整套正文实现。
+
+## 验证
+
+- 正文宿主只调用 `conversation.chat.reasoning.body`,没有旧 `reasoning-body` chain、可选 renderer 或内联 Markdown fallback。
+- 官方默认 Body 和翻译 Body 都通过 `conversation.chat.reasoning.content` 渲染;未传 labels 使用官方默认文案,传入自定义 labels 则原样交给 Markdown。
+- Factory 在没有 Session Provider 的调用位置也能使用;两个调用位置不共享组件本地状态。
+- 官方不存在标题动作类型、setter、状态或专用样式;翻译包装自行提供切换与重试控件。
+- 翻译插件启用、停用及 HMR 后,框架选中对应 Body;默认输出、原文访问、流式展示、折叠和取消行为保持一致。
+- 单元与装配测试覆盖注册撤销、无 Session Factory、labels 缺省与覆盖、默认与第三方共用实现及双实例;已有浏览器回放覆盖默认正文和翻译切换。
+
+## 后果
+
+旧 Slot 名称、chain selector 注册及标题动作回调不再受支持,第三方需要迁移。测试夹具与 Client inspect catalog 必须同步到新声明,避免仍向插件开发者提供旧注册方式。
+
+Body 仍是独占替换位置。此方案不解决多个正文转换插件的自动组合,也不将显示变换提升为脱敏或模型输入修改能力。
diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
index a38c0852ba..aab0051c22 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
@@ -18,11 +18,11 @@
en: 83e446c928a20c47
zh: b0a2bd24419dd815
/agent-note-durable-agent-teams-over-continuable-children/provisioning-and-recovery:
- en: 085846d0078c0898
- zh: 40b2ca3ae6864451
+ en: 4345ac9a6ac7ad04
+ zh: 1d19ecbb128a75a3
/agent-note-durable-agent-teams-over-continuable-children/mailbox-and-task-transactions:
- en: ab2ad55021eb34e0
- zh: 2d1996df7a69201d
+ en: bfc3063536837c0e
+ zh: 6b8e69ba85cc2bb1
/agent-note-durable-agent-teams-over-continuable-children/shared-checkout-boundary:
en: 34920da68b5763e0
zh: 7604392594e4b8a8
@@ -30,11 +30,11 @@
en: e6348054f024725b
zh: 8740636e99940308
/agent-note-durable-agent-teams-over-continuable-children/alternatives-considered:
- en: bb266fe7a97f0828
- zh: 7c86c2d6d11ba02a
+ en: 8cb2fd8839f5f8b7
+ zh: e302ca03e569de65
/agent-note-durable-agent-teams-over-continuable-children/testing:
- en: 35ce199351d8b63d
- zh: 332f0520e27e3309
+ en: 092ac3c2b6f552aa
+ zh: c688c1bec5ad8a07
/agent-note-durable-agent-teams-over-continuable-children/consequences:
- en: c3740ae56809e537
- zh: 76a1e77d14cb6f26
+ en: da9cd22b1185a793
+ zh: e20d7ef9226f237f
diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
index 74556d041f..2643101a91 100644
--- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
+++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md
@@ -30,7 +30,7 @@ The `spawn_teammate` tool prefixes the initial task with a user-role `=0.2.0 <0.3.0" }
+ },
+ {
+ "version": "0.2.0-rc.1",
+ "publishTimestamp": 1789872000,
+ "source": { "type": "npm" },
+ "engines": { "dsh": "0.2.0 - 0.2.4 || >=0.3.0 <0.4.0" }
+ }
+ ]
+ },
+ {
+ "name": "dsh-plugin-foo",
+ "title": "Foo",
+ "icon": "https://example.com/foo.png",
+ "dist-tags": { "latest": "1.0.0" },
+ "versions": [
+ {
+ "version": "1.0.0",
+ "publishTimestamp": 1788000000,
+ "source": {
+ "type": "git",
+ "url": "https://github.com/author/dsh-plugins",
+ "commit": "3f9c2e1d8a4b6c0e7f1a2b3c4d5e6f708192a3b4",
+ "path": "packages/foo"
+ },
+ "engines": { "dsh": "*" }
+ }
+ ]
+ }
+ ]
+}
+```
+
+`LocalizedText` below is the `LocalizedText` type of [`dsh-package-manifest`](../../../../packages/util/package-manifest/src/types.ts), a string or an object of strings that contains `en`, with two additional rules: object keys are lowercase language ids matching `^[a-z]{2,8}(-[a-z0-9]{1,8})*$`, and every string contains at least one non-whitespace character.
+
+### Root fields
+
+| Field | Required | Rule |
+|---|---|---|
+| `format` | yes | Integer `1`. |
+| `title` | yes | `LocalizedText` naming the market. |
+| `description` | no | `LocalizedText`. |
+| `metadata` | no | Object holding the three declaration maps below. |
+| `metadata.categories` | no | Object mapping category id → `{ title: LocalizedText }`. |
+| `metadata.labels` | no | Object mapping label id → `{ title: LocalizedText }`. |
+| `metadata.sortKeys` | no | Object mapping sort-key id → `{ title: LocalizedText, order: "asc" \| "desc" }`; `order` is the initial sort direction. |
+| `plugins` | yes | Array of plugins, possibly empty; its order is the index's default ordering. |
+
+Category, label, and sort-key ids are non-empty strings. Readers look ids up as own properties, so ids such as `constructor` have no special meaning.
+
+### Plugin fields
+
+| Field | Required | Rule |
+|---|---|---|
+| `name` | yes | Package name accepted by npm's maintained package-name validator for new packages; unique within the index. |
+| `title` | no | `LocalizedText`; a reader displays `name` when absent. |
+| `description` | no | `LocalizedText`. |
+| `icon` | no | Either `data:;base64,`, where `` is `image/svg+xml`, `image/png`, `image/jpeg`, or `image/webp`, `` is canonically padded base64, and the decoded payload is at most 256 KiB; or a URL reference whose resolution against the index URL is an `https:` URL or has the index URL's origin. |
+| `author` | no | String with at least one non-whitespace character. |
+| `homepage` | no | Absolute `https:` or `http:` URL of a page about the plugin. |
+| `metadata` | no | Object holding the values below. |
+| `metadata.category` | no | One declared category id. |
+| `metadata.labels` | no | Array of distinct declared label ids. |
+| `metadata.keywords` | no | Array of free-form strings; no root declaration required. |
+| `metadata.sortKeys` | no | Object mapping a declared sort-key id → finite number. |
+| `dist-tags` | no | Object mapping npm distribution-tag names to exact listed version strings; absent means no recorded channels. |
+| `versions` | yes | Non-empty array of versions; see below. Array order does not select the installation version. |
+
+`metadata.keywords` conventionally comes from npm `package.json` `keywords`, but generators may use another source, add strings, or adjust values. Neither presence nor equality with the package manifest is required. Readers preserve the strings and their order; an empty array is valid.
+
+### Version fields
+
+| Field | Required | Rule |
+|---|---|---|
+| `version` | yes | Exact version parsed by npm's `node-semver`; preserve the complete string, including Git build metadata. The exact string is unique within the plugin. |
+| `publishTimestamp` | yes | Integer Unix timestamp in seconds, in `[0, 10^11)`. |
+| `source` | yes | Where `version` is installed from; see below. |
+| `engines` | yes | Object containing `dsh`. |
+| `engines.dsh` | yes | Non-blank valid npm SemVer range declared in the source manifest; copied unchanged. `*` explicitly declares support for any DSH version. |
+
+A relative `icon` reference resolves against the index URL, as an HTML image reference resolves against its page. For an index at `https://market.example/dsh/index.json`, `"icon": "icons/foo.svg"` names `https://market.example/dsh/icons/foo.svg`. A generator can therefore publish the index and its icon files as one directory on any static host without knowing the final URL. Absolute icon URLs on other origins must use `https:`; the same-origin allowance lets an `http:` index on a loopback or intranet host serve its own icons. Readers resolve the reference when they read the index, so clients receive absolute URLs.
+
+`source` takes one of two forms, and readers reject any other `type`:
+
+- `{ "type": "npm", "registry"?: string }`. `registry` is an `http:` or `https:` URL without credentials, query, or fragment; `http:` is admitted for intranet mirrors, as in DSH's [install registries](../../implemented/architecture/2026-09-18-plugin-install-registries.md).
+- `{ "type": "git", "url": string, "commit": string, "path"?: string }`. `url` is a canonical `https:` URL (equal to its WHATWG URL serialization) with at least two non-empty path segments and no trailing slash, without credentials, query, or fragment, and not ending in `.tgz` or `.tar.gz`. `commit` is a lowercase 40-hex-digit SHA. `path` is a `/`-separated relative directory whose segments contain only ASCII letters, digits, and `._@+-`, and are neither `.` nor `..`; absent means the repository root.
+
+### Install arguments
+
+A client installs one version with exactly these pnpm arguments:
+
+- npm: the spec `@`. Without `registry`, the client asks its configured registries as it does for any install. With `registry`, the client follows DSH's [registry plan](../../implemented/architecture/2026-09-18-plugin-install-registries.md): it asks `registry` first and falls back to its other configured registries only when `registry` is one of them; any other `registry` is asked alone, so a private registry never falls through to a public one that may publish a different package under the same name and version.
+- git: the spec `git+.git#`, without doubling a trailing `.git`, followed by `&path:/` when `path` is present. The `git+` prefix makes pnpm treat URLs with more than two path segments, such as GitLab subgroups, as Git repositories.
+
+### DSH compatibility
+
+Every market-listed version must declare valid `engines.dsh`. The reader and market-origin installation evaluate that range against the running DSH version with npm's `node-semver` and `includePrerelease: true`. There is no peer fallback or range synthesis. npm package peer resolution remains outside market host compatibility.
+
+Installation rechecks the source manifest before accepting a market-origin package. An exact local `name@version` exemption may permit a valid range that rejects the runtime; it cannot make a missing or invalid declaration eligible. Exemptions are not published in the index.
+
+### Generator obligations
+
+A reader cannot verify these rules; a generator that violates them publishes a misleading index.
+
+- `name` and each `version` equal the package manifest of the installed source.
+- `publishTimestamp` is the registry publish time of `version` for npm sources and the committer time of `commit` for Git sources.
+- `engines.dsh` copies the manifest's declared range unchanged. A version with a missing, blank, or invalid range is omitted with a diagnostic. A plugin with no eligible version is omitted.
+- `dist-tags` snapshots npm's [distribution tags](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/), retaining only targets present among the eligible listed versions. For Git sources, the publisher or market supplies the same exact tag-to-version mapping.
+- Each listed version declares `dsh.bundle`. Installation independently refuses a package without a bundle patch.
+- `title`, `description`, and `icon` come from the highest listed version's locale metadata, manifest fields, and icon under the installed-plugin display rules. Use npm's `node-semver.rsort()` order, retaining input order for ties. This metadata choice does not select the installation version. `homepage` copies that version's manifest `homepage` and is absent without one.
+- `author` comes from the same highest listed version's npm author declaration. Copy an object's `name`; parse shorthand strings with a maintained npm person normalizer. Omit an absent or whitespace-only name and contact fields. Normalize a fresh projection, leaving the source manifest untouched.
+
+### Reader rules
+
+A reader rejects the whole index when the document violates any rule in Root fields, Plugin fields, Version fields, or the `source` forms, including an undeclared category, label, or sort-key reference, an unlisted distribution-tag target, a duplicate `name` or exact `version` string, or a `format` other than `1`. Each such defect is a generator bug, and dropping entries silently would hide it. Readers may bound the document size.
+
+A reader ignores members that this note does not define, at every level; an unknown `source.type` value is rejected. Format 1 can then gain optional members without breaking existing readers; a change that existing readers would misinterpret uses a new `format` value.
+
+Tag names must parse as registry tag specs under npm's maintained `npm-package-arg`; targets are exact listed version strings. npm sources use canonical published identifiers (`node-semver.valid(version) === version`); Git sources retain build metadata and pin their exact source by commit.
+
+Categories, labels, and sort keys are opaque: a reader filters and sorts by them, displays their `title`, and never interprets an id. Readers display declarations in the order of their localized `title`. When sorting plugins, a plugin without a value for the selected sort key follows all plugins with a value in either direction, and equal values keep the index order. Sorting by publish time uses a plugin's greatest `publishTimestamp`.
+
+A reader evaluates the required `engines.dsh` range under DSH compatibility. Versions use npm's `node-semver.rsort()` order, retaining index order for ties. Exact tag targets, not this display order, select installation versions.
+
+The initial channel is `latest`. Its exact target is the publisher- or market-selected version. Clients install that recorded version or commit, never `name@latest`. Missing or incompatible `latest` requires explicit version or channel selection; clients do not switch automatically. Installation confirmation identifies the version, selected channel if any, prerelease status, index URL, and source.
+
+## Alternatives considered
+
+**A plain list of npm names or repository URLs.** It identifies every plugin but forces one or more requests per row before a list renders, which is the cost this format removes.
+
+**One opaque install spec string per version.** A reader cannot verify that a free-form spec is pinned. A structured `source` with an exact `version`, or a `commit`, lets the reader verify pinning and lets the client build the spec it installs.
+
+**Installing the latest version instead of a pinned one.** The installed build could then differ from the metadata the list displayed, and a package release would change what an unchanged index installs.
+
+**One entry per version.** Every version would repeat the title, description, icon, author, and market metadata, and readers would group entries by `name` to show one plugin.
+
+**One `source` per plugin, with only the pin per version.** A plugin that moves to another repository, directory, or registry between releases could not list its older versions.
+
+**Display fields per version.** A list shows one row per plugin, so older display text, icons, authors, and homepages would cost index size without a reader that shows them.
+
+**Inline icons only.** A list without extra requests needs inline icons, but 256 KiB of raw bytes is about 341 KiB as base64, so a 100-plugin index could reach about 35 MB. URL icons trade extra requests, and disclosure of the user's IP address to the image host, for a small index.
+
+**Inferring host compatibility from peer dependencies.** The market defines a new standard requiring an author-declared `engines.dsh`. Existing packages must add that declaration before listing; no fallback, workspace mapping, or range intersection is needed.
+
+**Copying the complete npm person object into the display field.** The list needs a name. The generator supports npm's original declaration while omitting unused contact fields.
+
+**Opaque sort values for time, including ISO date strings.** Publish time has fixed semantics that clients format and sort, so it is a typed field. Without time, sort values can be numbers only, which needs no per-key value-type rule.
+
+**Root-level `labels` and `sortKeys`, and a plugin field named `sort`.** Grouping market-defined declarations and values under `metadata` at both levels, with matching member names except the single-valued `category`, separates them from fields that clients interpret.
+
+**Declaration order as display order.** JavaScript reorders integer-like object keys, so declaration order would need either an id pattern or array declarations. Clients sort declarations by their localized title instead.
+
+**Selecting the greatest compatible version.** npm's `latest` can name an older release. Inferring a target from SemVer ranking loses the recorded channel choice and is ambiguous for equally ranked builds.
+
+**Automatic prerelease fallback.** Missing or incompatible channel targets do not mean that the user selected a prerelease.
+
+**Rejecting unknown members.** Every optional addition would then require a new `format` value and break every deployed reader.
+
+## Acceptance criteria
+
+- A validator accepts the example above and rejects one violation of each rule in Root fields, Plugin fields, Version fields, and the `source` forms, with a diagnostic naming the JSON path.
+- Install arguments built from every example version are accepted by DSH's install-spec parser and by pnpm.
+- A validator ignores unknown members at every level.
+- Generator and validator refuse missing, blank, and invalid `engines.dsh`; omitted versions leave no dangling tag target. Index and market-origin installation agree on valid ranges, including hyphen ranges, prereleases, and valid ranges that reject the runtime. Exemptions never bypass the declaration requirement.
+- Exact tag targets select the same source after equally ranked Git build versions are reordered. Missing targets are rejected; absent or incompatible `latest` causes no automatic switch.
+- npm author objects and shorthand strings produce the declared name without contact fields or mutation of source identity.
+
+## Risks
+
+- An index is unsigned. Titles, authors, labels, and scores are market claims; an `official` label is not DSH verification. Clients must show the index URL and each version's `source`.
+- A Git version's `name`, `version`, and `engines.dsh` are claims about the repository. A repository can claim any name, including an `@deepseek-ai/dsh-` name, and the installed manifest decides what is actually installed.
+- An npm version's `source.registry` is equally a claim: it directs the install of any `name`, including an `@deepseek-ai/dsh-` name, to a registry the market chooses, and that registry decides what is installed.
+- URL icons disclose the user's IP address to image hosts.
+- Pinned versions and distribution tags stay stale until the index is regenerated.
+- Market text reaches users, and models through any client tool that lists plugins, without review by DSH.
diff --git a/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md
new file mode 100644
index 0000000000..12608c767b
--- /dev/null
+++ b/.agents/notes/proposed/feature/2026-10-02-plugin-market-index-format.zh.md
@@ -0,0 +1,234 @@
+# Agent Note: 插件市场索引格式
+
+Status: proposed
+
+[English](2026-10-02-plugin-market-index-format.md) | 中文
+
+## 问题
+
+DSH 只能从用户已知的 spec 安装第三方组合包(bundle):npm 包名、Git URL、tarball 或本地路径。用户无法浏览可安装的组合包。目前没有官方市场,以后可能会增加官方市场,因此市场机制必须支持第三方发布的索引。
+
+最小的索引是一份 npm 包名或 Git 仓库 URL 列表,每行一项。它能标识每个插件,但客户端渲染列表前必须为每个包获取 manifest(元数据清单)、语言文件和图标。列表页面每行发出一个或多个请求时,加载慢、可能部分失败,并且每个客户端都要实现元数据解析。
+
+因此,索引必须包含列表页面显示的全部内容:本地化标题和描述、图标、作者、可用版本、兼容性和发布时间。市场还需要发布自己的数据,例如分类、标签、下载量和评分,而 DSH 不需要理解这些值的含义。
+
+## 提案
+
+将**索引**与**索引生成器**分开。DSH 定义并读取一种 JSON 索引格式。索引如何生成、基于哪份源列表生成,由生成器负责。DSH 的读取器、Discover 页面和参考生成器是使用此格式的独立变更。索引列出[profile 插件组合包](../../implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md),并通过[引导式插件安装](../../implemented/architecture/2026-09-15-guided-plugin-installation.zh.md)安装它们。
+
+市场复用 npm 的包名、版本、范围、发布标签和 person 声明。读取器与生成器使用维护中的 npm 解析器和校验器。新增字段或改变含义需要 npm 无法满足的插件市场需求。
+
+### 示例
+
+```json
+{
+ "format": 1,
+ "title": { "en": "Example Market", "zh": "示例市场" },
+ "description": { "en": "Community DSH bundles", "zh": "社区 DSH bundle" },
+ "metadata": {
+ "categories": {
+ "productivity": { "title": { "en": "Productivity", "zh": "效率" } }
+ },
+ "labels": {
+ "official": { "title": { "en": "Official", "zh": "官方" } }
+ },
+ "sortKeys": {
+ "downloads": { "title": { "en": "Downloads", "zh": "下载量" }, "order": "desc" },
+ "rating": { "title": { "en": "Rating", "zh": "评分" }, "order": "desc" }
+ }
+ },
+ "plugins": [
+ {
+ "name": "@example/dsh-auto-review",
+ "title": { "en": "Auto Review", "zh": "自动审查" },
+ "description": { "en": "Per-tool LLM authorization review", "zh": "逐工具 LLM 授权审查" },
+ "icon": "icons/auto-review.svg",
+ "author": "Example Labs",
+ "homepage": "https://example.com/dsh-auto-review",
+ "metadata": {
+ "category": "productivity",
+ "labels": ["official"],
+ "keywords": ["review", "authorization"],
+ "sortKeys": { "downloads": 12034, "rating": 4.6 }
+ },
+ "dist-tags": { "latest": "0.2.0-rc.1", "next": "0.2.0-rc.2" },
+ "versions": [
+ {
+ "version": "0.2.0-rc.2",
+ "publishTimestamp": 1790476800,
+ "source": { "type": "npm" },
+ "engines": { "dsh": ">=0.2.0 <0.3.0" }
+ },
+ {
+ "version": "0.2.0-rc.1",
+ "publishTimestamp": 1789872000,
+ "source": { "type": "npm" },
+ "engines": { "dsh": "0.2.0 - 0.2.4 || >=0.3.0 <0.4.0" }
+ }
+ ]
+ },
+ {
+ "name": "dsh-plugin-foo",
+ "title": "Foo",
+ "icon": "https://example.com/foo.png",
+ "dist-tags": { "latest": "1.0.0" },
+ "versions": [
+ {
+ "version": "1.0.0",
+ "publishTimestamp": 1788000000,
+ "source": {
+ "type": "git",
+ "url": "https://github.com/author/dsh-plugins",
+ "commit": "3f9c2e1d8a4b6c0e7f1a2b3c4d5e6f708192a3b4",
+ "path": "packages/foo"
+ },
+ "engines": { "dsh": "*" }
+ }
+ ]
+ }
+ ]
+}
+```
+
+下文中的 `LocalizedText` 即 [`dsh-package-manifest`](../../../../packages/util/package-manifest/src/types.ts) 中的 `LocalizedText` 类型,即字符串或包含 `en` 的字符串对象,并附加两条规则:对象的键是匹配 `^[a-z]{2,8}(-[a-z0-9]{1,8})*$` 的小写语言 id,每个字符串至少包含一个非空白字符。
+
+### 根字段
+
+| 字段 | 必填 | 规则 |
+|---|---|---|
+| `format` | 是 | 整数 `1`。 |
+| `title` | 是 | 市场名称,类型为 `LocalizedText`。 |
+| `description` | 否 | `LocalizedText`。 |
+| `metadata` | 否 | 包含下列三个声明映射的对象。 |
+| `metadata.categories` | 否 | 对象,将分类 id 映射为 `{ title: LocalizedText }`。 |
+| `metadata.labels` | 否 | 对象,将标签 id 映射为 `{ title: LocalizedText }`。 |
+| `metadata.sortKeys` | 否 | 对象,将排序键 id 映射为 `{ title: LocalizedText, order: "asc" \| "desc" }`;`order` 是初始排序方向。 |
+| `plugins` | 是 | 插件数组,可以为空;数组顺序即索引的默认排序。 |
+
+分类、标签和排序键的 id 均为非空字符串。读取器按自有属性查找 id,因此 `constructor` 等 id 没有特殊含义。
+
+### 插件字段
+
+| 字段 | 必填 | 规则 |
+|---|---|---|
+| `name` | 是 | npm 维护的包名校验器接受的新包名称;在索引内唯一。 |
+| `title` | 否 | `LocalizedText`;缺省时读取器显示 `name`。 |
+| `description` | 否 | `LocalizedText`。 |
+| `icon` | 否 | 以下两种形式之一:`data:;base64,`,其中 `` 为 `image/svg+xml`、`image/png`、`image/jpeg` 或 `image/webp`,`` 是按规范填充的 base64,解码后的载荷不超过 256 KiB;或者 URL 引用,它相对于索引 URL 解析的结果是 `https:` URL,或与索引 URL 同源。 |
+| `author` | 否 | 至少包含一个非空白字符的字符串。 |
+| `homepage` | 否 | 介绍该插件的页面的绝对 `https:` 或 `http:` URL。 |
+| `metadata` | 否 | 包含下列值的对象。 |
+| `metadata.category` | 否 | 一个已声明的分类 id。 |
+| `metadata.labels` | 否 | 由互不相同的已声明标签 id 组成的数组。 |
+| `metadata.keywords` | 否 | 自由字符串数组;无需在根部声明。 |
+| `metadata.sortKeys` | 否 | 对象,将已声明的排序键 id 映射为有限数值。 |
+| `dist-tags` | 否 | 将 npm 发布标签名称映射为已列出的精确版本字符串的对象;缺省表示未记录发布渠道。 |
+| `versions` | 是 | 非空的版本数组;见下文。数组顺序不选择安装版本。 |
+
+`metadata.keywords` 按约定来自 npm `package.json` 的 `keywords`,但生成器可以使用其他来源、添加字符串或调整值。不要求提供此字段,也不要求它与包 manifest 一致。读取器保留字符串及其顺序;允许空数组。
+
+### 版本字段
+
+| 字段 | 必填 | 规则 |
+|---|---|---|
+| `version` | 是 | npm 的 `node-semver` 解析的精确版本;保留完整字符串,包括 Git 版本编译信息。精确字符串在插件内唯一。 |
+| `publishTimestamp` | 是 | 以秒为单位的整数 Unix 时间戳,取值范围为 `[0, 10^11)`。 |
+| `source` | 是 | `version` 的安装来源;见下文。 |
+| `engines` | 是 | 包含 `dsh` 的对象。 |
+| `engines.dsh` | 是 | 源 manifest 声明的非空白、有效 npm SemVer 范围;原样复制。`*` 显式声明支持任意 DSH 版本。 |
+
+相对的 `icon` 引用相对于索引 URL 解析,与 HTML 图片引用相对于所在页面解析的方式相同。对于位于 `https://market.example/dsh/index.json` 的索引,`"icon": "icons/foo.svg"` 指向 `https://market.example/dsh/icons/foo.svg`。因此,生成器可以把索引和图标文件作为一个目录发布到任意静态主机,无需知道最终 URL。指向其他源的绝对图标 URL 必须使用 `https:`;同源规则允许位于 loopback 或内网主机上的 `http:` 索引提供自己的图标。读取器在读取索引时解析该引用,因此客户端收到的是绝对 URL。
+
+`source` 采用以下两种形式之一,读取器拒绝其他任何 `type`:
+
+- `{ "type": "npm", "registry"?: string }`。`registry` 是不含凭据、查询或片段的 `http:` 或 `https:` URL;允许 `http:` 是为了支持内网镜像,与 DSH 的[安装 registry](../../implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 一致。
+- `{ "type": "git", "url": string, "commit": string, "path"?: string }`。`url` 是规范的 `https:` URL(等于其 WHATWG URL 序列化结果),至少包含两个非空路径段且不以斜杠结尾,不含凭据、查询或片段,且不以 `.tgz` 或 `.tar.gz` 结尾。`commit` 是 40 位小写十六进制 SHA。`path` 是以 `/` 分隔的相对目录,各段只包含 ASCII 字母、数字和 `._@+-`,且不是 `.` 或 `..`;缺省表示仓库根目录。
+
+### 安装参数
+
+客户端安装某个版本时恰好使用以下 pnpm 参数:
+
+- npm:spec 为 `@`。没有 `registry` 时,客户端像任何安装一样询问它配置的 registry。存在 `registry` 时,客户端遵循 DSH 的 [registry 计划](../../implemented/architecture/2026-09-18-plugin-install-registries.zh.md):先询问 `registry`,仅当它属于客户端配置的 registry 之一时才回退到其他已配置 registry;其他 `registry` 单独询问,因此私有 registry 不会回退到可能以相同名称和版本发布不同包的公共 registry。
+- git:spec 为 `git+.git#`,`url` 已以 `.git` 结尾时不重复添加;存在 `path` 时在其后追加 `&path:/`。`git+` 前缀使 pnpm 将路径段多于两个的 URL(例如 GitLab 子组)视为 Git 仓库。
+
+### DSH 兼容性
+
+市场列出的每个版本都必须声明有效的 `engines.dsh`。读取器与市场来源的安装使用 npm 的 `node-semver` 和 `includePrerelease: true`,根据当前运行的 DSH 版本求值。不存在对等依赖回退或范围合成。npm 包的对等依赖解析不属于市场宿主兼容性检查。
+
+安装接受市场来源的包前,会重新检查源 manifest。精确的本地 `name@version` 豁免可以允许有效范围拒绝当前运行时的情况,但不能使缺失或无效声明符合列出条件。豁免不发布在索引中。
+
+### 生成器义务
+
+读取器无法验证以下规则;违反这些规则的生成器会发布误导性的索引。
+
+- `name` 和每个 `version` 与所安装来源中的包 manifest 一致。
+- 对于 npm 来源,`publishTimestamp` 是 `version` 在 registry 中的发布时间;对于 Git 来源,它是 `commit` 的提交者时间。
+- `engines.dsh` 原样复制 manifest 声明的范围。缺失、空白或无效范围的版本会被省略并给出诊断。没有合格版本的插件会被省略。
+- `dist-tags` 是 npm [发布标签](https://docs.npmjs.com/cli/v11/commands/npm-dist-tag/)的快照,只保留指向合格且已列出版本的目标。对于 Git 来源,发布者或市场提供同样的精确标签到版本映射。
+- 列出的每个版本都声明了 `dsh.bundle`。安装时也会独立拒绝没有组合包 patch 的包。
+- `title`、`description` 和 `icon` 来自所列最高版本的语言元数据、manifest 字段和图标,采用已安装插件的显示规则。使用 npm 的 `node-semver.rsort()` 顺序,相等时保留输入顺序。元数据选择不选择安装版本。`homepage` 复制该版本 manifest 的 `homepage`,manifest 没有该字段时省略。
+- `author` 来自同一最高版本的 npm 作者声明。复制对象的 `name`;使用维护中的 npm person 归一化器解析简写字符串。省略缺失或全空白的名称以及联系字段。归一化新建的投影,不修改源 manifest。
+
+### 读取器规则
+
+文档违反根字段、插件字段、版本字段或 `source` 形式中的任何规则时,读取器拒绝整个索引,包括引用了未声明的分类、标签或排序键,发布标签指向未列出的目标,`name` 或精确的 `version` 字符串重复,以及 `format` 不是 `1`。每个此类缺陷都是生成器 bug,静默丢弃条目会掩盖它。读取器可以限制文档大小。
+
+读取器在每一层都忽略本 Agent Note 未定义的成员;未知的 `source.type` 值会被拒绝。这样格式 1 可以增加可选成员而不破坏现有读取器;现有读取器会误解的变更使用新的 `format` 值。
+
+标签名称必须能被 npm 维护的 `npm-package-arg` 解析为 registry 标签 spec;目标是已列出的精确版本字符串。npm 来源使用规范发布标识(`node-semver.valid(version) === version`);Git 来源保留版本编译信息并用提交固定精确来源。
+
+分类、标签和排序键对读取器不透明:读取器按它们筛选和排序,显示其 `title`,从不解释 id 的含义。读取器按本地化 `title` 对声明排序后显示。对插件排序时,无论排序方向如何,没有所选排序键值的插件都排在所有有值的插件之后,值相同的插件保持索引顺序。按发布时间排序时使用插件最大的 `publishTimestamp`。
+
+读取器按 DSH 兼容性一节的规则求值必填的 `engines.dsh` 范围。版本使用 npm 的 `node-semver.rsort()` 顺序,相等时保留索引顺序。安装版本由精确的标签目标选择,而不是此显示顺序。
+
+初始渠道是 `latest`。它的精确目标是发布者或市场选择的版本。客户端安装记录的版本或提交,绝不安装 `name@latest`。缺失或不兼容的 `latest` 需要显式选择版本或渠道;客户端不会自动切换。安装确认标明版本、所选渠道(如果有)、先行版本状态、索引 URL 和来源。
+
+## 考虑过的替代方案
+
+**纯 npm 包名或仓库 URL 列表。** 它能标识每个插件,但列表渲染前每行需要一个或多个请求,而此格式正是为消除这一开销而设计。
+
+**每个版本使用一个不透明的安装 spec 字符串。** 读取器无法验证自由格式的 spec 是否固定了版本。结构化的 `source` 配合精确的 `version` 或 `commit`,读取器可以验证版本固定,客户端可以构建要安装的 spec。
+
+**安装最新版本而不是固定版本。** 这样安装的构建可能与列表显示的元数据不一致,并且包发布新版本会改变未修改的索引所安装的内容。
+
+**每个版本一个条目。** 每个版本都会重复标题、描述、图标、作者和市场元数据,读取器还要按 `name` 分组才能显示一个插件。
+
+**每个插件一个 `source`,每个版本只记录固定点。** 在两次发布之间换了仓库、目录或 registry 的插件将无法列出旧版本。
+
+**每个版本各有显示字段。** 列表每个插件只显示一行,旧的显示文本、图标、作者和主页会增加索引大小,却没有读取器显示它们。
+
+**只允许内联图标。** 不发额外请求的列表需要内联图标,但 256 KiB 原始字节编码为 base64 后约为 341 KiB,因此 100 个插件的索引可能达到约 35 MB。URL 图标以额外请求和向图片主机暴露用户 IP 地址为代价,换取较小的索引。
+
+**从对等依赖推断宿主兼容性。** 市场定义要求作者声明 `engines.dsh` 的新标准。现有包必须添加此声明才能列出;不需要回退、workspace 映射或范围求交。
+
+**将完整 npm person 对象复制到显示字段。** 列表需要名称。生成器支持 npm 的原始声明,同时省略不用的联系字段。
+
+**用不透明排序值表示时间,包括 ISO 日期字符串。** 发布时间具有固定语义,客户端会对其格式化和排序,因此它是一个有类型的字段。排除时间后,排序值可以只用数值,无需为每个键规定值类型。
+
+**根级 `labels` 和 `sortKeys`,以及名为 `sort` 的插件字段。** 在两个层级都把市场定义的声明和值归入 `metadata`,并使用对应的成员名(单值的 `category` 除外),可以将它们与客户端解释的字段区分开。
+
+**以声明顺序作为显示顺序。** JavaScript 会重排类整数的对象键,因此要保留声明顺序,就需要限定 id 格式或改用数组声明。客户端改为按本地化标题对声明排序。
+
+**选择最高兼容版本。** npm 的 `latest` 可以指向较旧的发布版本。从 SemVer 排名推断目标会丢失记录的渠道选择,而且在构建排名相等时存在歧义。
+
+**自动回退到先行版本。** 渠道目标缺失或不兼容,不表示用户选择了先行版本。
+
+**拒绝未知成员。** 这样每次增加可选成员都需要新的 `format` 值,并会破坏所有已部署的读取器。
+
+## 验收标准
+
+- 校验器接受上面的示例,并对根字段、插件字段、版本字段和 `source` 形式中的每条规则各拒绝一个违例,诊断信息给出对应的 JSON 路径。
+- 根据每个示例版本构建的安装参数可被 DSH 的安装 spec 解析器和 pnpm 接受。
+- 校验器在每一层都忽略未知成员。
+- 生成器和校验器拒绝缺失、空白和无效的 `engines.dsh`;省略版本后不留下悬空的标签目标。索引和市场来源的安装对有效范围结果一致,包括连字符范围、先行版本以及拒绝当前运行时的有效范围。豁免绝不绕过声明要求。
+- 排名相等的 Git 构建版本重新排序后,精确标签目标仍选择同一来源。缺失的目标被拒绝;`latest` 缺省或不兼容时不自动切换。
+- npm 作者对象与简写字符串产生声明的名称,不包含联系字段,也不修改源身份。
+
+## 风险
+
+- 索引没有签名。标题、作者、标签和评分是市场的声明;`official` 标签不是 DSH 验证。客户端必须显示索引 URL 以及每个版本的 `source`。
+- Git 版本的 `name`、`version` 和 `engines.dsh` 是关于仓库的声明。仓库可以声明任意名称,包括 `@deepseek-ai/dsh-` 名称,实际安装的内容由已安装的 manifest 决定。
+- npm 版本的 `source.registry` 同样是一种声明:它把任意 `name`(包括 `@deepseek-ai/dsh-` 名称)的安装引向市场选择的 registry,由该 registry 决定实际安装的内容。
+- URL 图标会向图片主机暴露用户的 IP 地址。
+- 固定版本和发布标签在索引重新生成之前一直保持陈旧。
+- 市场文本未经 DSH 审核即到达用户,并通过任何列出插件的客户端工具到达模型。
diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md
index de63792f72..fa6ebb70a1 100644
--- a/.agents/skills/dsh-client-ui-ux/SKILL.md
+++ b/.agents/skills/dsh-client-ui-ux/SKILL.md
@@ -24,7 +24,7 @@ This skill is guidance, not a complete checklist. It covers judgment calls that
## Feedback surfaces
-Choose the surface by the lifetime of the message relative to the surface that produced it:
+Apply [dsh-error-handling](../dsh-error-handling/SKILL.md#localized-actionable-visible-reports) to failure reports. Choose the surface by the lifetime of the message relative to the surface that produced it:
- **Transient operation outcomes use the app-wide Toast primitive**, not in-place notices, and the toast's state and rendering live in a host that outlives the reporting surface (a `shell.overlay` entry, as RowActionToast does) — a toast rendered by the panel itself unmounts with that panel. A deletion can close the panel or tab that requested it; only a toast held outside that surface survives the close. Report success and failure; do not report still-pending states.
- **A failed operation keeps the data visible.** A failed deletion keeps its row; the toast announces the failure. Never blank content to show an error for a transient operation.
diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md
index 484cd1c288..e75ca07744 100644
--- a/.agents/skills/dsh-code-review/SKILL.md
+++ b/.agents/skills/dsh-code-review/SKILL.md
@@ -15,6 +15,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
- [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings.
- [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures.
- [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes.
+- [dsh-error-handling](../dsh-error-handling/SKILL.md): failure classification, handling ownership, containment, state guarantees, and actionable reports.
- [docs/testing.md](../../../docs/testing.md) and the [quality-gates reference](../../../docs/testing.md): required test tiers and gates.
- [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto.
- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation.
@@ -31,6 +32,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
## Manual checks
- **Intent and interface contracts:** trace both sides of every changed interface. Confirm the implementation matches the PR and any Agent Note, including errors, cancellation, ownership, and disposal.
+- **Error handling:** apply [dsh-error-handling](../dsh-error-handling/SKILL.md) to changed failure paths, including their state guarantees and final user-visible reports. Reject catch-and-continue unless the state owner establishes that subsequent work's preconditions hold, including after recovery.
- **Lifecycle and concurrency:** for async setup, callbacks, processes, or teardown, apply [defensive-patterns.md](../../../docs/defensive-patterns.md). Check races before publication, cancellation during awaits, independent error reporting, callback containment, ownership before reentry, complete detach cleanup, and quiescent disposal.
- **Capability and consumer fit:** trace every current consumer, then flag consumer-specific behavior leaking into the interface under [the package rules](../../../packages/AGENTS.md). Flag the inverse too: a new public method on a generic service (registry, session, agent) whose only caller is one internal consumer is an unnecessary API expansion — require a private capability closure handed to that consumer at construction instead.
- **Scope, ownership, and necessity:** map each abstraction, state machine, option, defensive copy, and compatibility path to its current contract, production consumer, and owning plugin or service. Challenge unrelated features and speculative generality, then test the PR against [the root rules](../../../AGENTS.md#conventions).
diff --git a/.agents/skills/dsh-doc/templates/persistence-change.md b/.agents/skills/dsh-doc/templates/persistence-change.md
index 4d5e720254..f31867aaaf 100644
--- a/.agents/skills/dsh-doc/templates/persistence-change.md
+++ b/.agents/skills/dsh-doc/templates/persistence-change.md
@@ -13,7 +13,7 @@ kind: persistence-change
## Skeleton
-The generator fills the machine declaration, schema companion, and consistency records, inferring the minimum version decision from the fixed rules. Supply `--prose` JSON with `en` and `zh` objects, each containing authored `summary`, `compatibility`, and `verification` strings; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) shows the complete operation. The following values illustrate the document fields; use the actual generated root names, record ids, and digests.
+The generator fills the machine declaration, schema companion, and consistency records. Structural changes outside the established same-version cases require an explicit compatibility decision. Supply `--prose` JSON with `en` and `zh` objects, each containing authored `summary`, `compatibility`, and `verification` strings. Use Compatibility to explain both reader directions and Verification to record executed checks. The [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) shows the operation. Use the actual generated root names, record ids, and digests in the following fields.
````markdown
# Persistence change:
@@ -48,7 +48,7 @@ changes:
## Compatibility
-Explain the declared decision for the detected paths. For an optional addition, explain why existing records can omit it and older readers can ignore it without changing replay. For required-to-optional changes, explain how readers handle absence. For a version bump, link the adjacent migration and its verification owner.
+Explain the declared decision for every detected path: how older readers preserve meaning, safely ignore optional data, or refuse required features before execution or recovery; and how new readers preserve historical meaning and mixed representations. Feature absence retains existing behavior. For a version bump, link the adjacent migration and its verification owner.
## Verification
@@ -66,8 +66,8 @@ None.
- Keep exactly one `yaml persistence-change` block. The English and Chinese blocks are byte-identical; the verifier parses the English record once and checks its counterpart.
- The filename stem and `id` agree. A root's `previous` identifies its predecessor record; `null` introduces a root. Its `after` matches the generated companion schema digest; `null` records deletion.
- One initial `baseline: true` record covers the complete inventory. Routine changes use `baseline: false` and preserve accepted predecessors. A version-bump record carries its own increasing `SessionHeader.version` transition.
-- Omit `--decision` in the ordinary authoring flow. An explicit value asserts the decision and cannot bypass classification or the required header increase. The command supplies no source changes or validation claims.
+- Structural differences alone do not infer a bump. A same-version change requiring review needs explicit `--decision same-version`; a changed header version infers `version-bump`. An explicit decision cannot waive the required header increase. The command supplies no source changes or validation claims.
- Explain only the detected persistence-type changes. This kind permits the before/after facts and historical evidence needed for that transition; behavior-only changes remain out of scope.
- Record creation and terminal-record update generate the catalog pair and consistency sidecars. Review both languages and retain the generated companions. Direct prose edits still require re-recording pairing; do not hand-maintain schema nodes or digests.
-- Use explicit `--update` only for an unaccepted terminal record. It rejects baselines and depended-on records; `--prose` replaces its authored explanations, while omission preserves them. Accepted records receive successors.
+- Use explicit `--update` only for an unaccepted terminal record. It rejects baselines and depended-on records; `--prose` replaces its authored explanations, while omission preserves them. A flagged same-version update requires explicit `--decision same-version`; reassess the explanation against the complete transition. Accepted records receive successors.
- The [folder reference](../../../../docs/persistence-changes/README.md) owns compatibility rules; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) owns commands and recovery.
diff --git a/.agents/skills/dsh-error-handling/SKILL.md b/.agents/skills/dsh-error-handling/SKILL.md
new file mode 100644
index 0000000000..f9dd7fd809
--- /dev/null
+++ b/.agents/skills/dsh-error-handling/SKILL.md
@@ -0,0 +1,30 @@
+---
+name: dsh-error-handling
+description: Apply error-handling principles when designing or reviewing failures, recovery, retries, background tasks, and user-visible error reports.
+---
+
+# DeepSeek Harness Error Handling
+
+Define failures, their owners, and what remains safe after failure.
+
+## Typed, structured failures
+
+Expected failures need declared types, stable discriminants, and handling-relevant details. Reuse package error conventions, preserve causes, and separate machine identity from display text. Never route handling by parsing messages. Unexpected defects such as `assertNever` may remain plain `Error`; containment must never turn them into success.
+
+## Catch, recover, or stop
+
+For each failure, define handling and reporting owners, containment, and guarantees in the owning API documentation: valid invariants, absent/partial/committed/unknown effects, and cleanup responsibility. Background work retains these obligations.
+
+Catching limits exception propagation, not mutations. Only the affected state's owner can establish safe continuation. Otherwise stop dependent work, discard invalid state or instances, or escalate. Logging alone is not recovery. Retries require safe repetition, cancellation, and an explicit owner; finite operations need budgets, while continuing supervision needs lifecycle ownership and observable degradation.
+
+## Localized, actionable, visible reports
+
+Every final report needs a readable summary, affected operation, and safe next action. Localize through the receiving application's locale mechanism where one exists; other diagnostics use their existing output language. Keep sensitive internal details out of user-facing text.
+
+Untrusted state or unknown outcomes must be visible through UI or default-enabled, accessible logs: report the impact, uncertainty, system disposition, and safe next step. Persistent invalid state needs persistent visibility. Never claim success, rollback, or safe retry without guarantees. Reporting failures need an independent fallback.
+
+Verify resulting state and visible outcomes, not merely that an exception was caught.
+
+## Foundations
+
+Meyer's *Object-Oriented Software Construction* (1997): contracts and invariants; Sutter's *Exceptional C++* (1999): exception safety guarantees; Armstrong's [thesis](https://www.erlang.org/download/armstrong_thesis_2003.pdf) (2003): isolation and supervision.
diff --git a/.github/AGENTS.md b/.github/AGENTS.md
index 7cc2946f53..502c1333ea 100644
--- a/.github/AGENTS.md
+++ b/.github/AGENTS.md
@@ -1,3 +1,5 @@
# AGENTS.md — GitHub Actions
Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)).
+
+The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes.
diff --git a/.github/workflows/build-exe-for-ssh-helper.yml b/.github/workflows/build-exe-for-ssh-helper.yml
new file mode 100644
index 0000000000..15455ddda2
--- /dev/null
+++ b/.github/workflows/build-exe-for-ssh-helper.yml
@@ -0,0 +1,127 @@
+name: Build SSH helper
+
+on:
+ workflow_call:
+ inputs:
+ targets:
+ description: Comma-separated native Node 24 targets; empty builds all four.
+ type: string
+ default: ''
+ release:
+ description: Retain a complete release build when another run starts.
+ type: boolean
+ default: false
+ workflow_dispatch:
+ inputs:
+ targets:
+ description: Linux/macOS x64/arm64 pkg targets; empty builds all four.
+ type: string
+ default: ''
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ssh-helper-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ !inputs.release }}
+
+env:
+ DSH_TELEMETRY_DISABLED: '1'
+
+jobs:
+ plan:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ outputs:
+ matrix: ${{ steps.targets.outputs.matrix }}
+ steps:
+ - name: Validate native targets
+ id: targets
+ env:
+ REQUESTED_TARGETS: ${{ inputs.targets }}
+ run: |
+ node --input-type=module <<'JS'
+ import { appendFileSync } from 'node:fs';
+ const runners = {
+ 'node24-linux-x64': 'ubuntu-24.04',
+ 'node24-linux-arm64': 'ubuntu-24.04-arm',
+ 'node24-macos-arm64': 'macos-latest',
+ 'node24-macos-x64': 'macos-15-intel',
+ };
+ const raw = process.env.REQUESTED_TARGETS;
+ const targets = raw ? raw.split(',').map(value => value.trim()) : Object.keys(runners);
+ if (new Set(targets).size !== targets.length || targets.some(target => !Object.hasOwn(runners, target))) throw new Error('Invalid or duplicate SSH helper target');
+ appendFileSync(process.env.GITHUB_OUTPUT, `matrix=${JSON.stringify(targets.map(target => ({target, runner: runners[target]})))}\n`);
+ JS
+
+ build:
+ needs: plan
+ name: ${{ matrix.target }}
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 40
+ strategy:
+ fail-fast: false
+ matrix:
+ include: ${{ fromJSON(needs.plan.outputs.matrix) }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: pnpm/action-setup@v4
+ with:
+ dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ cache: pnpm
+ - uses: actions/cache@v4
+ with:
+ path: ~/.pkg-cache
+ key: ssh-pkg-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }}
+ restore-keys: ssh-pkg-${{ matrix.target }}-
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+ - name: Prepare Linux compiler and sandbox
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update -q
+ sudo apt-get install -yq musl-tools bubblewrap
+ if sysctl kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
+ sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
+ fi
+ bwrap --ro-bind / / --dev /dev --unshare-pid --proc /proc --die-with-parent -- true
+ - name: Build compatible Linux PTY addon
+ if: runner.os == 'Linux'
+ run: bash scripts/prepare-linux-node-pty.sh
+ - name: Build native release archive
+ env:
+ HELPER_TARGET: ${{ matrix.target }}
+ run: pnpm exec tsx scripts/build-exe-for-ssh-helper.ts --target="$HELPER_TARGET"
+ - name: Verify relocated executable and confinement
+ env:
+ HELPER_TARGET: ${{ matrix.target }}
+ run: |
+ archives=(dist-exe/ssh-helper/*.tar.gz)
+ test "${#archives[@]}" -eq 1
+ pnpm exec tsx scripts/verify-ssh-helper-artifact.ts --archive="${archives[0]}" --sandbox=required --report="dist-exe/ssh-helper/$HELPER_TARGET.json"
+ - name: Verify the packaged Landlock backend
+ if: runner.os == 'Linux'
+ env:
+ HELPER_TARGET: ${{ matrix.target }}
+ run: |
+ archives=(dist-exe/ssh-helper/*.tar.gz)
+ pnpm exec tsx scripts/verify-ssh-helper-artifact.ts --archive="${archives[0]}" --sandbox=required --backend=landlock-run --report="dist-exe/ssh-helper/$HELPER_TARGET.landlock.json"
+ - name: Verify OpenSSH on glibc 2.28 without Node
+ if: runner.os == 'Linux'
+ env:
+ HELPER_TARGET: ${{ matrix.target }}
+ run: |
+ archives=(dist-exe/ssh-helper/*.tar.gz)
+ test "${#archives[@]}" -eq 1
+ pnpm exec tsx scripts/verify-ssh-helper-ssh.ts --archive="${archives[0]}" --report="dist-exe/ssh-helper/$HELPER_TARGET.ssh.json"
+ - name: Upload verified archive and evidence
+ uses: actions/upload-artifact@v7
+ with:
+ name: ssh-helper-${{ matrix.target }}
+ path: dist-exe/ssh-helper/*
+ if-no-files-found: error
diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml
index 5feb9fb33c..5620e6af63 100644
--- a/.github/workflows/ci-master.yml
+++ b/.github/workflows/ci-master.yml
@@ -13,6 +13,7 @@ on:
options:
- larger-runner-benchmark
- consolidated-runner-benchmark
+ - ssh-helper
# New master pushes and manual runs replace obsolete checks in this workflow/ref.
# Standby drills share cancellation; use completed runs as readiness evidence.
@@ -30,6 +31,13 @@ env:
DSH_TELEMETRY_DISABLED: '1'
jobs:
+ ssh-helper:
+ if: github.event_name == 'workflow_dispatch' && inputs.suite == 'ssh-helper'
+ name: ssh helper / native artifacts
+ uses: ./.github/workflows/build-exe-for-ssh-helper.yml
+ with:
+ targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64
+
# These native runtime carriers are post-merge checks; release keeps all targets.
python-runtime:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d9176179b3..93c3d5293c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -249,6 +249,20 @@ jobs:
DSH_GATE_VERBOSE: '1'
run: pnpm run check:ci:bench
+ # The report global teardown renders benchmarks/.dsh-report after the run,
+ # including after a failing budget assertion. A run that fails before
+ # recording any timing has no report, so missing files warn instead of
+ # failing the benchmark job a second time.
+ - name: Upload scaled benchmark report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: benchmark-report
+ path: benchmarks/.dsh-report/
+ include-hidden-files: true
+ if-no-files-found: warn
+ retention-days: 7
+
node-24-consumers:
if: github.event_name == 'pull_request'
runs-on: >-
diff --git a/.github/workflows/publish-ssh-helper.yml b/.github/workflows/publish-ssh-helper.yml
new file mode 100644
index 0000000000..f6740f210c
--- /dev/null
+++ b/.github/workflows/publish-ssh-helper.yml
@@ -0,0 +1,101 @@
+name: Release (SSH helper)
+
+# Validation accepts any ref; publication requires the matching dsh-v tag.
+on:
+ workflow_dispatch:
+ inputs:
+ publish:
+ description: Publish verified archives to GitHub Release. Must run from the matching dsh-v tag.
+ type: boolean
+ required: true
+ default: false
+
+permissions:
+ contents: read
+
+concurrency:
+ group: publish-ssh-helper-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.source.outputs.version }}
+ commit: ${{ steps.source.outputs.commit }}
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - name: Resolve source and validate publication tag
+ id: source
+ env:
+ PUBLISH: ${{ inputs.publish }}
+ run: |
+ node --input-type=module <<'JS'
+ import { readFileSync, appendFileSync } from 'node:fs';
+ import { execFileSync } from 'node:child_process';
+ const {version} = JSON.parse(readFileSync('package.json', 'utf8'));
+ if (process.env.PUBLISH === 'true' && (process.env.GITHUB_REF_TYPE !== 'tag' || process.env.GITHUB_REF_NAME !== `dsh-v${version}`)) throw new Error('Publication requires the matching dsh-v tag');
+ const commit = execFileSync('git', ['rev-parse', 'HEAD'], {encoding:'utf8'}).trim();
+ appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\ncommit=${commit}\n`);
+ JS
+ build:
+ needs: validate
+ uses: ./.github/workflows/build-exe-for-ssh-helper.yml
+ with:
+ release: true
+ verify:
+ needs: [validate, build]
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+ - uses: pnpm/action-setup@v4
+ with:
+ dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
+ - uses: actions/setup-node@v6
+ with:
+ node-version: 24
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: ssh-helper-node24-*
+ path: dist-exe/ssh-helper
+ merge-multiple: true
+ - name: Require complete evidence from the selected source commit
+ env:
+ RELEASE_VERSION: ${{ needs.validate.outputs.version }}
+ SOURCE_COMMIT: ${{ needs.validate.outputs.commit }}
+ run: pnpm exec tsx scripts/ssh-helper/release.ts --directory=dist-exe/ssh-helper --version="$RELEASE_VERSION" --commit="$SOURCE_COMMIT"
+ - name: Upload verified release checksums
+ uses: actions/upload-artifact@v7
+ with:
+ name: ssh-helper-release-checksums
+ path: dist-exe/ssh-helper/SHA256SUMS
+ if-no-files-found: error
+
+ publish:
+ if: inputs.publish
+ needs: [validate, verify]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: ssh-helper-*
+ path: dist-exe/ssh-helper
+ merge-multiple: true
+ - name: Attach the verified archives to the GitHub release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ run: |
+ if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
+ gh release create "$RELEASE_TAG" --verify-tag --draft --title "$RELEASE_TAG" --notes 'SSH helper executables for Linux and macOS.'
+ fi
+ gh release upload "$RELEASE_TAG" dist-exe/ssh-helper/*.tar.gz dist-exe/ssh-helper/SHA256SUMS
diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml
new file mode 100644
index 0000000000..56bcb47a96
--- /dev/null
+++ b/.github/workflows/windows-package.yml
@@ -0,0 +1,100 @@
+name: Windows installer (unsigned)
+
+on:
+ workflow_dispatch:
+ inputs:
+ build_version:
+ description: 'Build version: auto generates a version; otherwise enter a full version'
+ type: string
+ default: auto
+ required: true
+ deployment:
+ description: Update policy deployment
+ type: choice
+ options: [test, production]
+ default: test
+ required: true
+ policy_origin:
+ description: 'Required update policy HTTPS origin (no path)'
+ type: string
+ required: true
+ login_origins:
+ description: 'Required for test: comma-separated allowed login HTTPS origins'
+ type: string
+ required: false
+
+permissions:
+ contents: read
+
+concurrency:
+ group: windows-desktop-package
+ cancel-in-progress: false
+
+jobs:
+ package:
+ runs-on: dsh-win-package-trial
+ timeout-minutes: 120
+ defaults:
+ run:
+ shell: pwsh
+ env:
+ DSH_TELEMETRY_DISABLED: '1'
+ steps:
+ - uses: actions/checkout@v7.0.1
+ id: checkout
+ with:
+ persist-credentials: false
+ clean: true
+ - uses: pnpm/action-setup@v5.0.0
+ with:
+ dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
+ - uses: actions/setup-node@v7.0.0
+ with:
+ node-version: '24'
+ - name: Install locked dependencies
+ run: |
+ pnpm install --frozen-lockfile
+ exit $LASTEXITCODE
+ - name: Validate inputs and prepare public package settings
+ id: settings
+ env:
+ PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
+ PACKAGE_DEPLOYMENT: ${{ inputs.deployment }}
+ PACKAGE_POLICY_ORIGIN: ${{ inputs.policy_origin }}
+ PACKAGE_LOGIN_ORIGINS: ${{ inputs.login_origins }}
+ run: node apps/desktop/scripts/prepare-windows-ci.mjs
+ - name: Check packaging configuration and toolchain
+ env:
+ PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
+ run: |
+ pnpm run package:desktop:win:x64:unsigned --check --build-version "$env:PACKAGE_VERSION"
+ exit $LASTEXITCODE
+ - name: Build installer and verify packaged runtime
+ env:
+ PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
+ run: |
+ pnpm run package:desktop:win:x64:unsigned --build-version "$env:PACKAGE_VERSION"
+ exit $LASTEXITCODE
+ - name: Retain packaging diagnostics
+ if: always() && steps.checkout.outcome == 'success'
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: windows-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
+ include-hidden-files: true
+ path: apps/desktop/.desktop-build/packaging-runs/
+ if-no-files-found: warn
+ retention-days: 14
+ - name: Retain verified installer
+ uses: actions/upload-artifact@v7.0.1
+ with:
+ name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
+ include-hidden-files: true
+ path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe
+ if-no-files-found: error
+ retention-days: 14
+ - name: Remove generated package settings
+ if: always() && steps.checkout.outcome == 'success'
+ run: |
+ if (Test-Path -LiteralPath apps/desktop/.env.windows) {
+ Remove-Item -LiteralPath apps/desktop/.env.windows
+ }
diff --git a/.gitignore b/.gitignore
index 86e330fc0c..3cb86ce87d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,6 +39,7 @@ packages/**/__pycache__/
apps/web/dist/
.artifacts/
.dsh-build/
+.dsh-report/
.playwright-mcp/
.orig
.worktrees/
diff --git a/AGENTS.md b/AGENTS.md
index 38e5ae46cf..c29ff51cff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -128,7 +128,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- Packages use `@deepseek-ai/dsh-`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
-- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read by default — builds that do not know a type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
+- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read unless the envelope carries `ignorable: true`. Bump `SESSION_FORMAT_VERSION` only when existing discriminators cannot prevent unsafe interpretation; same-version changes require safe reader behavior ([review](.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md)).
- **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
- **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
- **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
@@ -143,6 +143,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- **No new assertions to `unknown`** (`as unknown` or ``). Preserve or reduce the exact legacy baseline; use typed values or validation for replacements ([rule](.agents/notes/implemented/process/2026-09-19-no-unknown-casts.md)).
- **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)).
- **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)).
+- **Error handling:** use typed, structured failures and localized, actionable reports. Define handling ownership, containment, and failure guarantees; stop dependent work and visibly report untrusted state or unknown outcomes. Follow [dsh-error-handling](.agents/skills/dsh-error-handling/SKILL.md).
- **An empty `catch` names the error** and why; keep its `try` to one statement.
- **Keep comments local.** Do not restate code, expand unrelated comments, or explain distant behavior without local need ([rationale](.agents/skills/dsh-prose-standard/SKILL.md)).
- **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)).
diff --git a/Makefile b/Makefile
index 4b95df6766..0836489ef8 100644
--- a/Makefile
+++ b/Makefile
@@ -1,13 +1,14 @@
# Short names for the Web and Desktop application commands. The package.json
# scripts they call stay the source of truth; docs/development.md documents both.
.DEFAULT_GOAL := help
-.PHONY: help build web desktop dev-web dev-desktop
+.PHONY: help build clean web desktop dev-web dev-desktop
PNPM ?= pnpm
ARGS ?=
help:
@echo "make build pnpm run build complete repository build"
+ @echo "make clean pnpm run clean remove build outputs and safe deleted-package residue"
@echo "make web pnpm run start:web serve the built Web artifacts from source"
@echo "make desktop pnpm run start:desktop launch the built Desktop artifacts"
@echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits"
@@ -18,6 +19,9 @@ help:
build:
$(PNPM) run build
+clean:
+ $(PNPM) run clean
+
web:
$(PNPM) run start:web $(ARGS)
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 741c98db08..2dbbb5cd40 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -247,6 +247,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 |
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`publint`](https://github.com/publint/publint) | MIT |
+| [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT |
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
| [`tar`](https://github.com/isaacs/node-tar) | BlueOak-1.0.0 |
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 3da15fcad8..e7291abcc7 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -90,7 +90,7 @@
"@deepseek-ai/schemastery": "workspace:~",
"commander": "^15.0.0",
"js-yaml": "^4.3.2",
- "node-addon-require-builtin": "^0.1.6",
+ "node-addon-require-builtin": "^0.1.9",
"@deepseek-ai/dsh-http-proxy": "workspace:*",
"@deepseek-ai/dsh-mcp-resources": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml
index da6e0863ff..69b8cfee07 100644
--- a/apps/cli/reference/README.i18n.yaml
+++ b/apps/cli/reference/README.i18n.yaml
@@ -9,8 +9,8 @@
en: e3a159750f5f9ed3
zh: e02d9004fcc239f9
/dsh-cli-behavior-reference/profile-boot/app-arguments:
- en: be06f11a3b585d0f
- zh: 28c7ee6e0d86dc3b
+ en: 3d2f4cf6a0afc3cd
+ zh: 9fe7e8f6a4acb596
/dsh-cli-behavior-reference/profile-boot/config-schema-dump:
en: a356f8a68f3b3685
zh: c718713b5d709295
@@ -21,8 +21,8 @@
en: 8d2cfff9bab7327f
zh: 21515ab6e6798504
/dsh-cli-behavior-reference/web-profile:
- en: 536c6883a0d54b14
- zh: 6dd2469b71189c87
+ en: ee6c00e55a2f0a10
+ zh: 404942e397e213ab
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md
index e8105bab78..3123b7898e 100644
--- a/apps/cli/reference/README.md
+++ b/apps/cli/reference/README.md
@@ -35,7 +35,7 @@ The shipped apps own these command lines:
| Profile | Arguments |
|---|---|
-| `web` | `--host`, `--port`, `--public-url`, repeatable `--trusted-host`, `--no-open` |
+| `web` | `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, repeatable `--trusted-host`, `--no-open` |
| `headless` | the task text, as the positional argument |
| `sdk` | no options; stdio carries the JSON-RPC protocol |
| `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol |
@@ -106,19 +106,23 @@ Git-hosted plugins that ship sources build during install through their `prepare
## Web profile
-`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
+`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed listener values, and `--tls-cert`/`--tls-key` set the listener's `tls` pair. The host must name one concrete local address, not a wildcard (`--host 0.0.0.0` exits with a usage error); `hostname -i` may list several, so pass one. Repeatable `--trusted-host` values are collected in `ctx.webStartup.trustedHosts`; a deployment expression may add its own authorities. `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere.
-`--public-url ` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's loopback URL. It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide.
+`--public-url ` advertises one HTTP(S) application root — with an optional forwarding prefix — in place of the listener's bind-address URL (loopback for a loopback bind). It grants no trust, so the browser-visible authority still has to be named with `--trusted-host`; [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what the proxy in front must provide.
+
+`--tls-cert ` and `--tls-key ` make the listener serve HTTPS in place of plain HTTP. They are one setting: both or neither, and each names a file resolved against the process working directory. The certificate file holds the full chain, including intermediates; the key file holds an unencrypted PEM private key. A missing, unreadable, empty, or invalid pair fails startup, which never falls back to HTTP, and a replacement pair takes effect only after a listener reload or a process restart because the carrier reads the files once and nothing obtains, renews, or watches certificates. HTTPS also grants no trust: the browser must trust the certificate and open an authority its names cover, Connection still authenticates each request and checks Host/Origin, and the session cookie is `Secure` because the receiving listener serves HTTPS. The default port stays 3080, and `--public-url` remains an independent advertisement.
```sh
dsh web
+dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
+dsh web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
dsh web --no-open
dsh web --patch ./extra.cordis.yml
dsh web --dump-config
dsh web --help
```
-The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default and, for a local launch, opens that canonical host URL — the advertised root when `--public-url` names one — only after the complete Loader tree settles. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses the browser handoff because the SSH client or editor owns the local forwarded address; the host URL is still printed. The CLI intentionally does not support `--host 0.0.0.0` and exits with a usage error. Immediately before a local handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, a diagnostic on stderr states the reason, leaves the server running, and names the URL for manual use. `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
+The production Web runner needs built package and frontend artifacts (`pnpm run build`). It prints the advertised `--public-url` when configured, otherwise the listener's bind-address URL with the listener's scheme (default `http://127.0.0.1:3080`, or `https://` when TLS is configured). The Host/Origin fence accepts the bind IP directly; a proxy or DNS authority still needs `--trusted-host`. The startup line and default-browser handoff wait for the complete Loader tree. A non-empty inherited `SSH_CONNECTION` or `SSH_TTY` suppresses browser opening, not the startup line. Immediately before a browser handoff it prints `dsh web: opening the default browser; pass --no-open to disable`; if the operating-system handoff fails, stderr names the reason and points to the startup URL while the server keeps running.
Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md
index 2471de196e..9a43ca2858 100644
--- a/apps/cli/reference/README.zh.md
+++ b/apps/cli/reference/README.zh.md
@@ -37,7 +37,7 @@ dsh rescue
| Profile | 参数 |
|---|---|
-| `web` | `--host`、`--port`、`--public-url`、可重复的 `--trusted-host`、`--no-open` |
+| `web` | `--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、可重复的 `--trusted-host`、`--no-open` |
| `headless` | 任务文本,作为位置参数 |
| `sdk` | 无选项;stdio 携带 JSON-RPC 协议 |
| `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 |
@@ -108,19 +108,23 @@ dsh --profile tui
## Web Profile
-`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
+`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 与 `--port` 覆盖组合的监听器取值,`--tls-cert`/`--tls-key` 则设置监听器的 `tls` 证书对。host 必须指名一个具体的本机地址,不能是通配地址(`--host 0.0.0.0` 会以用法错误退出);`hostname -i` 可能列出多个,请只传一个。可重复的 `--trusted-host` 值会收集到 `ctx.webStartup.trustedHosts`;部署表达式可以添加自己的 authority。`--no-open` 只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。
-`--public-url ` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的 loopback URL。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
+`--public-url ` 公告唯一的 HTTP(S) 应用根——可带转发前缀——替代监听器的绑定地址 URL(回环绑定时为回环地址)。它不授予信任,因此浏览器可见的 authority 仍需用 `--trusted-host` 点名;[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了前置代理必须提供的内容。
+
+`--tls-cert ` 与 `--tls-key ` 让监听器以 HTTPS 取代明文 HTTP 提供服务。它们属于同一个设置:要么都给,要么都不给;两者各是相对进程工作目录解析的路径。证书文件包含完整证书链(含中间证书),密钥文件为不带口令的 PEM 私钥。文件缺失、不可读、为空或无效时启动失败,且绝不回退到 HTTP;由于载体只读取一次文件,且既不获取、不续期也不监视证书,替换后的证书对只有在重新加载监听器或重启进程后才生效。HTTPS 同样不授予信任:浏览器必须信任该证书并访问其名称覆盖的 authority,Connection 仍认证每个请求并校验 Host/Origin,而由于接收请求的监听器提供 HTTPS,会话 cookie 带 `Secure`。默认端口仍为 3080,`--public-url` 仍是相互独立的公告。
```sh
dsh web
+dsh web --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
+dsh web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
dsh web --no-open
dsh web --patch ./extra.cordis.yml
dsh web --dump-config
dsh web --help
```
-生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`;本机启动时,只在完整 Loader 配置树结算后才用默认浏览器打开该规范宿主机 URL(配置了 `--public-url` 时即为公告根)。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会跳过浏览器交接,因为本地转发地址由 SSH 客户端或编辑器持有;宿主机 URL 仍会打印。CLI 有意不支持 `--host 0.0.0.0`,并会以用法错误退出。本机交接前会打印英文提示 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 诊断会说明原因、给出 URL 供手动访问,服务器仍继续运行。`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
+生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。配置了 `--public-url` 时打印公告地址,否则打印采用监听器协议的绑定地址 URL(默认为 `http://127.0.0.1:3080`,配置 TLS 时为 `https://`)。Host/Origin 栅栏直接接受绑定 IP;代理或 DNS authority 仍需 `--trusted-host`。启动日志与默认浏览器交接都会等待完整 Loader 配置树结算。继承的 `SSH_CONNECTION` 或 `SSH_TTY` 非空时会抑制浏览器打开,但仍打印启动 URL。浏览器交接前会打印 `dsh web: opening the default browser; pass --no-open to disable`;若操作系统交接失败,stderr 会说明原因并指向启动 URL,服务器仍继续运行。
进程关闭时,插件树最多有 5 秒完成 dispose。首次收到 `SIGINT` 或 `SIGTERM` 时会开始优雅排空:`SIGTERM` 是监督进程发出的常规停止请求,在所有运行模式下都以 0 退出;`SIGINT` 则报告 130。第二次收到信号时会立即强制退出。如果一次性运行在正常结束时已经卡在 dispose 阶段,第一次按下 `Ctrl+C` 就会直接升级为强制退出,而不会被忽略。
diff --git a/apps/cli/tests/agent-team-headless.e2e.ts b/apps/cli/tests/agent-team-headless.e2e.ts
index 45e954e40f..dd4e456252 100644
--- a/apps/cli/tests/agent-team-headless.e2e.ts
+++ b/apps/cli/tests/agent-team-headless.e2e.ts
@@ -17,7 +17,7 @@ function records(content: string): Record[] {
}
describe('dsh run with Agent Teams enabled', () => {
- it('runs two teammates, durable peer mail, dependent tasks, waiting, and final aggregation', async () => {
+ it('runs two teammates, direct peer messages, dependent tasks, waiting, and final aggregation', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-agent-team-headless-'))
try {
const home = join(cwd, '.dsh')
@@ -103,8 +103,11 @@ describe('dsh run with Agent Teams enabled', () => {
expect(root).toBeDefined()
const eventTypes = root!.map(record => record.type)
expect(eventTypes.filter(type => type === 'team/member')).toHaveLength(4)
- expect(eventTypes).toContain('team/message/queued')
- expect(eventTypes).toContain('team/message/delivered')
+ expect(eventTypes).not.toContain('team/message/queued')
+ expect(eventTypes).not.toContain('team/message/delivered')
+ const peerMessages = parsed.flatMap(log => log.filter(record => record.type === 'user/message'
+ && (record.data as { source: { kind: string } }).source.kind === 'agent-message'))
+ expect(peerMessages).toHaveLength(2)
const taskEvents = root!.filter(record => record.type === 'team/task')
expect(taskEvents.filter((record) => {
const data = record.data as { task?: { status?: string } } | undefined
diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts
index 09a0f39b1f..4ad8abce2d 100644
--- a/apps/cli/tests/built-bin.e2e.ts
+++ b/apps/cli/tests/built-bin.e2e.ts
@@ -381,7 +381,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
})
expect(wildcardHost.code).toBe(1)
expect(wildcardHost.stdout).toBe('')
- expect(wildcardHost.stderr).toContain('--host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead')
expect(wildcardHost.stderr).not.toContain('dsh web: http://')
const headlessHelp = await runBuiltBin(['headless', '--help'], {
diff --git a/apps/cli/tests/expected/dump-config-schema.json b/apps/cli/tests/expected/dump-config-schema.json
index 29f03673a0..ce57b82073 100644
--- a/apps/cli/tests/expected/dump-config-schema.json
+++ b/apps/cli/tests/expected/dump-config-schema.json
@@ -445,13 +445,6 @@
]
}
},
- {
- "not": {
- "required": [
- "preset"
- ]
- }
- },
{
"anyOf": [
{
@@ -501,13 +494,6 @@
]
}
},
- {
- "not": {
- "required": [
- "preset"
- ]
- }
- },
{
"anyOf": [
{
@@ -557,13 +543,6 @@
]
}
},
- {
- "not": {
- "required": [
- "preset"
- ]
- }
- },
{
"anyOf": [
{
@@ -599,11 +578,6 @@
"properties": {
"insert": {
"$ref": "#/$defs/entryList"
- },
- "preset": {
- "type": "string",
- "pattern": "\\S",
- "description": "Outer preset row id. The remaining patch fields apply to its current config.plugins list; a missing or unaddressable preset fails composition."
}
},
"description": "An insert appends entries, optionally inside the group identified by id. Other patches replace supplied fields; config is replaced wholesale, not deep-merged. A truthy name asserts the existing plugin name rather than renaming it. Unknown targets and non-insert patches without a nonempty id are warned and skipped."
diff --git a/apps/cli/tests/fixtures/agent-shutdown.mjs b/apps/cli/tests/fixtures/agent-shutdown.mjs
new file mode 100644
index 0000000000..22064e0e55
--- /dev/null
+++ b/apps/cli/tests/fixtures/agent-shutdown.mjs
@@ -0,0 +1,83 @@
+/** Scope-owned cancellation during shutdown of the shipped Web profile. */
+import { existsSync, writeFileSync } from 'node:fs'
+import { setImmediate } from 'node:timers/promises'
+import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
+
+export const name = 'agent-shutdown-fixture'
+export const inject = ['agents', 'llm', 'appReady', 'appExit']
+
+class Adapter extends LlmAdapter {
+ async resolveModel(provider, model) {
+ return { provider, id: model, name: model }
+ }
+
+ async *stream() {
+ throw new Error('The cancelled request must not reach the adapter.')
+ }
+}
+
+/** Hold one turn until cancellation and report the state seen by scope cleanup. */
+export function apply(ctx, config) {
+ ctx.llm.registerAdapter(['shutdown-fixture'], new Adapter())
+ const exit = ctx.appExit
+ ctx.effect(() => ctx.appReady.onReady(() => {
+ void start(ctx, config).catch(error => { console.error(error); exit(1) })
+ }))
+ if (process.platform === 'win32') {
+ const timer = setInterval(() => {
+ if (!existsSync(config.interrupt)) return
+ clearInterval(timer)
+ process.emit('SIGTERM')
+ }, 20)
+ ctx.effect(() => () => { clearInterval(timer) })
+ }
+}
+
+async function start(ctx, config) {
+ const { agent } = await ctx.agents.create({
+ sessionId: 'shutdown-fixture',
+ agentOptions: { provider: 'shutdown-fixture', model: 'shutdown-fixture' },
+ meta: { cwd: config.root },
+ })
+ const controller = new AbortController()
+ let abortHandled = 0
+ controller.signal.addEventListener('abort', () => {
+ agent.cancel({ kind: 'parent' })
+ agent.cancel({ kind: 'parent' })
+ abortHandled++
+ }, { once: true })
+ agent.ctx.effect(() => () => {
+ controller.abort()
+ controller.abort()
+ const events = agent.session.snapshotEvents()
+ writeFileSync(config.result, JSON.stringify({
+ abortHandled,
+ status: agent.status,
+ nextStep: agent.inbox.nextStep,
+ nextTurn: agent.inbox.nextTurn,
+ lastEvent: events.at(-1),
+ cancelled: events.filter(event => event.type === 'agent/inbox/spliced' && event.data.outcome === 'canceled'),
+ }))
+ })
+ const message = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
+ if (config.running) {
+ const entered = Promise.withResolvers()
+ agent.ctx.on('agent/request', async ({ signal }, next) => {
+ const aborted = Promise.withResolvers()
+ signal.addEventListener('abort', () => { aborted.resolve() }, { once: true })
+ entered.resolve()
+ await aborted.promise
+ await setImmediate()
+ return next()
+ })
+ agent.followup(message('active'))
+ await entered.promise
+ }
+ agent.send(message('pending step'), 'next-step', false)
+ agent.send(message('pending turn'), 'next-turn', false)
+ if (config.cancelFirst) {
+ controller.abort()
+ await agent.whenIdle()
+ }
+ console.log('agent-shutdown: ready')
+}
diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts
index 78d0e45813..623958ea9c 100644
--- a/apps/cli/tests/optional-bundle-transitions.e2e.ts
+++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts
@@ -185,7 +185,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code'])
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('session_trace')
expect(names(native.agent)).toContain('session_search')
- expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory'])
+ expect(names(minimal.agent)).toContain('session_search')
await turn(coded.agent, 'Read this session lineage through run_code.', [
toolCallResponse('ptc-search', 'run_code', {
code: 'console.log(await tools.session_trace({}));',
@@ -210,7 +210,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
const foreign = await agent('standard', otherWorkspace)
await turn(foreign.agent, 'Keep OPTIONAL_SEARCH_MARKER in the other workspace.', [textResponse('Other marker recorded.')])
await select(bundles.search, true)
- expect(names(live.agent)).not.toContain('session_search')
+ expect(names(live.agent)).toContain('session_search')
const searcher = await agent()
await turn(searcher.agent, 'Find the previous marker in session history.', [
toolCallResponse('search-history', 'session_search', { query: 'OPTIONAL_SEARCH_MARKER' }), textResponse('SEARCH_DONE'),
@@ -220,11 +220,8 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
expect(toolResult(searcher.agent, 'search-history')).not.toContain(foreign.agent.session.id)
expect(toolResult(searcher.agent, 'search-history')).toContain('OPTIONAL_SEARCH_MARKER')
await select(bundles.search, false)
+ expect(names(searcher.agent)).not.toContain('session_search')
expect(names((await agent()).agent)).not.toContain('session_search')
- await turn(searcher.agent, 'Search the marker again using the retained capability.', [
- toolCallResponse('search-retained', 'session_search', { query: 'OPTIONAL_SEARCH_MARKER' }), textResponse('RETAINED_SEARCH_DONE'),
- ])
- expect(toolResult(searcher.agent, 'search-retained')).toContain(seedId)
const sessionId = searcher.agent.session.id
await searcher.dispose()
handles.delete(searcher)
@@ -311,9 +308,15 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
await expect.poll(() => titleRequests(following.agent).length).toBe(1)
expect(titleRequests(following.agent)[0]?.data.titleProvider).toBe('session-title-all-prompts-llm')
await expect.poll(() => ctx.sessionTitle.get(following.agent.session)?.source.kind).toBe('provider')
+ const currentTitle = ctx.sessionTitle.get(following.agent.session)?.title
+ const firstInput = titleRequests(following.agent)[0]?.data.messages[0]?.content[0]
+ expect(firstInput?.type === 'text' && firstInput.text).toContain('Generate the session title from this JSON array')
await turn(following.agent, 'Continue the growing conversation.', [textResponse('TWO')])
await expect.poll(() => titleRequests(following.agent).length).toBe(2)
expect(titleRequests(following.agent).every(event => event.data.titleProvider === 'session-title-all-prompts-llm')).toBe(true)
+ const followingInput = titleRequests(following.agent)[1]?.data.messages[0]?.content[0]
+ expect(followingInput?.type === 'text' && followingInput.text)
+ .toContain(`Update the session title from this JSON object:\n{"currentTitle":${JSON.stringify(currentTitle)},`)
await select(bundles.titles, false)
const restored = await agent()
await turn(restored.agent, 'Restore first-prompt titles.', [textResponse('ONE')])
@@ -323,10 +326,10 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
expect(titleRequests(restored.agent)[0]?.data.titleProvider).toBe('session-title-first-prompt-llm')
})
- it('composes all five bundles while leaving minimal and host tools unchanged', async () => {
+ it('composes all five bundles as global tools across every preset', async () => {
for (const name of Object.values(bundles)) await select(name, true)
let coded: Agent | undefined
- for (const preset of ['standard', 'cordis', 'ptc']) {
+ for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) {
const owner = await agent(preset)
expect(names(owner.agent)).toEqual(expect.arrayContaining(['session_search', 'terminal_open', 'ralph']))
if (preset === 'ptc') coded = owner.agent
@@ -340,9 +343,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
])
expect(toolResult(coded, 'combined-program')).toContain(coded.session.id)
expect(toolResult(coded, 'combined-program')).toContain('dsh-badge')
- const minimal = await agent('minimal')
- expect(names(minimal.agent)).toEqual([process.platform === 'win32' ? 'pwsh' : 'bash', 'working_directory'])
- expect(names()).toEqual(['working_directory'])
+ expect(names()).toEqual(expect.arrayContaining(['session_search', 'terminal_open', 'ralph', 'working_directory']))
for (const name of [...Object.values(bundles)].reverse()) await select(name, false)
expect(names((await agent()).agent)).not.toContain('session_search')
})
diff --git a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml
index c7048d2406..290714194e 100644
--- a/apps/cli/tests/profiles/headless/team-snapshot.patch.yml
+++ b/apps/cli/tests/profiles/headless/team-snapshot.patch.yml
@@ -42,7 +42,6 @@
config:
maxMembers: 8
maxTasks: 256
- maxPendingMessagesPerMember: 64
maxMessageBytes: 65536
disposalTimeoutMs: 5000
diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml
new file mode 100644
index 0000000000..6d2a8dde06
--- /dev/null
+++ b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml
@@ -0,0 +1,36 @@
+# pi-ai Kimi route with dynamic prompt and tool updates over the shipped headless profile.
+- id: headless-startup
+ disabled: true
+
+- id: headless-runner
+ disabled: true
+
+- id: llm-deepseek
+ disabled: true
+
+- id: llm-pi-ai
+ config:
+ providers:
+ moonshotai:
+ apiKeyEnv: MOONSHOT_API_KEY
+ baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
+ streamIdleTimeoutMs: 1000
+
+- id: agent-loop
+ config:
+ agents:
+ - id: main
+ provider: moonshotai
+ model: kimi-k3
+ cwd: !!js process.cwd()
+
+- id: agent-instructions
+ disabled: true
+
+# The fixture counts every model request, so title generation stays off.
+- id: session-title-llm
+ disabled: true
+
+- insert:
+ - id: dynamic-tool-prompt-updates
+ name: '../../../../../../../packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-prompt-updates.ts'
diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
index 7a46b1dc89..6406d63753 100644
--- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
+++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
@@ -42,6 +42,7 @@ const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', im
const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.patch.yml', import.meta.url))
const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.patch.yml', import.meta.url))
const piAiDefaultsConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-defaults.patch.yml', import.meta.url))
+const piAiConversationUpdatesConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-conversation-updates.patch.yml', import.meta.url))
const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url))
const headlessSessionExpected = join(goldensDir, 'headless-profile', 'session.expected.jsonl')
const headlessReasoningExpected = join(goldensDir, 'headless-profile', 'reasoning.stderr.expected.txt')
@@ -148,6 +149,58 @@ async function deepseekDefaultsServer(
}
}
+/** Script Chat Completions responses in request order: `read`, then `snapshot_ping`, then text. */
+async function piAiConversationServer(): Promise {
+ const requests: JsonObject[] = []
+ const agentReplies = [
+ { tool_calls: [{ index: 0, id: 'call_read', type: 'function', function: { name: 'read', arguments: '{"file_path":"task.txt"}' } }] },
+ { tool_calls: [{ index: 0, id: 'call_ping', type: 'function', function: { name: 'snapshot_ping', arguments: '{}' } }] },
+ { content: 'DONE' },
+ ]
+ const server = createServer((request: IncomingMessage, response: ServerResponse) => {
+ let body = ''
+ request.setEncoding('utf8')
+ request.on('data', (chunk: string) => { body += chunk })
+ request.on('end', () => {
+ requests.push(JSON.parse(body) as JsonObject)
+ const delta = agentReplies[requests.length - 1] ?? { content: 'UNEXPECTED' }
+ const finish = 'tool_calls' in delta ? 'tool_calls' : 'stop'
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
+ response.end([
+ `data: ${JSON.stringify({ choices: [{ delta }] })}`,
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: finish }], usage: { prompt_tokens: 3, completion_tokens: 1 } })}`,
+ 'data: [DONE]',
+ '',
+ ].join('\n\n'))
+ })
+ })
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('pi-ai conversation snapshot server has no port')
+ return {
+ url: `http://127.0.0.1:${address.port}`,
+ requests,
+ paths: [],
+ close: () => new Promise(resolve => server.close(() => { resolve() })),
+ }
+}
+
+/** Project one Chat Completions request to its fixture-owned declarations and message roles, tools, and prompt markers. */
+function piAiTranscript(request: JsonObject): JsonObject {
+ const names = (tools: unknown): string[] => Array.isArray(tools)
+ ? tools.map(tool => ((tool as { function: { name: string } }).function.name))
+ : []
+ return {
+ // Profile tools change independently of this scenario; only the fixture's tool is recorded.
+ tools: names(request.tools).filter(name => name === 'snapshot_ping'),
+ messages: (request.messages as JsonObject[]).map(message => ({
+ role: message.role,
+ ...message.tools === undefined ? {} : { tools: names(message.tools) },
+ ...typeof message.content === 'string' && message.content.includes('Dynamic tool guidance') ? { guidance: true } : {},
+ })),
+ }
+}
+
function parseJsonl(content: string): JsonObject[] {
return content.split('\n')
.filter(line => line.trim().length > 0)
@@ -745,6 +798,113 @@ describe('headless stream-json snapshots', () => {
}
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
+ it('sends pi-ai Kimi prompt and tool updates in conversation history', async () => {
+ const server = await piAiConversationServer()
+ try {
+ const result = await runLoaderSmoke({
+ label: 'pi-ai Kimi conversation updates headless stream-json snapshot',
+ tempDirPrefix: 'headless-snapshot-pi-ai-conversation-updates-',
+ binScript,
+ libBinScript: binScript,
+ configPath: piAiConversationUpdatesConfigPath,
+ binArgs: [
+ piAiConversationUpdatesConfigPath,
+ 'read task.txt and follow it',
+ ],
+ tsconfigPath,
+ prepare: cwd => writeFile(join(cwd, 'task.txt'), 'Call snapshot_ping once.\n'),
+ env: {
+ MOONSHOT_API_KEY: 'snapshot-key',
+ DSH_SNAPSHOT_BASE_URL: server.url,
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+ },
+ })
+
+ expect(result.stderr).toBe('')
+ expect(parseJsonl(result.stdout).at(-1)).toMatchObject({ type: 'result', output: 'DONE' })
+ expect(server.requests.map(piAiTranscript)).toMatchInlineSnapshot(`
+ [
+ {
+ "messages": [
+ {
+ "role": "system",
+ },
+ {
+ "role": "user",
+ },
+ {
+ "role": "user",
+ },
+ ],
+ "tools": [],
+ },
+ {
+ "messages": [
+ {
+ "role": "system",
+ },
+ {
+ "role": "user",
+ },
+ {
+ "role": "user",
+ },
+ {
+ "role": "assistant",
+ },
+ {
+ "role": "tool",
+ },
+ {
+ "guidance": true,
+ "role": "system",
+ },
+ {
+ "role": "system",
+ "tools": [
+ "snapshot_ping",
+ ],
+ },
+ ],
+ "tools": [],
+ },
+ {
+ "messages": [
+ {
+ "role": "system",
+ },
+ {
+ "role": "user",
+ },
+ {
+ "role": "user",
+ },
+ {
+ "role": "assistant",
+ },
+ {
+ "role": "tool",
+ },
+ {
+ "guidance": true,
+ "role": "system",
+ },
+ {
+ "role": "assistant",
+ },
+ {
+ "role": "tool",
+ },
+ ],
+ "tools": [],
+ },
+ ]
+ `)
+ } finally {
+ await server.close()
+ }
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => {
let projection: unknown
const result = await runLoaderSmoke({
@@ -804,14 +964,14 @@ describe('headless stream-json snapshots', () => {
if (typeof message !== 'object' || message === null || Array.isArray(message)) return false
const source = (message as JsonObject).source
return typeof source === 'object' && source !== null && !Array.isArray(source)
- && (source as JsonObject).kind === 'team-message'
+ && (source as JsonObject).kind === 'agent-message'
})
})
const steeredMessageIndex = implementerRows.findIndex((row) => {
if (row.type !== 'user/message') return false
const source = (row.data as JsonObject).source
return typeof source === 'object' && source !== null && !Array.isArray(source)
- && (source as JsonObject).kind === 'team-message'
+ && (source as JsonObject).kind === 'agent-message'
})
const openTurnStart = implementerRows.findLastIndex((row, index) => (
index < steeredMessageIndex && row.type === 'turn/start'
@@ -874,7 +1034,7 @@ describe('headless stream-json snapshots', () => {
"researcher",
],
"checkedRoster": true,
- "deliveredMessages": 2,
+ "deliveredMessages": 0,
"identityReminders": [
"
You are teammate "implementer".
@@ -892,7 +1052,7 @@ describe('headless stream-json snapshots', () => {
",
],
"memberEdges": 4,
- "queuedMessages": 2,
+ "queuedMessages": 0,
"sessions": 4,
"steerEvidence": {
"completedAfterMessage": true,
diff --git a/apps/cli/tests/profiles/official-installer-packed.e2e.ts b/apps/cli/tests/profiles/official-installer-packed.e2e.ts
index 3062b5c5b8..e9039e8a5a 100644
--- a/apps/cli/tests/profiles/official-installer-packed.e2e.ts
+++ b/apps/cli/tests/profiles/official-installer-packed.e2e.ts
@@ -158,8 +158,8 @@ it.skipIf(!built)('installs the exported registry\'s local closure and switches
expect(step.install, JSON.stringify(step.install)).toMatchObject({ application: 'applied', enabled: true, version })
expect(step.savedVersion).toBe(version)
expect(step.installed).toMatchObject({ official: true, availability: 'profile', enabled: true, installed: true, version })
- expect(step.installed.rows.filter(row => row.preset !== undefined).map(row => row.preset).sort())
- .toEqual(['preset-cordis', 'preset-ptc', 'preset-standard'])
+ const product = step.name.replace('@deepseek-ai/dsh-subagent-', '')
+ expect(step.installed.rows.map(row => row.rowId)).toEqual([`subagent-${product}`, `tool-subagent-${product}`])
expect(step.providerEnabled).toBe(true)
expect(step.nativeArtifact).toBe(true)
// A transitive repository package the closure needs can only have come from this run's artifacts.
diff --git a/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts
index 5ed381e1d0..b596f4e943 100644
--- a/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/public-url.expected.e2e.ts
@@ -1,7 +1,8 @@
-/** Built Web-profile acceptance for the advertised public root and browser authentication behind a prefix-stripping proxy. */
+/** Built Web-profile acceptance for concrete bind addresses, the advertised public root, and browser authentication. */
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { request as httpRequest, type IncomingMessage } from 'node:http'
+import { request as httpsRequest } from 'node:https'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createInterface } from 'node:readline'
@@ -9,8 +10,10 @@ import { Readable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it } from 'vitest'
+import { probeNonLoopbackIpv4, probeZonedLoopback } from '../../../../../../scripts/test-non-loopback-address.ts'
import { startPrefixProxy } from '../../../../../../apps/web/tests/prefix-proxy.ts'
import { PROCESS_SHUTDOWN_TIMEOUT_MS } from '../../../../src/process-shutdown.ts'
+import { materializeTlsFixture } from '../../../../../../packages/host/webserver/tests/tls-fixture.ts'
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
@@ -19,6 +22,18 @@ const builtArtifactsExist = existsSync(dshBin)
/** Browser-facing mount the reference proxy serves and strips. */
const MOUNT = '/tools/dsh/'
+const NON_LOOPBACK_IPV4 = builtArtifactsExist ? await probeNonLoopbackIpv4() : undefined
+
+const ZONED_LOOPBACK = builtArtifactsExist ? await probeZonedLoopback() : undefined
+
+/**
+ * Every spelling that binds IPv6 loopback. `::0.0.0.1` is the same address as
+ * `::1`, and the zoned rows reuse the probed scope on that same address.
+ */
+const IPV6_LOOPBACKS = ZONED_LOOPBACK === undefined
+ ? ['::1', '::0.0.0.1']
+ : ['::1', '::0.0.0.1', ZONED_LOOPBACK, ZONED_LOOPBACK.replace('::1%', '::0.0.0.1%')]
+
/** One live Web child, as `execa` types it. */
type WebChild = ReturnType
@@ -155,16 +170,22 @@ function expectCleanExit(stopped: StoppedWeb): void {
}
/**
- * One HTTP request that connects to an explicit transport address while
- * presenting an explicit browser-facing authority, as a proxy would.
+ * One request to an explicit transport address with a browser-facing authority.
+ * HTTPS verifies the certificate and hostname against the supplied CA or system roots.
*/
async function request(
- dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string,
+ protocol: 'http:' | 'https:',
+ dial: string, port: number, path: string, host: string, method: 'GET' | 'POST', cookie?: string, ca?: string,
): Promise {
const { promise, resolve, reject } = Promise.withResolvers()
- const req = httpRequest({
+ const req = (protocol === 'http:' ? httpRequest : httpsRequest)({
host: dial, port, path, method,
- headers: { host, 'content-type': 'application/json', ...cookie === undefined ? {} : { cookie } },
+ ...ca === undefined ? {} : { ca },
+ headers: {
+ host, 'content-type': 'application/json',
+ ...protocol === 'http:' ? {} : { origin: `https://${host}` },
+ ...cookie === undefined ? {} : { cookie },
+ },
}, resolve)
req.once('error', reject)
req.setTimeout(10_000, () => { req.destroy(new Error('HTTP request timed out')) })
@@ -181,16 +202,45 @@ async function request(
/** Establish the browser session needed by the protected API, keeping the minted cookie's own attributes. */
async function exchange(
- dial: string, port: number, path: string, host: string,
+ protocol: 'http:' | 'https:',
+ dial: string, port: number, path: string, host: string, ca?: string,
): Promise<{ cookie: string; setCookie: string }> {
- const response = await request(dial, port, path, host, 'GET')
+ const response = await request(protocol, dial, port, path, host, 'GET', undefined, ca)
const setCookie = response.headers.get('set-cookie')
await response.arrayBuffer()
if (setCookie === null) throw new Error(`entry returned ${String(response.status)} without a browser cookie`)
return { cookie: setCookie.split(';', 1)[0]!, setCookie }
}
-describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root', () => {
+describe.skipIf(!builtArtifactsExist)('dsh Web profile bind address and advertised root', () => {
+ it('serves the default advertised URL over verified TLS with Secure browser authentication', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'dsh-web-tls-'))
+ try {
+ const tls = await materializeTlsFixture(root)
+ await withWeb(['--tls-cert', tls.certFile, '--tls-key', tls.keyFile], async (web) => {
+ expect(web.startup.origin).toBe(`https://127.0.0.1:${String(web.port)}`)
+ await expect(request('https:', '127.0.0.1', web.port, '/', web.startup.host, 'GET'))
+ .rejects.toThrow(/self-signed certificate/i)
+ const { cookie, setCookie } = await exchange(
+ 'https:', '127.0.0.1', web.port, `${web.startup.pathname}${web.startup.search}`, web.startup.host, tls.ca,
+ )
+ expect(setCookie).toContain('; Secure')
+ const index = await request('https:', '127.0.0.1', web.port, '/', web.startup.host, 'GET', cookie, tls.ca)
+ expect(index.status).toBe(200)
+ expect(await index.text()).toContain(' {
await withWeb(['--public-url', `http://gateway.example${MOUNT.slice(0, -1)}`, '--trusted-host', 'gateway.example'], async (web) => {
expect(web.startup.origin).toBe('http://gateway.example')
@@ -204,12 +254,12 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root',
{ dial: '127.0.0.1', port: web.port, prefix: '/', host: `127.0.0.1:${String(web.port)}` },
]
for (const entry of entries) {
- const { cookie } = await exchange(entry.dial, entry.port, `${entry.prefix}${web.startup.search}`, entry.host)
- const response = await request(entry.dial, entry.port, `${entry.prefix}api/session/create`, entry.host, 'POST', cookie)
+ const { cookie } = await exchange('http:', entry.dial, entry.port, `${entry.prefix}${web.startup.search}`, entry.host)
+ const response = await request('http:', entry.dial, entry.port, `${entry.prefix}api/session/create`, entry.host, 'POST', cookie)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ result: { ok: true } })
}
- const foreign = await request('127.0.0.1', proxy.port, `${MOUNT}api/session/create`, 'evil.example', 'POST')
+ const foreign = await request('http:', '127.0.0.1', proxy.port, `${MOUNT}api/session/create`, 'evil.example', 'POST')
expect(foreign.status).toBe(403)
await foreign.arrayBuffer()
} finally {
@@ -218,4 +268,89 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile advertised public root',
expectCleanExit(await web.stop())
})
})
+
+ it.skipIf(NON_LOOPBACK_IPV4 === undefined)('serves the bound address and a declared authority, refusing any other name', async () => {
+ const trustedHost = 'dsh-direct.example'
+ await withWeb(['--host', NON_LOOPBACK_IPV4!, '--trusted-host', trustedHost], async (web) => {
+ expect(web.startup.origin).toBe(`http://${NON_LOOPBACK_IPV4!}:${String(web.port)}`)
+ expect(web.startup.pathname).toBe('/')
+ const token = web.startup.searchParams.get('token')
+ expect(token).not.toBeNull()
+ const authority = `${NON_LOOPBACK_IPV4!}:${String(web.port)}`
+ const { cookie, setCookie } = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, authority)
+ // The carrier is plain HTTP, where a browser never returns a Secure cookie.
+ expect(setCookie).not.toContain('; Secure')
+ const authenticated = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', authority, 'POST', cookie)
+ expect(authenticated.status).toBe(200)
+ expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
+
+ // A declared authority is admitted without being advertised; any other
+ // name for the same socket is a rebound Host.
+ const declared = `${trustedHost}:${String(web.port)}`
+ const declaredSession = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, declared)
+ const viaDeclared = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', declared, 'POST', declaredSession.cookie)
+ expect(viaDeclared.status).toBe(200)
+ await viaDeclared.arrayBuffer()
+ const rebound = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', `evil.example:${String(web.port)}`, 'POST', cookie)
+ expect(rebound.status).toBe(403)
+ await rebound.arrayBuffer()
+
+ const stopped = await web.stop()
+ expectCleanExit(stopped)
+ expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`)
+ expect(stopped.stderr).not.toContain(token!)
+ })
+ })
+
+
+ it.skipIf(NON_LOOPBACK_IPV4 === undefined)('warns about the plain-HTTP bind even when an HTTPS root is advertised', async () => {
+ await withWeb(['--host', NON_LOOPBACK_IPV4!, '--public-url', 'https://app.example/', '--trusted-host', 'app.example'], async (web) => {
+ expect(web.startup.origin).toBe('https://app.example')
+ expect(web.startup.host).toBe('app.example')
+ const token = web.startup.searchParams.get('token')
+ expect(token).not.toBeNull()
+ const { cookie } = await exchange('http:', NON_LOOPBACK_IPV4!, web.port, `/${web.startup.search}`, 'app.example')
+ const authenticated = await request('http:', NON_LOOPBACK_IPV4!, web.port, '/api/session/create', 'app.example', 'POST', cookie)
+ expect(authenticated.status).toBe(200)
+ expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
+
+ const stopped = await web.stop()
+ expectCleanExit(stopped)
+ expect(stopped.stderr).toContain(`listening on ${NON_LOOPBACK_IPV4!} over plain HTTP`)
+ })
+ })
+
+ it.each([
+ '::ffff:127.0.0.1',
+ '::ffff:7f00:1',
+ ])('prints an IPv4 root for mapped loopback %s and serves it', async (host) => {
+ await withWeb(['--host', host], async (web) => {
+ expect(web.startup.origin).toBe(`http://127.0.0.1:${String(web.port)}`)
+ const { cookie } = await exchange('http:', web.startup.hostname, web.port, `/${web.startup.search}`, web.startup.host)
+ const authenticated = await request(
+ 'http:', web.startup.hostname, web.port, '/api/session/create', web.startup.host, 'POST', cookie,
+ )
+ expect(authenticated.status).toBe(200)
+ expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
+
+ const stopped = await web.stop()
+ expectCleanExit(stopped)
+ expect(stopped.stderr).not.toContain('plain HTTP')
+ })
+ })
+
+ it.each(IPV6_LOOPBACKS)('prints the IPv6 loopback root for %s and serves it', async (host) => {
+ await withWeb(['--host', host], async (web) => {
+ expect(web.startup.origin).toBe(`http://[::1]:${String(web.port)}`)
+ const authority = `[::1]:${String(web.port)}`
+ const { cookie } = await exchange('http:', '::1', web.port, `/${web.startup.search}`, authority)
+ const authenticated = await request('http:', '::1', web.port, '/api/session/create', authority, 'POST', cookie)
+ expect(authenticated.status).toBe(200)
+ expect(await authenticated.json()).toMatchObject({ result: { ok: true } })
+
+ const stopped = await web.stop()
+ expectCleanExit(stopped)
+ expect(stopped.stderr).not.toContain('plain HTTP')
+ })
+ })
})
diff --git a/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts
index fa7d64a0e9..115c163d24 100644
--- a/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts
@@ -296,7 +296,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(result.stderr).toContain('Failed plugins (1):')
expect(result.stderr).toContain(' webserver (required)\n Package: @deepseek-ai/dsh-host-webserver')
expect(result.stderr).toContain('Plugins waiting for services (')
- expect(result.stderr).toMatch(/connection \(required\) +webRuntime/u)
+ expect(result.stderr).toMatch(/web-runtime \(required\) +webServer/u)
expect(result.stderr).toContain('at Server.setupListenHandle')
const summary = result.stderr.split(/\n\n(?:Full diagnostics:|dsh: warning:)/u)[0]!
expect(summary.match(/EADDRINUSE/gu)).toHaveLength(1)
@@ -319,7 +319,7 @@ describe.skipIf(!builtArtifactsExist)('dsh Web profile best-effort startup', ()
expect(report).toContain('configurationPath:')
expect(report).toContain("code: 'EADDRINUSE'")
expect(report).toContain(`port: ${String(address.port)}`)
- expect(report).toContain("module: '@deepseek-ai/dsh-client-connection'")
+ expect(report).toContain("module: '@deepseek-ai/dsh-web-app'")
expect(report).toContain('at auditStartupEntries')
} finally {
await new Promise((resolve, reject) => {
diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
index b1f89c11d1..64ab162454 100644
--- a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
+++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts
@@ -59,8 +59,13 @@ function fixture() {
' }, 20)',
' ctx.effect(() => () => { clearInterval(timer) })',
' ctx.inject(["webServer", "connection"], scope => {',
- ' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
- ' scope.effect(() => () => { rmSync(config.url, { force: true }) })',
+ ' let disposed = false',
+ ' scope.effect(() => () => { disposed = true; rmSync(config.url, { force: true }) })',
+ // A recovery URL is a readiness signal; sibling HMR work must settle first.
+ ' void scope.get("loader").await().then(() => {',
+ ' if (disposed || !scope.get("webServer") || !scope.get("connection")) return',
+ ' writeFileSync(config.url, scope.connection.authenticatedUrl(`http://127.0.0.1:${scope.webServer.port}`))',
+ ' })',
' })',
'}',
'',
@@ -107,8 +112,8 @@ function fixture() {
return { root, home, events, diagnostics, serverUrl, states, observer, stop, patch, watcher, render, url, config, witness, target }
}
-function start(f: ReturnType, extra: string[] = []) {
- const child = execa(process.execPath, [bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
+function start(f: ReturnType, extra: string[] = [], nodeArgs: string[] = []) {
+ const child = execa(process.execPath, [...nodeArgs, bin, '--profile', 'web', '--patch', f.watcher, ...extra, '--no-open', '--port', '0'], {
cwd: f.root,
env: { ...process.env, DSH_HOME: f.home, DSH_AGENTS_HOME: join(f.root, '.agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-matrix-no-call', NODE_NO_WARNINGS: '1' },
input: '', reject: false, timeout: 110_000, killSignal: 'SIGKILL',
@@ -167,6 +172,64 @@ function exit(result: { timedOut: boolean; signal?: string | undefined; exitCode
}
describe.skipIf(!built)('Web process failure matrix', () => {
+ it('exits on unreadable TLS material without opening a listener', async () => {
+ const f = fixture()
+ const missingCert = join(f.root, 'missing-cert.pem')
+ const listenerProbe = join(f.root, 'listen-probe.mjs')
+ const listening = join(f.root, 'listening')
+ writeFileSync(listenerProbe, [
+ "import { Server } from 'node:net'",
+ "import { appendFileSync } from 'node:fs'",
+ 'const listen = Server.prototype.listen',
+ 'Server.prototype.listen = function (...args) {',
+ ` this.once('listening', () => appendFileSync(${JSON.stringify(listening)}, 'listening\\n'))`,
+ ' return Reflect.apply(listen, this, args)',
+ '}',
+ '',
+ ].join('\n'))
+ const app = start(f, ['--tls-cert', missingCert, '--tls-key', missingCert], ['--import', pathToFileURL(listenerProbe).href])
+ try {
+ const result = await app.child
+ exit(result, 1)
+ expect(result.stderr).toContain(missingCert)
+ expect(existsSync(listening)).toBe(false)
+ } finally {
+ exit(await app.close(), 1)
+ }
+ })
+
+ it('rejects a non-loopback zone bind through the required Web runtime before readiness', async () => {
+ const f = fixture()
+ const host = 'fe80::1%dsh-test'
+ const network = join(f.root, 'zone-listen.mjs')
+ // Mock only the OS bind: the real WebServer retains its configured zone,
+ // and the shipped runtime row, Loader dependencies, and boot audit stay real.
+ writeFileSync(network, [
+ "import { Server } from 'node:net'",
+ 'const listen = Server.prototype.listen',
+ 'Server.prototype.listen = function (...args) {',
+ ` if (args[1] === ${JSON.stringify(host)}) {`,
+ ' args[1] = "127.0.0.1"',
+ ' }',
+ ' return Reflect.apply(listen, this, args)',
+ '}',
+ '',
+ ].join('\n'))
+ const app = start(f, ['--host', host], ['--import', pathToFileURL(network).href])
+ try {
+ const result = await app.child
+ exit(result, 1)
+ expect(result.stderr).toContain('dsh: startup failed: 1 required plugin did not activate')
+ expect(result.stderr).toContain(' web-runtime (required)\n Package: @deepseek-ai/dsh-web-app')
+ expect(result.stderr).toContain(`bind address "${host}" carries an interface zone id`)
+ expect(result.stderr).toContain('pass --public-url')
+ expect(result.stdout).not.toContain('dsh web: http')
+ expect(app.events()).toBe('witness apply 0\nwitness dispose 0\n')
+ } finally {
+ exit(await app.close(), 1)
+ }
+ })
+
for (const required of [false, true]) {
const id = required ? 'acp' : 'matrix-optional'
it.each(failures)(`${required ? 'required' : 'optional'} startup %s`, async (failure, diagnostic) => {
@@ -408,7 +471,7 @@ export function apply(ctx, config) {
// Entry-level injection requirements are captured when the fiber is created.
writeFileSync(f.patch, f.render('matrix-optional') + `- id: ${id}\n disabled: true\n`)
await app.wait(() => app.state(id) === FiberState.DISPOSED)
- const inject = id === 'connection' ? ['webRuntime', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
+ const inject = id === 'connection' ? ['webStartup', 'matrixMissingWebDependency'] : ['matrixMissingWebDependency']
const pending = f.render('matrix-optional', undefined, 2) + `- id: ${id}\n inject: ${JSON.stringify(inject)}\n`
writeFileSync(f.patch, pending)
await app.wait(() => app.state(id) === FiberState.PENDING && app.events().includes('witness apply 2\n'))
diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts
index 6fa2861ce0..db0d8e26e3 100644
--- a/apps/cli/tests/web-agent-presets.e2e.ts
+++ b/apps/cli/tests/web-agent-presets.e2e.ts
@@ -13,15 +13,12 @@ import {
loadProfile,
PluginPackages,
type Profile,
- type ProfilePatch,
} from '@deepseek-ai/dsh-app-boot'
import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from 'vitest'
-import { dump, load } from 'js-yaml'
-import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import { bundlePatchPaths, composeEntries } from '@deepseek-ai/dsh-app-boot'
import { createPluginProfile } from '../../desktop/src/project-manager.ts'
/** Profile entry ids whose volatile fields these scenarios edit through Settings. */
@@ -63,7 +60,7 @@ const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell
*/
async function bootWeb(
profileHome: string,
- extra: ProfilePatch[] = [],
+ extra: PatchOptions[] = [],
profilePackages: readonly string[] = [],
profileBundles?: readonly string[],
surface: 'web' | 'desktop' | 'spec' = 'spec',
@@ -532,165 +529,74 @@ describe('the shipped Web composition', () => {
})
})
-describe('product Bundle and user-preset intersection', () => {
- const presetIds = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const
- type Product = 'codex' | 'claude-code'
- type PresetId = typeof presetIds[number]
+describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surface) => {
+ const products = ['codex', 'claude-code'] as const
+ type Product = typeof products[number]
+ const packageDir = (product: Product): string => (product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR)
+ const toolName = (product: Product): string => `subagent_${product.replaceAll('-', '_')}`
- async function bootProducts(installed: readonly Product[]): Promise {
- const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-'))
- const definitions: import('@deepseek-ai/cordis-plugin-loader').EntryOptions[] = []
- const standardConfig = composeEntries([webPatches('test')]).find(row => row.id === 'preset-standard')!.config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition
- const standard = dump(standardConfig.plugins, { schema: entryListSchema })
- for (const id of presetIds) {
- const plugins = load(standard, { schema: entryListSchema }) as import('@deepseek-ai/cordis-plugin-loader').EntryOptions[]
- // User presets declare their own delegation rows; provider bundles target only the three full shipped presets.
- if (id === 'products-codex' || id === 'products-both') {
- plugins.push({ id: 'custom-tool-subagent-codex', name: '@deepseek-ai/dsh-tool-subagent', config: {
- provider: 'codex', toolName: 'subagent_codex', maxDepth: 'provider-managed',
- } })
- }
- if (id === 'products-claude' || id === 'products-both') {
- plugins.push({ id: 'custom-tool-subagent-claude-code', name: '@deepseek-ai/dsh-tool-subagent', config: {
- provider: 'claude-code', toolName: 'subagent_claude_code', maxDepth: 'provider-managed',
- } })
- }
- definitions.push({ id: `preset-${id}`, name: '@deepseek-ai/dsh-agent-preset', config: { id, plugins } })
- }
- const packageDir = (product: Product): string => (
- product === 'codex' ? CODEX_PACKAGE_DIR : CLAUDE_CODE_PACKAGE_DIR
- )
- const packageName = (product: Product): string => (
- product === 'codex'
- ? '@deepseek-ai/dsh-subagent-codex'
- : '@deepseek-ai/dsh-subagent-claude-code'
- )
- return await bootWeb(root, [{ insert: definitions }], installed.map(packageDir), [
- '@deepseek-ai/dsh-base',
- '@deepseek-ai/dsh-web-app',
- ...installed.map(packageName),
- ])
+ async function bootSelected(installed: readonly Product[], extra: PatchOptions[] = []): Promise {
+ const root = await mkdtemp(join(tmpdir(), `dsh-${surface}-native-bundles-`))
+ onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
+ return await bootWeb(root, extra, installed.map(packageDir), [
+ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...installed.map(product => `@deepseek-ai/dsh-subagent-${product}`),
+ ], surface)
}
- it('composes the intersection of installed Bundles and enabled preset rows', async () => {
- const enabledByPreset: Record = {
- 'products-none': [],
- 'products-codex': ['codex'],
- 'products-claude': ['claude-code'],
- 'products-both': ['codex', 'claude-code'],
- }
- const scenarios: Array<{ installed: Product[]; presets: readonly PresetId[] }> = [
- { installed: [], presets: ['products-both'] },
- { installed: ['codex'], presets: ['products-both'] },
- { installed: ['claude-code'], presets: ['products-both'] },
- { installed: ['codex', 'claude-code'], presets: presetIds },
- ]
+ async function presetTools(productCtx: Context, preset: string): Promise {
+ const handle = await productCtx.agents.create({
+ sessionId: SessionId(`native-${surface}-${preset}-${randomUUID()}`),
+ setup: owner => productCtx.agentPresets.mount(owner, preset).then(() => undefined),
+ })
+ try {
+ return toolNames(productCtx, handle.agent).filter(name => /(?:subagent|custom)_(?:codex|claude_code)$/.test(name))
+ } finally { await handle.dispose() }
+ }
- for (const { installed, presets } of scenarios) {
- const productCtx = await bootProducts(installed)
+ it.each([[[]], [['codex']], [['claude-code']], [['codex', 'claude-code']]] as Array<[Product[]]>)(
+ 'adds the selected %j delegation tools to every preset as global tools', async (installed) => {
+ const productCtx = await bootSelected(installed)
const spawn = vi.spyOn(productCtx.subprocess, 'spawn')
try {
- expect(productCtx.subagents.list()
- .filter(name => name === 'codex' || name === 'claude-code')
- .sort())
+ expect(productCtx.subagents.list().filter(name => (products as readonly string[]).includes(name)).sort())
.toEqual([...installed].sort())
- for (const id of presets) {
- const handle = await productCtx.agents.create({
- sessionId: SessionId(`preset-${id}-${installed.join('-') || 'none'}-${randomUUID()}`),
- setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined),
- })
- try {
- const productTools = enabledByPreset[id]
- .filter(product => installed.includes(product))
- .map(product => product === 'codex' ? 'subagent_codex' : 'subagent_claude_code')
- .sort()
- const tools = toolNames(productCtx, handle.agent)
- expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code'))
- .toEqual(productTools)
- expect(tools).toEqual(expect.arrayContaining(['job_kill', 'job_list', 'job_output']))
- for (const productTool of productTools) {
- expect(toolParameterNames(productCtx, handle.agent, productTool)).toEqual([
- 'cwd', 'description', 'prompt',
- ])
- }
- } finally {
- await handle.dispose()
- }
+ const expected = installed.map(toolName).sort()
+ for (const preset of ['standard', 'cordis', 'ptc', 'minimal']) {
+ expect(await presetTools(productCtx, preset)).toEqual(expected)
}
+ const handle = await productCtx.agents.create({
+ sessionId: SessionId(`native-${surface}-params-${randomUUID()}`),
+ setup: owner => productCtx.agentPresets.mount(owner, 'standard').then(() => undefined),
+ })
+ try {
+ for (const product of installed) {
+ expect(toolParameterNames(productCtx, handle.agent, toolName(product))).toEqual(['cwd', 'description', 'prompt'])
+ }
+ } finally { await handle.dispose() }
expect(spawn).not.toHaveBeenCalled()
} finally {
spawn.mockRestore()
await productCtx.fiber.dispose()
}
- }
- }, 120_000)
-})
+ }, 120_000)
-describe.each(['web', 'desktop'] as const)('%s native bundle adoption', (surface) => {
- const products = ['codex', 'claude-code'] as const
- const bundleNames = products.map(product => `@deepseek-ai/dsh-subagent-${product}`)
-
- async function bootSelected(extra: ProfilePatch[]): Promise {
- const root = await mkdtemp(join(tmpdir(), `dsh-${surface}-native-adoption-`))
- onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
- return await bootWeb(root, extra, [CODEX_PACKAGE_DIR, CLAUDE_CODE_PACKAGE_DIR], [
- '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', ...bundleNames,
- ], surface)
- }
-
- it('keeps selected provider identities and later tool overrides without duplicating custom preset tools', async () => {
- const definition = composeEntries([webPatches('adoption')]).find(row => row.id === 'preset-cordis')!
- .config as import('@deepseek-ai/dsh-agent-preset-registry').PresetDefinition
- const config = { ...definition, plugins: [...definition.plugins, ...products.map(product => ({
- id: `tool-subagent-${product}`, name: '@deepseek-ai/dsh-tool-subagent', disabled: product === 'codex',
- config: { provider: product, toolName: `custom_${product.replaceAll('-', '_')}`, maxDepth: 'provider-managed' },
- }))] }
+ it('keeps provider identities and applies later Host row overrides', async () => {
const providerConfig = { model: 'adoption-model', env: { DSH_NATIVE_ADOPTION: 'preserved' } }
- const productCtx = await bootSelected([
+ const productCtx = await bootSelected(products, [
...products.map(product => ({ id: `subagent-${product}`, config: providerConfig })),
- { preset: 'preset-standard', id: 'tool-subagent-codex', disabled: true },
- { preset: 'preset-standard', id: 'tool-subagent-claude-code', config: {
+ { id: 'tool-subagent-codex', disabled: true },
+ { id: 'tool-subagent-claude-code', config: {
provider: 'claude-code', toolName: 'custom_claude_code', maxDepth: 'provider-managed',
} },
- { id: 'preset-cordis', config },
])
- const spawn = vi.spyOn(productCtx.subprocess, 'spawn')
try {
- expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([...products]))
for (const product of products) {
expect([...productCtx.loader.entries()].find(entry => entry.options.id === `subagent-${product}`)?.options.config)
.toMatchObject(providerConfig)
}
- for (const [preset, expected] of [
- ['standard', ['custom_claude_code']], ['cordis', ['custom_claude_code']],
- ['ptc', ['subagent_claude_code', 'subagent_codex']], ['minimal', []],
- ] as const) {
- const handle = await productCtx.agents.create({
- sessionId: SessionId(`adoption-${surface}-${preset}-${randomUUID()}`),
- setup: owner => productCtx.agentPresets.mount(owner, preset).then(() => undefined),
- })
- try {
- expect(toolNames(productCtx, handle.agent).filter(name => /(?:subagent|custom)_(?:codex|claude_code)/.test(name)))
- .toEqual(expected)
- } finally { await handle.dispose() }
+ for (const preset of ['standard', 'ptc', 'minimal']) {
+ expect(await presetTools(productCtx, preset)).toEqual(['custom_claude_code'])
}
- expect(spawn).not.toHaveBeenCalled()
- } finally {
- spawn.mockRestore()
- await productCtx.fiber.dispose()
- }
- }, 120_000)
-
- it('reports a redundant manual tool instead of silently replacing the bundle contribution', async () => {
- const productCtx = await bootSelected([{ preset: 'preset-standard', insert: [{
- id: 'manual-codex', name: '@deepseek-ai/dsh-tool-subagent', config: {
- provider: 'codex', toolName: 'subagent_codex', maxDepth: 'provider-managed',
- },
- }] }])
- try {
- expect((await productCtx.agentPresets.list()).find(preset => preset.id === 'standard')?.broken)
- .toContain('already registered')
- expect(productCtx.profileContext.startedBundles).toEqual(expect.arrayContaining(bundleNames))
} finally { await productCtx.fiber.dispose() }
}, 120_000)
})
diff --git a/apps/cli/tests/web-agent-shutdown.e2e.ts b/apps/cli/tests/web-agent-shutdown.e2e.ts
new file mode 100644
index 0000000000..86ecaa5c75
--- /dev/null
+++ b/apps/cli/tests/web-agent-shutdown.e2e.ts
@@ -0,0 +1,87 @@
+/** Keyless SIGTERM regression for Agent cancellation during Web teardown. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createInterface } from 'node:readline'
+import { fileURLToPath } from 'node:url'
+import { execa } from 'execa'
+import { expect, it } from 'vitest'
+import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
+
+it.each([
+ { state: 'idle', cancelFirst: false },
+ { state: 'running', cancelFirst: false },
+ { state: 'idle', cancelFirst: true },
+ { state: 'running', cancelFirst: true },
+])('preserves Agent cleanup during Web shutdown ($state, cancel first: $cancelFirst)', { retry: 0 }, async ({ state, cancelFirst }) => {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-web-agent-shutdown-'))
+ try {
+ const patch = join(root, 'shutdown.patch.yml')
+ const resultPath = join(root, 'result.json')
+ const interrupt = join(root, 'interrupt')
+ await writeFile(patch, [
+ '- id: hmr',
+ ' disabled: true',
+ '- id: session-title-llm',
+ ' disabled: true',
+ '- insert:',
+ ` - name: ${JSON.stringify(new URL('./fixtures/agent-shutdown.mjs', import.meta.url).href)}`,
+ ` config: ${JSON.stringify({ root, result: resultPath, interrupt, running: state === 'running', cancelFirst })}`,
+ '',
+ ].join('\n'))
+ const launch = resolveExampleLaunch({
+ srcBin: fileURLToPath(new URL('../src/bin.ts', import.meta.url)),
+ tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
+ configArgs: ['--profile', 'web', '--patch', patch, '--no-open', '--port', '0'],
+ env: {
+ DSH_HOME: join(root, '.dsh'),
+ DSH_AGENTS_HOME: join(root, '.agents'),
+ DSH_TELEMETRY_DISABLED: '1',
+ },
+ })
+ const child = execa(launch.command, launch.args, {
+ cwd: root, env: launch.env, stdin: 'pipe',
+ timeout: 90_000, killSignal: 'SIGKILL', reject: false,
+ })
+ const lines = createInterface({ input: child.stdout })
+ try {
+ const ready = Promise.withResolvers()
+ lines.on('line', (line) => {
+ if (line === 'agent-shutdown: ready') ready.resolve(undefined)
+ })
+ await Promise.race([
+ ready.promise,
+ child.then((result) => { throw new Error(`Web exited before readiness: ${result.stderr}`) }),
+ ])
+ if (process.platform === 'win32') await writeFile(interrupt, '')
+ else child.kill('SIGTERM')
+ const result = await child
+ expect(result.timedOut, result.stderr).toBe(false)
+ expect(result.signal, result.stderr).toBeUndefined()
+ expect(result.exitCode, result.stderr).toBe(0)
+ expect(result.stderr).not.toMatch(/dsh: fatal|projection registration is not active/u)
+ const evidence: unknown = JSON.parse(await readFile(resultPath, 'utf8'))
+ expect(evidence).toMatchObject({
+ abortHandled: 1,
+ status: 'idle',
+ nextStep: [],
+ nextTurn: [],
+ cancelled: [
+ { data: { target: 'next-step', removedCount: 1 } },
+ { data: { target: 'next-turn', removedCount: 1 } },
+ ],
+ })
+ if (state === 'running') {
+ expect(evidence).toMatchObject({
+ lastEvent: { type: 'turn/end', data: { reason: { kind: 'aborted', reason: { kind: cancelFirst ? 'parent' : 'disposed' } } } },
+ })
+ }
+ } finally {
+ lines.close()
+ child.kill('SIGKILL')
+ await child
+ }
+ } finally {
+ await rm(root, { recursive: true, force: true })
+ }
+})
diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts
index dd8f57c54c..3086755b0b 100644
--- a/apps/desktop-host/src/index.ts
+++ b/apps/desktop-host/src/index.ts
@@ -27,7 +27,7 @@ async function main(): Promise {
profile: 'desktop',
resolvedProfile: { profile, installAnchor },
patchFiles: [],
- args: ['--no-open', '--port', '0'],
+ args: ['--no-open', '--host', '127.0.0.1', '--port', '0'],
...(process.argv[5] === undefined ? {} : {
packageManager: {
command: process.execPath,
@@ -100,7 +100,7 @@ async function main(): Promise {
installPlatformSessionPublisher(ctx, (session) => {
if (process.connected) process.send?.({ type: 'platform-session', session })
})
- const url = ctx.connection.authenticatedUrl(`http://127.0.0.1:${String(ctx.webServer.port)}`)
+ const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`)
if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) })
}
diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml
index 9c95cd3111..2d82f686ea 100644
--- a/apps/desktop/README.i18n.yaml
+++ b/apps/desktop/README.i18n.yaml
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
/deepseek-harness-desktop:
- en: 0f0f6ea914eff51e
- zh: 4934245b04e9f1c0
+ en: 001b7d11c237078b
+ zh: eda6b580dc224c7f
/deepseek-harness-desktop/terminal-command:
en: bef92953c237363f
zh: d21d6effd21b8c00
@@ -42,8 +42,8 @@
en: 4cd8a8da6977928d
zh: 9f624855a2edac37
/deepseek-harness-desktop/package/release-versions:
- en: 99607dc36badb7f1
- zh: 5df47873fe8cdea8
+ en: d938302f92fc3a1a
+ zh: 29df50bc8791baa7
/deepseek-harness-desktop/package/local-macos-dmg-without-release-signing:
en: 57869a5e3885f843
zh: 7cfc01b2dcbebd78
@@ -57,20 +57,26 @@
en: f48baa680e841b0d
zh: 25ded1aeaac6dad1
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
- en: cfd5780dd26a8e46
- zh: f7a16b1ede793b7b
+ en: fb6de542360dbae7
+ zh: 0e17d7765fb10c48
/deepseek-harness-desktop/package/windows-installer-interface:
en: 1fc1db8f54475c8f
zh: dd834ed206070396
/deepseek-harness-desktop/package/windows-ev-signing:
- en: be1379e4b4703e14
- zh: 244ccd2874bb155a
+ en: 64836513eb7eda6e
+ zh: 6ebb9184a7aa51de
+/deepseek-harness-desktop/package/desktop-runtime-preparation:
+ en: 32ef30fc38462109
+ zh: 093b1b57d084f5bb
/deepseek-harness-desktop/updates:
- en: 10d58aa5bbffbd67
- zh: d9c2476e744a6537
+ en: 41d0f50eb7173342
+ zh: ce940ef3cbcc6fa5
+/deepseek-harness-desktop/updates/local-desktop-settings:
+ en: 2e57457c44c85b5f
+ zh: 5b8510a3dc4c6c9d
/deepseek-harness-desktop/updates/mandatory-update-policy:
- en: 1527dceec540649e
- zh: 543782c36f91f893
+ en: 54596bef0706d305
+ zh: 48737302dcf3c939
/deepseek-harness-desktop/updates/local-updater-qualification:
en: bc50fd8a4d96c227
zh: fb0a6ce80c31cda6
diff --git a/apps/desktop/README.md b/apps/desktop/README.md
index 57eb2cb333..c9d14c4511 100644
--- a/apps/desktop/README.md
+++ b/apps/desktop/README.md
@@ -6,6 +6,8 @@ Desktop analytics follows the [product collection policy](../../packages/client/
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it.
+Desktop binds `127.0.0.1`, the address Electron dials for the ready URL and the WebSocket credential filter. WebSocket streams use `ws:` or `wss:` according to the Host listener; credentials require matching authority and scheme.
+
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog.
@@ -199,6 +201,8 @@ Record the current dsh version as the base. A production Desktop release uses th
Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base.
+Manual Windows CI uses a separate unpublished build identifier: `auto` appends the UTC date, workflow run number and attempt (`.YYYYMMDD.run.attempt`, with `-test` for a stable base). For example, `0.2.1-alpha.1.20261008.42.2`. These numbers are not reserved release sequence numbers.
+
Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert:
```sh
@@ -352,7 +356,15 @@ On Windows x64, use the complete unsigned packaging command for local installati
pnpm run package:desktop:win:x64:unsigned
```
-The command requires `DSH_DESKTOP_APP_ID` and the normal build dependencies, including Python and Visual C++ build tools for native modules. Set `PYTHON` to the Python executable when it is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits automatic-update configuration, strips signing credentials, and creates no release completion record. It does not require EV credentials or an update origin. The signed packaging and upload commands retain their release requirements.
+The command reads `.env.windows` and requires `DSH_DESKTOP_APP_ID` and the [mandatory-update policy settings](#mandatory-update-policy), including a real HTTPS service origin. The local run and CI use Node 24; the remaining tools are the root `packageManager` version of pnpm, Git, PowerShell, tar, Python, Visual C++ Build Tools and a Windows SDK. Set `PYTHON` when Python is absent from `PATH`. It writes the installer to `.desktop-build/targets/win-x64/unsigned-artifacts/`, omits ordinary automatic-update configuration, strips signing credentials, and creates no release completion record. EV and upload credentials are unnecessary. Signed packaging and upload retain their release requirements.
+
+Run `pnpm install --frozen-lockfile`, then `pnpm run package:desktop:win:x64:unsigned --check` to validate configuration and tooling before the full command above. Packaging performs its own complete build and isolated runtime smoke without an online model. Stage logs remain under `.desktop-build/packaging-runs/`; a successful build alone does not qualify the installer. Dependency installation and runtime preparation need access to npm, GitHub release assets and nodejs.org; use the existing registry and proxy configuration when the network requires it. Caches and tool locations belong to the build machine, not tracked personal paths.
+
+The [manual Windows workflow](../../.github/workflows/windows-package.yml) selects a self-hosted Windows x64 runner with the dedicated `dsh-win-package-trial` label and calls the same unsigned command. Provision PowerShell 7 and the tools listed above before starting the runner. It requires the policy deployment and HTTPS origin; test deployments also require comma-separated login origins. These public inputs generate a temporary `.env.windows` without signing or upload credentials. `build_version` defaults to `auto`, using the CI identifier described in [Release versions](#release-versions). An explicit version uses the existing product-version validator. The resolved version appears in the run summary before packaging; manifests remain unchanged.
+
+After this workflow reaches the default branch, select it in Actions, choose the source branch, fill the inputs and click Run workflow. It has no push or PR trigger and leaves existing automatic CI unchanged. Packaging logs and successful EXE artifacts are retained for 14 days; installation, signing and publication are separate operations. Run it once from the default branch and inspect the retained logs and installer before relying on Actions packaging. Checkout cleans the runner-owned source directory, including ignored files; never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run can remain pending, and a newer dispatch replaces that pending run. Diagnostics are uploaded only after a successful clean checkout, and generated settings are removed even when preparation fails after writing them.
+
+
### Windows installer interface
@@ -423,6 +435,14 @@ pnpm run prepare:desktop
This diagnostic command is an alternative stopping point, not the first half of a two-command build. A later `package:desktop*` command repeats the official build and preparation so it cannot consume stale dsh packages, runtime files, or dsh content.
+
+
+### Desktop runtime preparation
+
+Desktop [patch policy](scripts/runtime-patch-policy.ts) explicitly classifies every workspace patch as shared, workspace-only, or runtime-only. Shared entries reuse the root patch bytes and verify their hash against the root lockfile; runtime-only entries use separate repository files without changing workspace installation. Workspace-only patches belong to build tools or dependencies already embedded in client bundles. Unclassified or stale entries, missing files, changed hashes, incompatible resolved versions, and workspace-only packages entering the runtime stop packaging. The [preparation script](scripts/prepare-runtime-patches.ts) writes only to the temporary project; pnpm applies the selected patches and rejects application failures.
+
+Desktop dependency overrides are declared separately in the patch-policy module, not inferred from patch versions. The pi-ai constraint keeps its runtime version at the version qualified by the shared patch. Desktop still resolves a fresh runtime lockfile for each build, then installs with `--frozen-lockfile`; dependency resolution across separate builds is not yet reproducible. CLI packed-installation tests and other distribution flows retain their own configuration.
+
Every package command builds the repository, packs the first-party production closures rooted at dsh and the private Desktop Host, and prepares the target Electron distribution and pnpm CLI. `prepare:dsh` installs the production graph once at build time, prepares materialized packages for electron-builder to archive under `app.asar/dsh`, removes package-manager metadata, and writes `desktop-runtime.json` with shared package versions and final file hashes. Signed macOS builds sign and verify native files before inventory generation; electron-builder excludes this already-signed tree from nested re-signing. Resource mappings explicitly include `dsh/node_modules`, which the default root-directory filter omits; the prepared runtime inventory is checked after native signing. Native executables and libraries are unpacked beside ASAR; Python, standalone Node and pnpm remain in external runtime resources. Windows packaging checks every prepared PE against its ASAR unpacked entry and byte-identical disk copy, including unsigned builds. Builder glob rules match braces in PE filenames as single-character wildcards, so a matching neighboring file may also be unpacked. Prepared runtime smoke uses the verified target descriptor rather than the build host architecture. Signed installer, notarization, installed upgrade, and target-specific native-module qualification require the release environment.
Signed macOS packaging writes `Contents/Resources/app-update.yml` while assembling the App and before code signing, including the directory build that feeds the parallel ZIP and DMG lanes. The signing hook verifies the exact feed and updater cache directory. Both lane copies and the promoted App are checked again before the release completion record is written; a missing or mismatched configuration prevents artifact promotion and therefore prevents upload.
@@ -445,6 +465,26 @@ If task teardown fails after confirmed Host exit, installation is refused and th
Confirmed Host exit without successful task teardown displays localized recovery guidance in both ordinary and mandatory update dialogs. A typed preparation cause selects that guidance in each locale; changing translated wording cannot reclassify the failure. “View technical details” is collapsed by default and exposes only exit status, signal, shutdown acknowledgement, and deadline facts, not plugin stderr. Expanding it neither retries nor authorizes installation.
+
+
+### Local desktop settings
+
+The [shell configuration decision](../../.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md) defines Host-independent ownership and the criteria for evaluating existing settings for migration.
+
+The Electron main process reads `app.getPath('userData')/desktop/settings.json` once at startup, independently of Host and Cordis configuration. The default packaged paths are `%APPDATA%\@deepseek-ai\dsh-desktop\desktop\settings.json` on Windows and `~/Library/Application Support/@deepseek-ai/dsh-desktop/desktop/settings.json` on macOS. Development uses the `userData` path printed by its launcher. `DSH_HOME` does not relocate this file.
+
+On first launch, including after an upgrade, Desktop creates this document if it is absent:
+
+```json
+{
+ "updates": {
+ "allowTestAuthPopupWindow": false
+ }
+}
+```
+
+Existing documents are never overwritten; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules).
+
### Mandatory update policy
@@ -466,7 +506,9 @@ Packaging reads `.env.windows` or `.env.macos`: `DSH_DESKTOP_AUTO_UPDATE_ENV=tes
Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, DSH_CLIENT_VERSION, bundled dsh version, current locale and UTC offset, an empty bundle ID, and fixed Nightly. It uses no business login credentials or installation ID.
-With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually.
+With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login only when the local `updates.allowTestAuthPopupWindow` setting is `true`. It defaults to `false`, suppressing the entire authentication dialog flow during startup, manual update checks, mandatory-update refreshes, and deferred or repeated failures. Test gateway cookies are process-local. With popups disabled at startup, a gateway requiring authentication cannot provide new mandatory-update decisions during that process; enable the setting, restart and sign in to obtain them. Policy requests still enforce gateway authentication, and an authentication failure never clears a known mandatory block. Ordinary updates and product account authentication remain independent. Production keeps its anonymous policy requests regardless of this setting.
+
+When permitted, login is offered during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually.
Login and policy requests share an in-memory Session, separate from product windows and the updater; restarting requires a new login. Closing cancels login, and navigation failure provides localized retry guidance. Returning to the service triggers a fresh policy query; a redirect, cookie, or HTTP 422 is not a valid policy decision. Cancellation, expiry, and invalid responses retain any known mandatory block. Fixed login outcomes appear in process diagnostics and the optional update journal; cookies, OAuth parameters, and remote error text are not recorded by the login controller. Live Harness gateway/API integration and macOS login qualification remain unverified.
diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md
index d8256885dc..d5c52ebc1b 100644
--- a/apps/desktop/README.zh.md
+++ b/apps/desktop/README.zh.md
@@ -6,6 +6,8 @@
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。
+Desktop 绑定 `127.0.0.1`,即 Electron 为就绪 URL 与 WebSocket 凭据过滤器拨号的地址。WebSocket 流按 Host 监听器使用 `ws:` 或 `wss:`;附加凭据要求 authority 与 scheme 均匹配。
+
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。
@@ -201,6 +203,8 @@ Desktop 在 Host 启动后、打开工作区前检查模型 API Key 引用是否
日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。
+Windows 手动 CI 使用独立的未发布构建标识:`auto` 追加 UTC 日期、工作流运行编号和尝试次数(`.YYYYMMDD.run.attempt`,稳定基础版本使用 `-test`)。例如 `0.2.1-alpha.1.20261008.42.2`。这些编号不是预留的发布序号。
+
把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动:
```sh
@@ -354,7 +358,15 @@ Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置
pnpm run package:desktop:win:x64:unsigned
```
-该命令要求设置 `DSH_DESKTOP_APP_ID` 并具备常规构建依赖,包括编译原生模块所需的 Python 和 Visual C++ 构建工具。Python 不在 `PATH` 中时,将 `PYTHON` 设置为其可执行文件路径。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略自动更新配置,清除签名凭据,且不生成发布完成记录。它不需要 EV 凭据或更新源地址。签名打包和上传命令仍遵循正式发布要求。
+该命令读取 `.env.windows`,要求设置 `DSH_DESKTOP_APP_ID` 和[强制更新策略配置](#mandatory-update-policy),包括真实的 HTTPS 服务 origin。本机验收和 CI 使用 Node 24;其他工具为根 `packageManager` 固定版本的 pnpm、Git、PowerShell、tar、Python、Visual C++ Build Tools 和 Windows SDK。Python 不在 `PATH` 中时设置 `PYTHON`。命令将安装包写入 `.desktop-build/targets/win-x64/unsigned-artifacts/`,省略普通自动更新配置,清除签名凭据,且不生成发布完成记录。无需 EV 或上传凭据。签名打包和上传仍遵循正式发布要求。
+
+先运行 `pnpm install --frozen-lockfile`,再运行 `pnpm run package:desktop:win:x64:unsigned --check` 校验配置和工具,最后执行上面的完整命令。打包自行执行完整构建和隔离的运行时 smoke,不调用在线模型。阶段日志保留在 `.desktop-build/packaging-runs/`;仅构建成功不代表安装包验证通过。依赖安装和运行时准备需要访问 npm、GitHub release 资源和 nodejs.org;网络需要时使用现有 registry 和代理配置。缓存与工具位置属于构建机器配置,不在跟踪文件中固定个人路径。
+
+[Windows 手动工作流](../../.github/workflows/windows-package.yml) 选择带有专用标签 `dsh-win-package-trial` 的自托管 Windows x64 runner,并调用同一个未签名命令。启动 runner 前需准备 PowerShell 7 和上述工具。必须填写策略部署环境和 HTTPS origin;测试部署还需填写逗号分隔的登录 origin。这些公开输入生成临时 `.env.windows`,不包含签名或上传凭据。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中说明的 CI 标识。显式版本复用现有产品版本校验器。解析后的版本在打包前显示于运行摘要,manifest 保持不变。
+
+工作流进入默认分支后,在 Actions 中选择它,选择源码分支、填写输入并点击 Run workflow。它不监听 push 或 PR,不改变现有自动 CI。打包日志和成功生成的 EXE 产物保留 14 天;安装、签名和发布是独立操作。依赖 Actions 打包前,先从默认分支运行一次,并检查保留的日志和安装包。Checkout 会清理 runner 专用源码目录,包括被忽略的文件;不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新的手动触发会替换该待运行任务。仅在成功完成干净检出后上传诊断日志;即使配置准备在写入后失败,也会清理生成的配置。
+
+
### Windows 安装界面
@@ -425,6 +437,14 @@ pnpm run prepare:desktop
这条诊断命令是另一种停止位置,并非两条命令构建流程的前半段。之后执行 `package:desktop*` 时仍会重新完成正式构建与准备,避免使用陈旧的 dsh 包、运行时文件或 dsh 内容。
+
+
+### Desktop 运行时准备
+
+Desktop [补丁策略](scripts/runtime-patch-policy.ts) 将每份工作区补丁明确归为共享、仅工作区或仅运行时。共享项复用根目录补丁字节,并对照根锁文件校验 hash;仅运行时项使用仓库内的独立文件,不改变工作区安装。仅工作区补丁用于构建工具或已嵌入客户端 bundle 的依赖。未分类或过期的条目、文件缺失、hash 改变、解析版本不兼容,以及仅工作区包进入运行时,都会阻止打包。[准备脚本](scripts/prepare-runtime-patches.ts) 只写入临时项目;pnpm 负责应用选中的补丁,应用失败时会报错。
+
+Desktop 依赖 overrides 在补丁策略模块中单独声明,不从补丁版本推导。pi-ai 约束使运行时版本保持在共享补丁已验证的版本。Desktop 仍会在每次构建时重新解析运行时锁文件,再通过 `--frozen-lockfile` 安装;不同构建之间的依赖解析尚不保证可复现。CLI 打包安装测试及其他交付流程保留各自的配置。
+
每条打包命令都会构建仓库,打包以 dsh 和私有 Desktop Host 为根的第一方生产依赖闭包,并准备目标专用的 Electron 分发包与 pnpm CLI。`prepare:dsh` 在构建时安装一次生产依赖图,准备物化包供 electron-builder 归档到 `app.asar/dsh`,移除包管理器元数据,并生成包含共享包版本和最终文件哈希的 `desktop-runtime.json`。macOS 签名构建先签名并验证原生文件,再生成清单;electron-builder 不对已签名的此目录重复进行嵌套签名。资源映射明确包含默认根目录过滤器会忽略的 `dsh/node_modules`;准备完成的运行时清单在原生签名后检查。原生可执行文件及库解包到 ASAR 旁;Python、独立 Node 和 pnpm 保留在外部 runtime 资源中。Windows 打包逐项检查准备好的 PE,确认其 ASAR 条目已标记为解包,且磁盘副本字节一致;未签名构建也执行此检查。Builder glob 规则用单字符通配符匹配 PE 文件名中的花括号,因此同目录中名称匹配的文件也可能被解包。准备好的运行时 smoke 沿用已验证的目标描述符,不使用构建宿主的架构。签名安装包、公证、已安装应用升级和各目标原生模块的验收需要发布环境。
macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/app-update.yml`,供并行 ZIP 与 DMG 路线使用的目录构建也执行此操作。签名钩子验证准确的更新源和 updater 缓存目录。写入发布完成记录前,流程会再次检查两条路线的副本和最终移入的 App;配置缺失或不匹配会阻止移入产物,因而也会阻止上传。
@@ -447,6 +467,26 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
已确认 Host 退出但任务未成功收尾时,常规与强更弹窗均展示本地化恢复提示。两种语言都根据类型化的准备失败原因选择提示,翻译文案变化不会改变失败分类。“查看技术详情”默认折叠,仅展示退出状态、信号、关闭确认和截止时间事实,不展示插件 stderr。展开详情既不重试,也不授权安装。
+
+
+### 本地桌面设置
+
+[壳层配置决策](../../.agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.zh.md)说明独立于 Host 的配置归属,以及评估现有设置迁移的标准。
+
+Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/settings.json`,独立于 Host 和 Cordis 配置。打包应用默认路径为 Windows 的 `%APPDATA%\@deepseek-ai\dsh-desktop\desktop\settings.json` 和 macOS 的 `~/Library/Application Support/@deepseek-ai/dsh-desktop/desktop/settings.json`。开发模式使用启动器打印的 `userData` 路径。`DSH_HOME` 不改变此文件的位置。
+
+首次启动(包括升级后首次启动)时,Desktop 会在文件缺失时创建以下内容:
+
+```json
+{
+ "updates": {
+ "allowTestAuthPopupWindow": false
+ }
+}
+```
+
+已有文件不会被覆盖;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。
+
### 强制更新策略
@@ -468,7 +508,9 @@ Windows 下载完成后的更新确认说明应用会在安装期间关闭、完
时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、DSH_CLIENT_VERSION、内置 dsh 版本、当前语言与 UTC 偏移、空 bundle ID 和固定 Nightly。不使用业务登录凭据或安装 ID。
-启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应会在用户主动检查和打包应用首次启动检查时提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。
+启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应仅在本地 `updates.allowTestAuthPopupWindow` 为 `true` 时提供登录入口。该字段默认为 `false`,禁止启动、手动检查更新、强更刷新以及延迟或重复失败触发的整个鉴权弹窗流程。测试网关 Cookie 仅在当前进程有效。启动时关闭弹窗后,需要鉴权的网关无法在本次进程中提供新的强更决策;需开启设置、重启并登录后才能获取。策略请求仍需网关鉴权,鉴权失败不会解除已知强更阻塞。常规更新和产品账号鉴权保持独立。正式环境始终使用原有匿名策略请求,不受此设置影响。
+
+允许弹窗时,用户主动检查和打包应用首次启动检查会提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index ba7d0844cc..8ea4dbf43b 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -60,10 +60,10 @@
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.20.1",
"@types/semver": "^7.8.0",
- "app-builder-lib": "26.15.3",
+ "app-builder-lib": "26.17.0",
"cos-nodejs-sdk-v5": "3.0.0",
- "electron": "^44.0.0",
- "electron-builder": "^26.15.3",
+ "electron": "^44.7.0",
+ "electron-builder": "^26.17.0",
"extract-zip": "^2.0.1",
"js-yaml": "^4.3.2",
"koffi": "3.1.1",
diff --git a/apps/desktop/scripts/desktop-build-version.mjs b/apps/desktop/scripts/desktop-build-version.mjs
index e2152ce0d3..411b5238fb 100644
--- a/apps/desktop/scripts/desktop-build-version.mjs
+++ b/apps/desktop/scripts/desktop-build-version.mjs
@@ -2,6 +2,9 @@
* Resolve the version one build publishes, which is not always the version the
* repository declares.
*
+ * Manual Windows CI uses UTC date, workflow run number and attempt as
+ * `.YYYYMMDD.run.attempt`; these identify unpublished builds, not release sequences.
+ *
* A production release publishes the version in the manifests, aligned with the
* `dsh` npm package. A test build publishes a version that appends a date and a
* sequence number, so one test feed can carry several builds of a single
@@ -71,7 +74,8 @@ export function validateDesktopBuildVersion(buildVersion, productVersion) {
&& required.every((field, index) => build.prerelease[index] === field)
if (!extendsProduct) {
throw new Error(`desktop build version: ${buildVersion} must extend ${productVersion} as ${
- desktopBuildVersionPrefix(productVersion)}.`)
+ desktopBuildVersionPrefix(productVersion)}. or ${
+ desktopBuildVersionPrefix(productVersion)}..`)
}
return build.version
}
diff --git a/apps/desktop/scripts/prepare-dsh.ts b/apps/desktop/scripts/prepare-dsh.ts
index 4e03f763cb..e40cac98c4 100644
--- a/apps/desktop/scripts/prepare-dsh.ts
+++ b/apps/desktop/scripts/prepare-dsh.ts
@@ -20,6 +20,7 @@ import {
import { smokePrimaryRuntime } from './prepare-primary-runtime.ts'
import { smokePreparedRuntime } from './smoke-prepared-runtime.ts'
import { prepareRuntimeManifests } from './prepare-runtime-manifests.ts'
+import { prepareRuntimePatches, verifyRuntimePatches } from './prepare-runtime-patches.ts'
import { writeDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts'
import {
resolveDesktopAppId,
@@ -117,12 +118,14 @@ async function main(): Promise {
mkdirSync(STORE_ROOT, { recursive: true })
})
const release = desktopRelease()
- await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => {
+ const patches = await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:stage-packages', async () => {
copyFileSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGE_SET_FILE), join(BUILD_ROOT, DESKTOP_PACKAGE_SET_FILE))
cpSync(join(PACKAGE_SET_ROOT, DESKTOP_PACKAGES_DIR), join(BUILD_ROOT, DESKTOP_PACKAGES_DIR), { recursive: true })
createRuntimeProjectMetadata(BUILD_ROOT, release)
+ return prepareRuntimePatches(BUILD_ROOT, resolve(APP_ROOT, '..', '..'))
})
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:lockfile', () => runPnpm(['install', '--lockfile-only']))
+ verifyRuntimePatches(BUILD_ROOT, patches)
verifyDesktopCoreLockfile(
readFileSync(join(BUILD_ROOT, 'pnpm-lock.yaml'), 'utf8'),
readDesktopCorePackageSet(BUILD_ROOT, release.version),
diff --git a/apps/desktop/scripts/prepare-runtime-patches.ts b/apps/desktop/scripts/prepare-runtime-patches.ts
new file mode 100644
index 0000000000..83ebbbe457
--- /dev/null
+++ b/apps/desktop/scripts/prepare-runtime-patches.ts
@@ -0,0 +1,125 @@
+/** Stage explicitly selected Desktop patches without modifying the source workspace. */
+import { createHash } from 'node:crypto'
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { isAbsolute, join, relative, resolve } from 'node:path'
+import yaml from 'js-yaml'
+import { valid } from 'semver'
+import { DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES, DESKTOP_RUNTIME_PATCHES, type DesktopRuntimePatch } from './runtime-patch-policy.ts'
+
+/** Expected patch bytes and package version for one independent installation. */
+export interface PreparedRuntimePatch {
+ spec: string
+ name: string
+ version: string
+ path: string
+ hash: string
+}
+
+/** Selected bytes and dependencies excluded from the Desktop runtime. */
+export interface PreparedRuntimePatches {
+ patches: PreparedRuntimePatch[]
+ workspaceOnly: string[]
+}
+
+function patchIdentity(spec: string): { name: string; version: string } {
+ const separator = spec.lastIndexOf('@')
+ const name = spec.slice(0, separator)
+ const version = spec.slice(separator + 1)
+ if (separator <= 0 || valid(version) !== version) throw new Error(`desktop patches: expected an exact package version: ${spec}`)
+ return { name, version }
+}
+
+function patchHash(bytes: Buffer): string {
+ return createHash('sha256').update(bytes).digest('hex')
+}
+
+/**
+ * Stage selected patches and explicit overrides before Desktop resolves its temporary lockfile.
+ * Every root patch must be classified; shared bytes must match the root lockfile's pnpm 11 hash.
+ * @param projectRoot Disposable project containing pnpm-workspace.yaml, outside the repository root itself.
+ * @param repositoryRoot Source repository; its files remain read-only.
+ * @param policy Desktop patch decisions, including optional runtime-only files relative to the repository.
+ * @param overrides Explicit Desktop dependency constraints; patch versions never generate overrides.
+ * @returns Expected patch identities and hashes for lockfile and staged-file verification.
+ */
+export function prepareRuntimePatches(
+ projectRoot: string, repositoryRoot: string,
+ policy: Readonly> = DESKTOP_RUNTIME_PATCHES,
+ overrides: Readonly> = DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES,
+): PreparedRuntimePatches {
+ if (resolve(projectRoot) === resolve(repositoryRoot)) throw new Error('desktop patches: destination must not be the source workspace')
+ const source = yaml.load(readFileSync(join(repositoryRoot, 'pnpm-workspace.yaml'), 'utf8')) as {
+ patchedDependencies: Record
+ }
+ const sourceLock = yaml.load(readFileSync(join(repositoryRoot, 'pnpm-lock.yaml'), 'utf8')) as {
+ patchedDependencies: Record
+ }
+ for (const spec of Object.keys(source.patchedDependencies)) {
+ if (!Object.hasOwn(policy, spec)) throw new Error(`desktop patches: classify the workspace patch ${spec}`)
+ }
+ const selected: Array = []
+ for (const [spec, rule] of Object.entries(policy)) {
+ const identity = patchIdentity(spec)
+ if (rule.scope !== 'runtime-only' && !Object.hasOwn(source.patchedDependencies, spec)) {
+ throw new Error(`desktop patches: stale workspace patch decision ${spec}`)
+ }
+ if (rule.scope === 'workspace-only') continue
+ const sourcePath = rule.scope === 'shared' ? source.patchedDependencies[spec]! : rule.path
+ const localPath = relative(resolve(repositoryRoot), resolve(repositoryRoot, sourcePath))
+ if (isAbsolute(sourcePath) || localPath.startsWith('..') || isAbsolute(localPath)) {
+ throw new Error(`desktop patches: patch must be inside the repository: ${sourcePath}`)
+ }
+ const bytes = readFileSync(join(repositoryRoot, sourcePath))
+ const hash = patchHash(bytes)
+ if (rule.scope === 'shared' && sourceLock.patchedDependencies[spec] !== hash) {
+ throw new Error(`desktop patches: shared patch hash differs from the root lockfile: ${spec}`)
+ }
+ selected.push({ spec, ...identity, path: `patches/desktop-${selected.length}.patch`, hash, bytes })
+ }
+ const targetPath = join(projectRoot, 'pnpm-workspace.yaml')
+ const target = yaml.load(readFileSync(targetPath, 'utf8')) as Record & { overrides?: Record }
+ for (const [name, version] of Object.entries(overrides)) {
+ if (target.overrides?.[name] !== undefined && target.overrides[name] !== version) {
+ throw new Error(`desktop patches: conflicting dependency override for ${name}`)
+ }
+ }
+ mkdirSync(join(projectRoot, 'patches'), { recursive: true })
+ for (const entry of selected) writeFileSync(join(projectRoot, entry.path), entry.bytes)
+ writeFileSync(targetPath, yaml.dump({ ...target, overrides: { ...target.overrides, ...overrides },
+ patchedDependencies: Object.fromEntries(selected.map(entry => [entry.spec, entry.path])), allowUnusedPatches: false }))
+ return { patches: selected.map(({ bytes: _bytes, ...entry }) => entry),
+ workspaceOnly: Object.entries(policy).filter(([, rule]) => rule.scope === 'workspace-only').map(([spec]) => patchIdentity(spec).name) }
+}
+
+/**
+ * Reject changed patch bytes, lock hashes or resolved versions before the frozen production install.
+ * @param projectRoot Independent Desktop installation containing its resolved lockfile.
+ * @param plan Expected identities and exclusions returned by patch preparation.
+ */
+export function verifyRuntimePatches(projectRoot: string, plan: PreparedRuntimePatches): void {
+ const lock = yaml.load(readFileSync(join(projectRoot, 'pnpm-lock.yaml'), 'utf8')) as {
+ patchedDependencies?: Record
+ packages?: Record
+ }
+ for (const patch of plan.patches) {
+ if (!Object.keys(lock.packages ?? {}).some(spec => spec.startsWith(`${patch.name}@`))) {
+ throw new Error(`desktop patches: selected patch has no runtime dependency: ${patch.spec}`)
+ }
+ if (patchHash(readFileSync(join(projectRoot, patch.path))) !== patch.hash) {
+ throw new Error(`desktop patches: staged patch bytes changed: ${patch.spec}`)
+ }
+ if (lock.patchedDependencies?.[patch.spec] !== patch.hash) {
+ throw new Error(`desktop patches: lockfile patch hash mismatch: ${patch.spec}`)
+ }
+ }
+ for (const [spec, entry] of Object.entries(lock.packages ?? {})) {
+ if (plan.workspaceOnly.some(name => spec.startsWith(`${name}@`))) {
+ throw new Error(`desktop patches: workspace-only dependency entered the runtime: ${spec}; review its patch scope`)
+ }
+ const candidates = plan.patches.filter(patch => spec.startsWith(`${patch.name}@`))
+ if (candidates.length === 0) continue
+ if (!candidates.some(patch => (entry.version ?? spec.slice(patch.name.length + 1)) === patch.version)) {
+ throw new Error(`desktop patches: ${spec} has no matching patch; review its version and Desktop patch policy`)
+ }
+ }
+}
diff --git a/apps/desktop/scripts/prepare-windows-ci.d.mts b/apps/desktop/scripts/prepare-windows-ci.d.mts
new file mode 100644
index 0000000000..0fa67ecf7f
--- /dev/null
+++ b/apps/desktop/scripts/prepare-windows-ci.d.mts
@@ -0,0 +1,14 @@
+/** Public settings prepared for a manual Windows package run. */
+export interface WindowsCiSettings {
+ version: string
+ settings: string
+}
+
+/**
+ * Validate manual inputs without local credentials or release storage.
+ * @param environment Manual inputs and GitHub run identity.
+ * @param productVersion Version declared by the selected checkout.
+ * @param now Time used for the UTC date segment.
+ * @returns Validated version and public dotenv content.
+ */
+export function windowsCiSettings(environment: NodeJS.ProcessEnv, productVersion: string, now?: Date): WindowsCiSettings
diff --git a/apps/desktop/scripts/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs
new file mode 100644
index 0000000000..c86c303d41
--- /dev/null
+++ b/apps/desktop/scripts/prepare-windows-ci.mjs
@@ -0,0 +1,54 @@
+/** Prepare public Windows packaging settings and a version for a manual GitHub run. */
+import { appendFileSync, readFileSync, writeFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs'
+import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
+
+/**
+ * Validate manual inputs without reading local credentials or querying release storage.
+ * @param {NodeJS.ProcessEnv} environment Manual inputs and GitHub run identity.
+ * @param {string} productVersion Version declared by the selected checkout.
+ * @param {Date} now Time used for the UTC date segment.
+ * @returns {{ version: string, settings: string }} Validated version and public dotenv content.
+ */
+export function windowsCiSettings(environment, productVersion, now = new Date()) {
+ const deployment = environment.PACKAGE_DEPLOYMENT
+ if (deployment !== 'test' && deployment !== 'production') throw new Error('Select test or production deployment')
+ const loginOrigins = (environment.PACKAGE_LOGIN_ORIGINS ?? '').split(',').map(value => value.trim()).filter(Boolean)
+ if (deployment === 'production' && loginOrigins.length > 0) throw new Error('Production must not supply login origins')
+ const originKey = deployment === 'test' ? 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN' : 'DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN'
+ const config = deployment === 'test' ? { allowedAuthOrigins: loginOrigins } : {}
+ const policy = resolveDesktopPolicyEnvironment({
+ DSH_DESKTOP_AUTO_UPDATE_ENV: deployment,
+ [originKey]: environment.PACKAGE_POLICY_ORIGIN,
+ DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify(config),
+ })
+ const requested = environment.PACKAGE_BUILD_VERSION?.trim()
+ if (!requested) throw new Error('Build version is required; use auto or a full version')
+ let candidate = requested
+ if (requested === 'auto') {
+ for (const name of ['GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT']) {
+ if (!/^[1-9]\d*$/u.test(environment[name] ?? '')) throw new Error(`${name} must be a positive integer`)
+ }
+ const date = now.toISOString().slice(0, 10).replaceAll('-', '')
+ candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}`
+ }
+ const version = validateDesktopBuildVersion(candidate, productVersion)
+ const settings = [
+ 'DSH_DESKTOP_APP_ID=com.deepseek.harness',
+ `DSH_DESKTOP_AUTO_UPDATE_ENV=${deployment}`,
+ `${originKey}=${policy.origin}`,
+ `DSH_DESKTOP_MANDATORY_UPDATE_CONFIG=${JSON.stringify(config)}`,
+ ].join('\n') + '\n'
+ return { version, settings }
+}
+
+if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
+ const appRoot = resolve(import.meta.dirname, '..')
+ const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
+ const result = windowsCiSettings(process.env, product.version)
+ if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
+ writeFileSync(resolve(appRoot, '.env.windows'), result.settings, { flag: 'wx' })
+ appendFileSync(process.env.GITHUB_OUTPUT, `version=${result.version}\n`)
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 unsigned installer\n\nVersion: ${result.version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nDeployment: ${process.env.PACKAGE_DEPLOYMENT}\n`)
+}
diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts
new file mode 100644
index 0000000000..f688db356d
--- /dev/null
+++ b/apps/desktop/scripts/runtime-patch-policy.ts
@@ -0,0 +1,24 @@
+/** Desktop-specific selection of workspace and runtime-only dependency patches. */
+
+/** A patch shared with the workspace, excluded from runtime installation, or supplied only to Desktop. */
+export type DesktopRuntimePatch =
+ | { scope: 'shared'; reason: string }
+ | { scope: 'workspace-only'; reason: string }
+ | { scope: 'runtime-only'; path: string; reason: string }
+
+/** Every workspace patch requires an explicit Desktop decision; runtime-only entries may add or replace a patch. */
+export const DESKTOP_RUNTIME_PATCHES: Readonly> = {
+ '@earendil-works/pi-ai@1.0.2': { scope: 'shared', reason: 'Runtime providers need the streamed argument parsing fix.' },
+ '@electron/osx-sign@1.3.3': { scope: 'workspace-only', reason: 'Signing executes on the build host.' },
+ '@fortune-sheet/core@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' },
+ '@fortune-sheet/react@1.0.4': { scope: 'workspace-only', reason: 'Patched spreadsheet code is embedded in the client bundle.' },
+ '@yao-pkg/pkg@6.21.0': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' },
+ 'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
+ 'node-pty@1.2.0-beta.15': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
+}
+
+/** Reviewed Desktop dependency constraints, independent of patch selection. */
+export const DESKTOP_RUNTIME_DEPENDENCY_OVERRIDES: Readonly> = {
+ // The provider declares ^1.0.2; its patch is qualified against 1.0.2.
+ '@earendil-works/pi-ai@^1.0.2': '1.0.2',
+}
diff --git a/apps/desktop/scripts/smoke-prepared-runtime.ts b/apps/desktop/scripts/smoke-prepared-runtime.ts
index 697f52708e..e6afd574da 100644
--- a/apps/desktop/scripts/smoke-prepared-runtime.ts
+++ b/apps/desktop/scripts/smoke-prepared-runtime.ts
@@ -31,7 +31,16 @@ export async function smokePreparedRuntime(
const { stdout } = await promisify(execFile)(node, [
'--expose-internals', resolve(import.meta.dirname, '../tests/fixtures/runtime-payload-smoke.mjs'), root, resourcesRuntime,
], { timeout: 120_000, windowsHide: true,
- env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) })
+ env: desktopNodeEnvironment(node, join(resourcesRuntime, 'bin'), environment) }).catch((error: unknown) => {
+ if (error instanceof Error) {
+ if ('stdout' in error && typeof error.stdout === 'string') process.stdout.write(error.stdout)
+ if ('stderr' in error && typeof error.stderr === 'string') process.stderr.write(error.stderr)
+ if ('killed' in error && 'signal' in error) {
+ process.stderr.write(`runtime payload: killed=${String(error.killed)}, signal=${String(error.signal)}\n`)
+ }
+ }
+ throw error
+ })
process.stdout.write(stdout)
await smokeDesktopRuntime(root, node, descriptor, environment, resourcesRuntime)
} finally {
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index b4b8153b7f..38585675b1 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -51,6 +51,7 @@ import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig, type DesktopP
import { desktopClientMetadata, desktopClientVersion } from './client-metadata.ts'
import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts'
import { DesktopPolicyTestAuth } from './policy-test-auth.ts'
+import { readDesktopSettings } from './settings.ts'
import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts'
import { readDesktopRuntime } from './runtime-tree.ts'
import { DesktopBrowserGuests } from './browser-guests.ts'
@@ -313,6 +314,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
}
async function main(): Promise {
+ const desktopSettings = readDesktopSettings(app.getPath('userData'))
void pruneCrashReports(app.getPath('logs'))
const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
@@ -707,14 +709,14 @@ async function main(): Promise {
return browserGuests.release(event.sender, lease)
})
- session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => {
+ session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*', 'wss://127.0.0.1/*'] }, (details, callback) => {
if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) {
callback({})
return
}
const target = new URL(hostUrl)
const requested = new URL(details.url)
- if (requested.host !== target.host) { callback({}); return }
+ if (requested.host !== target.host || requested.protocol !== (target.protocol === 'https:' ? 'wss:' : 'ws:')) { callback({}); return }
const headers = Object.fromEntries(Object.entries(details.requestHeaders).map(([name, value]) => [name.toLowerCase(), value]))
if (headers.origin !== 'dsh-app://app') { callback({ cancel: true }); return }
callback({ requestHeaders: { ...headers, origin: target.origin, cookie: hostCookie, 'sec-fetch-site': 'same-origin' } })
@@ -875,6 +877,7 @@ async function main(): Promise {
void authenticatePolicy().catch((error: unknown) => { console.error(error) })
}
const queuePolicyAuthentication = (): void => {
+ if (!desktopSettings.updates.allowTestAuthPopupWindow) return
if (authenticationOperation !== undefined) {
policyAuth?.focus(); updateDialog.focus()
return
@@ -883,7 +886,8 @@ async function main(): Promise {
flushQueuedPolicyAuthentication()
}
const runPolicyAuthentication = async () => {
- if (policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
+ if (!desktopSettings.updates.allowTestAuthPopupWindow
+ || policyAuth === undefined || mandatoryPolicy === undefined || quitting) return undefined
const parent = mandatoryUI?.confirmationWindow ?? currentDialogWindow()
if (parent === undefined) return undefined
const consent = await updateDialog.show(parent, { type: 'info', title: locale.messages.policyLoginTitle,
@@ -905,7 +909,7 @@ async function main(): Promise {
const checkPolicyManually = async (authentication: 'immediate' | 'deferred' = 'immediate') => {
if (authenticationOperation !== undefined) return authenticatePolicy()
const policy = await mandatoryPolicy?.check('manual', true)
- if (policy?.error !== 'authentication-required') return policy
+ if (policy?.error !== 'authentication-required' || !desktopSettings.updates.allowTestAuthPopupWindow) return policy
if (authentication === 'immediate') return authenticatePolicy()
queuePolicyAuthentication()
return policy
diff --git a/apps/desktop/src/settings.ts b/apps/desktop/src/settings.ts
new file mode 100644
index 0000000000..a4bb67b3bd
--- /dev/null
+++ b/apps/desktop/src/settings.ts
@@ -0,0 +1,57 @@
+/**
+ * Local Electron shell settings, loaded once per process independently of the Host.
+ * Ownership: .agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md.
+ */
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+/** Resolved shell preferences; absent fields use the defaults without rewriting existing files. */
+export interface DesktopSettings {
+ readonly updates: {
+ readonly allowTestAuthPopupWindow: boolean
+ }
+}
+
+function record(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Read shell settings, creating the default document exclusively when missing.
+ * @param userData - Electron's userData directory, independent of DSH_HOME.
+ * @returns Validated settings, or defaults when reading, creating or validating fails; changes require a restart.
+ */
+export function readDesktopSettings(userData: string): DesktopSettings {
+ const directory = join(userData, 'desktop')
+ const path = join(directory, 'settings.json')
+ const defaults: DesktopSettings = { updates: { allowTestAuthPopupWindow: false } }
+ try {
+ let raw: string
+ try { raw = readFileSync(path, 'utf8') } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ mkdirSync(directory, { recursive: true, mode: 0o700 })
+ try { writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) {
+ // An existing document belongs to the user, including one created during initialization.
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+ }
+ raw = readFileSync(path, 'utf8')
+ }
+ const value: unknown = JSON.parse(raw)
+ if (!record(value) || (value.updates !== undefined && !record(value.updates))) {
+ throw new Error('expected a JSON object with an optional updates object')
+ }
+ const allow = value.updates?.allowTestAuthPopupWindow
+ if (allow !== undefined && typeof allow !== 'boolean') {
+ throw new Error('updates.allowTestAuthPopupWindow must be true or false')
+ }
+ return { updates: { allowTestAuthPopupWindow: allow ?? false } }
+ } catch (error) {
+ try {
+ console.warn(`desktop settings: ${path}: using defaults because settings could not be loaded`, error)
+ } catch (_logError) {
+ // Diagnostic output is best effort; its failure must not make optional settings fatal.
+ // TODO: Persist settings diagnostics when console output is unavailable, without blocking startup.
+ }
+ return defaults
+ }
+}
diff --git a/apps/desktop/tests/expected/policy-login-en.json b/apps/desktop/tests/expected/policy-login-en.json
index 822c1a0f30..4e63787b24 100644
--- a/apps/desktop/tests/expected/policy-login-en.json
+++ b/apps/desktop/tests/expected/policy-login-en.json
@@ -1,4 +1,8 @@
{
+ "disabled": [
+ "Checking for updates…",
+ "You’re up to date!"
+ ],
"returned": [
"Checking for updates…",
"You’re up to date!",
diff --git a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
index 503c4bae3c..f9b6ca9279 100644
--- a/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
+++ b/apps/desktop/tests/fixtures/runtime-payload-smoke.mjs
@@ -161,12 +161,19 @@ function checkHtml() {
try {
const builtin = requireRuntime('node-addon-require-builtin')
assert.equal(typeof builtin.requireBuiltin('internal/modules/esm/loader').getOrInitializeCascadedLoader, 'function')
+ console.error('runtime payload: pnpm')
checkPnpm()
+ console.error('runtime payload: koffi')
checkKoffi()
+ console.error('runtime payload: sharp')
await checkSharp()
+ console.error('runtime payload: html')
checkHtml()
+ console.error('runtime payload: pty')
await checkPty()
+ console.error('runtime payload: search')
await checkSearch()
+ console.error('runtime payload: checks complete; awaiting process exit')
} finally {
// This private tree contains only fixture files; Windows may release handles after terminal exit.
await rm(scratch, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 })
diff --git a/apps/desktop/tests/fixtures/windows-pty-exit.cjs b/apps/desktop/tests/fixtures/windows-pty-exit.cjs
new file mode 100644
index 0000000000..5aaa17a56e
--- /dev/null
+++ b/apps/desktop/tests/fixtures/windows-pty-exit.cjs
@@ -0,0 +1,28 @@
+/** Exercise terminal cleanup in a child whose natural exit detects retained workers. */
+const assert = require('node:assert/strict')
+const { createRequire } = require('node:module')
+const { resolve } = require('node:path')
+const pty = createRequire(resolve(__dirname, '../../../../packages/subprocess/subprocess-local/package.json'))('node-pty')
+const killWhileRunning = process.argv[2] === 'kill'
+const terminal = pty.spawn(process.env.ComSpec, ['/d', '/c', killWhileRunning ? 'echo pty-ready & pause >nul' : 'echo pty-complete'], {
+ cwd: __dirname, env: process.env, cols: 80, rows: 24, useConptyDll: true,
+})
+let output = ''
+let killed = false
+terminal.onData(data => {
+ output += data
+ if (killWhileRunning && !killed && output.includes('pty-ready')) {
+ killed = true
+ terminal.kill()
+ }
+})
+terminal.onExit(event => {
+ if (!killWhileRunning) {
+ assert.equal(event.exitCode, 0)
+ assert.match(output, /pty-complete/u)
+ terminal.kill()
+ } else {
+ assert.equal(killed, true)
+ }
+ process.once('beforeExit', () => console.log('pty-cleanup-complete'))
+})
diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts
index b6b6fd476f..fff39c38ef 100644
--- a/apps/desktop/tests/main-startup.spec.ts
+++ b/apps/desktop/tests/main-startup.spec.ts
@@ -3,7 +3,7 @@ import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import type { IpcMainInvokeEvent } from 'electron'
import { join } from 'node:path'
-import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import type { MenuItemConstructorOptions, MessageBoxOptions } from 'electron'
import { DESKTOP_IPC, type DesktopUpdateState } from '../src/ipc.ts'
@@ -371,6 +371,12 @@ vi.mock('../src/welcome-backend.ts', () => ({
}),
}))
+function configureTestAuthPopup(allow: boolean): void {
+ const directory = join(harness.app.getPath('userData'), 'desktop')
+ mkdirSync(directory, { recursive: true })
+ writeFileSync(join(directory, 'settings.json'), JSON.stringify({ updates: { allowTestAuthPopupWindow: allow } }))
+}
+
function invoke(channel: string, origin = channel === DESKTOP_IPC.boot ? 'app' : 'shell', ...args: unknown[]): unknown {
const handler = harness.handlers.get(channel)
if (handler === undefined) throw new Error(`missing handler ${channel}`)
@@ -437,6 +443,19 @@ afterEach(async () => {
})
describe('desktop main startup', () => {
+ it('starts the Host with authentication popups disabled when local settings are invalid', async () => {
+ configureTestAuthPopup(false)
+ const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
+ const raw = '{"updates":{"allowTestAuthPopupWindow":"true"}}'
+ writeFileSync(path, raw)
+ await readyForUpdate()
+ expect(harness.hosts).toHaveLength(1)
+ expect(testAuth.login).not.toHaveBeenCalled()
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
+ expect(harness.dialog.showMessageBox).not.toHaveBeenCalledWith(expect.objectContaining({ title: en.startupFailed }))
+ expect(readFileSync(path, 'utf8')).toBe(raw)
+ })
+
it('routes shell update documents and assets through the registered main protocol handler', async () => {
const root = join(import.meta.dirname, '..')
vi.spyOn(harness.app, 'getAppPath').mockReturnValue(root)
@@ -531,7 +550,98 @@ describe('desktop main startup', () => {
expect(console.error).toHaveBeenLastCalledWith(expect.objectContaining({ message: 'overlay unavailable' }))
})
+ it.each(['missing-file', 'missing-field', 'disabled'] as const)('suppresses every test auth dialog for %s while continuing update checks', async (configuration) => {
+ if (configuration === 'disabled') configureTestAuthPopup(false)
+ if (configuration === 'missing-field') {
+ configureTestAuthPopup(true)
+ writeFileSync(join(harness.app.getPath('userData'), 'desktop', 'settings.json'), '{}')
+ }
+ harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
+ allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
+ const request = vi.fn().mockImplementation(async () =>
+ Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
+ vi.stubGlobal('fetch', request)
+ await readyForUpdate()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+ expect(request).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ credentials: 'include', redirect: 'error' }))
+ // Editing the file cannot change the consent policy of an already running process.
+ configureTestAuthPopup(true)
+ harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
+ await invoke(DESKTOP_IPC.updatesOpen, 'app')
+ harness.powerMonitor.emit('resume')
+ await vi.advanceTimersByTimeAsync(10_000)
+ expect(request.mock.calls.length).toBeGreaterThan(2)
+ expect(testAuth.login).not.toHaveBeenCalled()
+ expect(testAuth.focus).not.toHaveBeenCalled()
+ const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
+ const expected = JSON.parse(readFileSync(new URL('./expected/policy-login-en.json', import.meta.url), 'utf8')) as Record
+ expect(messages).toEqual(expected.disabled)
+ expect(harness.updateDownload).not.toHaveBeenCalled()
+ })
+
+ it('checks updates from the application menu without offering disabled test authentication', async () => {
+ configureTestAuthPopup(false)
+ harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test',
+ allowedAuthOrigins: ['https://login.example.com'], allowedPageOrigins: ['https://downloads.example.com'] }
+ const request = vi.fn().mockImplementation(async () =>
+ Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
+ vi.stubGlobal('fetch', request)
+ await readyForUpdate()
+ await vi.advanceTimersByTimeAsync(0)
+ request.mockClear()
+ harness.updateCheck.mockClear()
+ const checkUpdates = applicationMenuItems().find(item => item.label === en.checkUpdatesMenu)!.click as () => void
+ checkUpdates()
+ await vi.advanceTimersByTimeAsync(10_000)
+ expect(request).toHaveBeenCalled()
+ expect(harness.updateCheck).toHaveBeenCalledWith(true)
+ expect(testAuth.login).not.toHaveBeenCalled()
+ expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message))
+ .not.toContain(en.policyLoginRequired)
+ })
+
+ it.each([false, true])('retains anonymous production checks with popup permission %s', async (allow) => {
+ configureTestAuthPopup(allow)
+ harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'anonymous',
+ allowedPageOrigins: ['https://downloads.example.com'] }
+ const request = vi.fn().mockImplementation(async () =>
+ Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
+ vi.stubGlobal('fetch', request)
+ await readyForUpdate()
+ await invoke(DESKTOP_IPC.updatesOpen, 'app')
+ expect(request).toHaveBeenCalledWith(expect.any(URL), expect.objectContaining({ credentials: 'omit' }))
+ expect(testAuth.login).not.toHaveBeenCalled()
+ expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message))
+ .not.toContain(en.policyLoginRequired)
+ })
+
+ it('keeps a received mandatory decision when test cookies expire with popups disabled', async () => {
+ configureTestAuthPopup(false)
+ harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
+ allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
+ const request = vi.fn().mockImplementation(async () =>
+ Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
+ .mockResolvedValueOnce(Response.json({ code: 40005, data: { show_content: { title: 'Update', detail: 'Required' },
+ desktop_app_link: 'https://downloads.example.com/' } }))
+ vi.stubGlobal('fetch', request)
+ const host = await readyForUpdate()
+ await harness.policyBlocked.promise
+ await vi.advanceTimersByTimeAsync(1000)
+ const modal = harness.windows[0]!
+ const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
+ await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh')
+ expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({
+ policy: { blocking: true, error: 'authentication-required' },
+ })
+ expect(testAuth.login).not.toHaveBeenCalled()
+ expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
+ expect(modal.isDestroyed()).toBe(false)
+ expect(host.stop).not.toHaveBeenCalled()
+ })
+
it('shows one explained startup login before Host readiness and joins concurrent checks without reopening it', async () => {
+ configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () =>
@@ -575,6 +685,7 @@ describe('desktop main startup', () => {
})
it.each(['returned', 'cancelled', 'failed'] as const)('requires explicit test login and handles %s without downloading', async (outcome) => {
+ configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
const request = vi.fn().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 }))
@@ -602,6 +713,7 @@ describe('desktop main startup', () => {
})
it('does not open Feishu when the user declines test login', async () => {
+ configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => Response.json({ error: { code: 'UNAUTHENTICATED' } }, { status: 401 })))
@@ -625,6 +737,7 @@ describe('desktop main startup', () => {
})
it('retains the embedded block and running Host after expired test login is cancelled', async () => {
+ configureTestAuthPopup(true)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 1000, jitter: 0 }
const request = vi.fn().mockImplementation(async () =>
@@ -906,11 +1019,12 @@ describe('desktop main startup', () => {
} finally { harness.app.name = originalName }
})
- it('attaches Host socket credentials only to the owned application origin and window', async () => {
+ it.each(['http:', 'https:'] as const)('attaches %s Host socket credentials only to the owned application origin and window', async (protocol) => {
await import('../src/main.ts')
await harness.preparing.promise
harness.prepared.resolve()
await harness.hostStarted.promise
+ harness.hosts[0]!.url = `${protocol}//127.0.0.1:3080/?token=test`
harness.hosts[0]!.ready.resolve()
await Promise.resolve(invoke(DESKTOP_IPC.boot))
const handler = harness.socketHeaders.mock.calls[0]![1] as (
@@ -918,16 +1032,19 @@ describe('desktop main startup', () => {
callback: (result: unknown) => void,
) => void
const callback = vi.fn()
- const details = { url: 'ws://127.0.0.1:3080/api/remote.mux', webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } }
+ const socketProtocol = protocol === 'https:' ? 'wss:' : 'ws:'
+ const details = { url: `${socketProtocol}//127.0.0.1:3080/api/remote.mux`, webContentsId: 42, requestHeaders: { Origin: 'dsh-app://app' } }
handler(details, callback)
expect(callback).toHaveBeenLastCalledWith({ requestHeaders: {
- origin: 'http://127.0.0.1:3080', cookie: 'test-cookie', 'sec-fetch-site': 'same-origin',
+ origin: `${protocol}//127.0.0.1:3080`, cookie: 'test-cookie', 'sec-fetch-site': 'same-origin',
} })
handler({ ...details, requestHeaders: { Origin: 'https://other.example' } }, callback)
expect(callback).toHaveBeenLastCalledWith({ cancel: true })
handler({ ...details, webContentsId: 43 }, callback)
expect(callback).toHaveBeenLastCalledWith({})
- handler({ ...details, url: 'ws://127.0.0.1:9999/api/remote.mux' }, callback)
+ handler({ ...details, url: `${socketProtocol}//127.0.0.1:9999/api/remote.mux` }, callback)
+ expect(callback).toHaveBeenLastCalledWith({})
+ handler({ ...details, url: `${socketProtocol === 'wss:' ? 'ws:' : 'wss:'}//127.0.0.1:3080/api/remote.mux` }, callback)
expect(callback).toHaveBeenLastCalledWith({})
})
@@ -1557,7 +1674,8 @@ describe('desktop main startup', () => {
}
})
- it('queues policy authentication until the ordinary result dialog closes', async () => {
+ it.each([false, true])('handles a deferred authentication failure with popup permission %s', async (allow) => {
+ configureTestAuthPopup(allow)
harness.embeddedPolicy = { origin: 'https://policy.example.com', authentication: 'feishu-test', allowedAuthOrigins: ['https://login.example.com'],
allowedPageOrigins: ['https://downloads.example.com'], intervalMs: 10_000, jitter: 0 }
const request = vi.fn()
@@ -1583,7 +1701,10 @@ describe('desktop main startup', () => {
.not.toContain(en.policyLoginRequired)
ordinaryResult.resolve({ response: 0 })
await operation
- await policyShown.promise
+ await vi.advanceTimersByTimeAsync(0)
+ if (allow) await policyShown.promise
+ else expect(harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message))
+ .not.toContain(en.policyLoginRequired)
expect(testAuth.login).not.toHaveBeenCalled()
})
diff --git a/apps/desktop/tests/prepare-runtime-patches.spec.ts b/apps/desktop/tests/prepare-runtime-patches.spec.ts
new file mode 100644
index 0000000000..951724e114
--- /dev/null
+++ b/apps/desktop/tests/prepare-runtime-patches.spec.ts
@@ -0,0 +1,140 @@
+import { execFile } from 'node:child_process'
+import { createHash } from 'node:crypto'
+import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { promisify } from 'node:util'
+import { afterEach, expect, it } from 'vitest'
+import yaml from 'js-yaml'
+import { pnpmCommand } from '../../../scripts/release/process.ts'
+import { prepareRuntimePatches, verifyRuntimePatches } from '../scripts/prepare-runtime-patches.ts'
+import type { DesktopRuntimePatch } from '../scripts/runtime-patch-policy.ts'
+
+const roots: string[] = []
+const shared = { scope: 'shared', reason: 'Runtime fixture.' } as const
+const excluded = { scope: 'workspace-only', reason: 'Build fixture.' } as const
+const patch = 'diff --git a/value.txt b/value.txt\n--- a/value.txt\n+++ b/value.txt\n@@ -1 +1 @@\n-original\n+patched\n'
+afterEach(() => {
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 })
+})
+
+function fixture(): { root: string; source: string; project: string } {
+ const root = mkdtempSync(join(tmpdir(), 'desktop-patch-policy-'))
+ roots.push(root)
+ const source = join(root, 'source')
+ const project = join(root, 'project')
+ mkdirSync(source)
+ mkdirSync(project)
+ writeFileSync(join(source, 'shared.patch'), patch)
+ writeFileSync(join(source, 'private.patch'), patch.replace('+patched', '+private'))
+ writeFileSync(join(source, 'pnpm-workspace.yaml'), yaml.dump({ patchedDependencies: { 'fixture@1.0.0': 'shared.patch' } }))
+ writeFileSync(join(source, 'pnpm-lock.yaml'), yaml.dump({
+ patchedDependencies: { 'fixture@1.0.0': createHash('sha256').update(patch).digest('hex') },
+ }))
+ writeFileSync(join(project, 'pnpm-workspace.yaml'), yaml.dump({
+ packages: ['.'], nodeLinker: 'hoisted', overrides: { internal: 'file:./internal.tgz' }, allowBuilds: { fixture: false },
+ }))
+ return { root, source, project }
+}
+
+it('classifies the current workspace and shares only runtime patch bytes', () => {
+ const { project } = fixture()
+ const repository = resolve(import.meta.dirname, '../../..')
+ const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
+ const plan = prepareRuntimePatches(project, repository)
+ expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.0.2', 'node-pty@1.2.0-beta.15'])
+ expect(plan.workspaceOnly).toHaveLength(5)
+ const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record }
+ expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined()
+ expect(settings.overrides['@earendil-works/pi-ai@^1.0.2']).toBe('1.0.2')
+ expect(readFileSync(join(repository, 'pnpm-lock.yaml'))).toEqual(before)
+})
+
+it('does not infer version overrides from selected patches', () => {
+ const { source, project } = fixture()
+ const before = readFileSync(join(source, 'pnpm-workspace.yaml'))
+ prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, {})
+ expect(yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8'))).toMatchObject({
+ overrides: { internal: 'file:./internal.tgz' }, allowBuilds: { fixture: false },
+ })
+ expect(readFileSync(join(source, 'pnpm-workspace.yaml'))).toEqual(before)
+ expect(readFileSync(join(project, 'patches/desktop-0.patch'), 'utf8')).toBe(patch)
+})
+
+it('supports an independent runtime patch for a package also patched in the workspace', () => {
+ const { source, project } = fixture()
+ const plan = prepareRuntimePatches(project, source, {
+ 'fixture@1.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Different runtime layout.' },
+ 'other@2.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Runtime-only dependency.' },
+ }, {})
+ expect(plan.patches).toHaveLength(2)
+ expect(readFileSync(join(project, plan.patches[0]!.path), 'utf8')).toContain('+private')
+ expect(readFileSync(join(source, 'shared.patch'), 'utf8')).toBe(patch)
+})
+
+it.each([
+ { policy: {}, message: 'classify the workspace patch' },
+ { policy: { 'fixture@1.0.0': shared, 'absent@1.0.0': shared }, message: 'stale workspace patch decision' },
+ { policy: { 'fixture@1.0.0': { scope: 'runtime-only', path: '../outside.patch', reason: 'Invalid path.' } }, message: 'inside the repository' },
+ { policy: { 'fixture@1.0.0': { scope: 'runtime-only', path: 'missing.patch', reason: 'Missing file.' } }, message: 'ENOENT' },
+] satisfies Array<{ policy: Record; message: string }>)('rejects invalid selection: $message', ({ policy, message }) => {
+ const { source, project } = fixture()
+ expect(() => { prepareRuntimePatches(project, source, policy, {}) }).toThrow(message)
+})
+
+it('rejects shared bytes that differ from the workspace lockfile', () => {
+ const { source, project } = fixture()
+ writeFileSync(join(source, 'shared.patch'), patch + '\n')
+ expect(() => { prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, {}) }).toThrow('root lockfile')
+})
+
+it('refuses to write to the workspace or replace an unrelated existing override', () => {
+ const { source, project } = fixture()
+ expect(() => { prepareRuntimePatches(source, source, { 'fixture@1.0.0': shared }, {}) }).toThrow('source workspace')
+ expect(() => { prepareRuntimePatches(project, source, { 'fixture@1.0.0': shared }, { internal: '2.0.0' }) }).toThrow('conflicting')
+})
+
+it.each(['version', 'lock hash', 'file bytes', 'workspace-only', 'unused'] as const)('rejects invalid resolved installation: %s', (failure) => {
+ const { source, project } = fixture()
+ const plan = prepareRuntimePatches(project, source, { 'fixture@1.0.0': failure === 'workspace-only' ? excluded : shared }, {})
+ writeFileSync(join(project, 'pnpm-lock.yaml'), yaml.dump({
+ patchedDependencies: Object.fromEntries(plan.patches.map(entry => [entry.spec, failure === 'lock hash' ? 'wrong' : entry.hash])),
+ packages: failure === 'unused' ? {} : { [failure === 'version' ? 'fixture@1.0.1' : 'fixture@1.0.0']: {} },
+ }))
+ if (failure === 'file bytes') writeFileSync(join(project, plan.patches[0]!.path), 'changed')
+ expect(() => { verifyRuntimePatches(project, plan) }).toThrow(/desktop patches:/u)
+})
+
+it.each([false, true])('pnpm applies runtime-only bytes and rejects an inapplicable patch (invalid=%s)', async (invalid) => {
+ const { source, project, root } = fixture()
+ const packageDir = join(root, 'package')
+ mkdirSync(packageDir)
+ writeFileSync(join(packageDir, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0' }))
+ writeFileSync(join(packageDir, 'value.txt'), 'original\n')
+ const run = promisify(execFile)
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
+ /^(?:path|systemroot|windir|comspec|temp|tmp)$/iu.test(name)
+ )))
+ await run('tar', ['-czf', 'fixture.tgz', '-C', root, 'package'], { cwd: project, env, timeout: 15_000, windowsHide: true })
+ writeFileSync(join(project, 'package.json'), JSON.stringify({ private: true, dependencies: { fixture: 'file:./fixture.tgz' } }))
+ writeFileSync(join(project, 'npmrc'), '')
+ if (invalid) writeFileSync(join(source, 'private.patch'), patch.replace('-original', '-does-not-exist'))
+ const plan = prepareRuntimePatches(project, source, {
+ 'fixture@1.0.0': { scope: 'runtime-only', path: 'private.patch', reason: 'Offline install fixture.' },
+ }, {})
+ const [command, ...args] = pnpmCommand()
+ const install = run(command, [...args, 'install', '--offline', '--ignore-scripts',
+ `--config.userconfig=${join(project, 'npmrc')}`, `--config.store-dir=${join(project, 'store')}`,
+ '--config.manage-package-manager-versions=false'], { cwd: project, env: { ...env, CI: 'true' }, timeout: 30_000, windowsHide: true })
+ if (invalid) {
+ const failure: unknown = await install.catch((error: unknown) => error)
+ if (!(failure instanceof Error) || !('stdout' in failure)) throw new Error('Expected a captured pnpm failure')
+ expect(failure).toHaveProperty('code', 1)
+ expect(failure.stdout).toMatch(/PATCH_FAILED/u)
+ } else {
+ await install
+ verifyRuntimePatches(project, plan)
+ expect(readFileSync(join(project, 'node_modules/fixture/value.txt'), 'utf8')).toBe('private\n')
+ }
+ expect(existsSync(join(source, 'node_modules'))).toBe(false)
+}, 45_000)
diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts
new file mode 100644
index 0000000000..4633a77a12
--- /dev/null
+++ b/apps/desktop/tests/prepare-windows-ci.spec.ts
@@ -0,0 +1,56 @@
+import { parseEnv } from 'node:util'
+import { describe, expect, it } from 'vitest'
+import { windowsCiSettings } from '../scripts/prepare-windows-ci.mjs'
+import { validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
+
+const inputs = {
+ PACKAGE_BUILD_VERSION: 'auto',
+ PACKAGE_DEPLOYMENT: 'test',
+ PACKAGE_POLICY_ORIGIN: 'https://policy.example.com',
+ PACKAGE_LOGIN_ORIGINS: 'https://login.example.com, https://sso.example.com',
+ GITHUB_RUN_NUMBER: '42',
+ GITHUB_RUN_ATTEMPT: '2',
+}
+const productVersion = '0.2.1-alpha.1'
+const now = new Date('2026-10-08T23:59:59Z')
+
+describe('manual Windows package inputs', () => {
+ it('numbers fresh runners and reruns without storage credentials', () => {
+ const result = windowsCiSettings(inputs, productVersion, now)
+ expect(result.version).toBe('0.2.1-alpha.1.20261008.42.2')
+ const settings = parseEnv(result.settings)
+ expect(() => { validateDesktopPackageEnvironment(settings, { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow()
+ expect(Object.keys(settings).sort()).toEqual([
+ 'DSH_DESKTOP_APP_ID', 'DSH_DESKTOP_AUTO_UPDATE_ENV',
+ 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN', 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG',
+ ].sort())
+ })
+
+ it('accepts an explicit version without a GitHub run identity', () => {
+ const result = windowsCiSettings({ ...inputs, PACKAGE_BUILD_VERSION: '0.2.1-alpha.1.20261008.7',
+ GITHUB_RUN_NUMBER: undefined, GITHUB_RUN_ATTEMPT: undefined }, productVersion, now)
+ expect(result.version).toBe('0.2.1-alpha.1.20261008.7')
+ })
+
+ it('uses the stable product test prefix and anonymous production policy', () => {
+ const result = windowsCiSettings({ ...inputs, PACKAGE_DEPLOYMENT: 'production', PACKAGE_LOGIN_ORIGINS: '' }, '0.2.1', now)
+ expect(result.version).toBe('0.2.1-test.20261008.42.2')
+ expect(() => { validateDesktopPackageEnvironment(parseEnv(result.settings), { platform: 'win32', arch: 'x64' }, { unsigned: true }) }).not.toThrow()
+ })
+
+ it.each([
+ { PACKAGE_BUILD_VERSION: '' },
+ { PACKAGE_BUILD_VERSION: '0.3.0' },
+ { PACKAGE_DEPLOYMENT: 'staging' },
+ { PACKAGE_POLICY_ORIGIN: '' },
+ { PACKAGE_POLICY_ORIGIN: 'http://policy.example.com' },
+ { PACKAGE_POLICY_ORIGIN: 'https://policy.example.com/path' },
+ { PACKAGE_LOGIN_ORIGINS: '' },
+ { PACKAGE_LOGIN_ORIGINS: 'https://user:password@login.example.com' },
+ { PACKAGE_DEPLOYMENT: 'production' },
+ { GITHUB_RUN_NUMBER: '0' },
+ { GITHUB_RUN_ATTEMPT: '1\nINJECTED=value' },
+ ])('rejects invalid manual settings %j', (override) => {
+ expect(() => windowsCiSettings({ ...inputs, ...override }, productVersion, now)).toThrow()
+ })
+})
diff --git a/apps/desktop/tests/prepared-runtime-smoke.spec.ts b/apps/desktop/tests/prepared-runtime-smoke.spec.ts
index 2a2644719e..c4c840c009 100644
--- a/apps/desktop/tests/prepared-runtime-smoke.spec.ts
+++ b/apps/desktop/tests/prepared-runtime-smoke.spec.ts
@@ -21,6 +21,27 @@ afterEach(() => {
Object.defineProperty(process, 'arch', hostArch)
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
vi.clearAllMocks()
+ vi.restoreAllMocks()
+})
+
+it('retains payload progress and termination details when the child fails', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'prepared-runtime-failure-'))
+ roots.push(root)
+ const fixture = runtimeFixture(root)
+ const target = { platform: 'win32' as const, arch: 'x64' }
+ writeDesktopRuntime(root, fixture.release, fixture.sharedPackages.map(entry => entry.name), target)
+ const descriptor = await verifyDesktopRuntime(root, fixture.release.version, target)
+ const failure = Object.assign(new Error('payload timed out'), {
+ stdout: 'payload output\n', stderr: 'runtime payload: pty\n', killed: true, signal: 'SIGTERM',
+ })
+ payload.mockRejectedValueOnce(failure)
+ const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
+ const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
+ await expect(smokePreparedRuntime(root, join(root, 'electron'), join(root, 'runtime'), descriptor)).rejects.toBe(failure)
+ expect(stdout).toHaveBeenCalledWith('payload output\n')
+ expect(stderr).toHaveBeenCalledWith('runtime payload: pty\n')
+ expect(stderr).toHaveBeenCalledWith('runtime payload: killed=true, signal=SIGTERM\n')
+ expect(smokeDesktopRuntime).not.toHaveBeenCalled()
})
it('smokes an x64 target verified on an arm64 build host without revalidating against the host', async () => {
diff --git a/apps/desktop/tests/ptc-runtime.spec.ts b/apps/desktop/tests/ptc-runtime.spec.ts
index 757bfe95b2..e2f8fca009 100644
--- a/apps/desktop/tests/ptc-runtime.spec.ts
+++ b/apps/desktop/tests/ptc-runtime.spec.ts
@@ -30,7 +30,7 @@ it.skipIf(process.platform !== 'darwin' || !electronInstalled).each(['workspace-
await mkdir(cwd)
vi.stubEnv('ELECTRON_RUN_AS_NODE', '1')
vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit')
- const runtime = await mountRuntime(ctx, { nodeExecutable: electron }, { mode, workspaceRoot: cwd })
+ const runtime = await mountRuntime(ctx, { launch: { kind: 'node-script', executable: electron } }, { mode, workspaceRoot: cwd })
const result = await runtime.run(runtime.resolve({
// Bound startup failure before the outer test deadline, leaving time for managed cleanup.
timeoutMs: 30_000,
diff --git a/apps/desktop/tests/settings.spec.ts b/apps/desktop/tests/settings.spec.ts
new file mode 100644
index 0000000000..b285bb98e9
--- /dev/null
+++ b/apps/desktop/tests/settings.spec.ts
@@ -0,0 +1,83 @@
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import * as fs from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, expect, it, onTestFinished, vi } from 'vitest'
+import { readDesktopSettings } from '../src/settings.ts'
+
+vi.mock('node:fs', async importOriginal => ({ ...await importOriginal() }))
+
+beforeEach(() => { vi.spyOn(console, 'warn').mockImplementation(() => {}) })
+afterEach(() => { vi.restoreAllMocks() })
+
+function fixture() {
+ const userData = mkdtempSync(join(tmpdir(), 'dsh-desktop-settings-'))
+ onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) })
+ const directory = join(userData, 'desktop')
+ mkdirSync(directory)
+ return { userData, path: join(directory, 'settings.json') }
+}
+
+it('creates a discoverable default file on first launch without touching other preferences', () => {
+ const { userData, path } = fixture()
+ rmSync(join(userData, 'desktop'), { recursive: true })
+ writeFileSync(join(userData, 'keybindings.json'), 'existing shortcuts\n')
+ expect(readDesktopSettings(userData)).toEqual({ updates: { allowTestAuthPopupWindow: false } })
+ expect(readFileSync(path, 'utf8')).toBe('{\n "updates": {\n "allowTestAuthPopupWindow": false\n }\n}\n')
+ expect(readFileSync(join(userData, 'keybindings.json'), 'utf8')).toBe('existing shortcuts\n')
+})
+
+it.each(['{}', '{"updates": {}}', '{"other": 1, "updates": {"other": true}}'])('defaults an older document without rewriting it: %s', (raw) => {
+ const { userData, path } = fixture()
+ writeFileSync(path, raw)
+ expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
+ expect(readFileSync(path, 'utf8')).toBe(raw)
+})
+
+it('loads explicit opt-in and opt-out on the next read without changing an earlier process snapshot', () => {
+ const { userData, path } = fixture()
+ for (const allow of [true, false]) {
+ const raw = JSON.stringify({ updates: { allowTestAuthPopupWindow: allow }, custom: 'preserve' })
+ writeFileSync(path, raw)
+ expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(allow)
+ expect(readFileSync(path, 'utf8')).toBe(raw)
+ }
+ const previous = readDesktopSettings(userData)
+ writeFileSync(path, '{"updates":{"allowTestAuthPopupWindow":true}}')
+ expect(previous.updates.allowTestAuthPopupWindow).toBe(false)
+ expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(true)
+})
+
+it.each(['', '{', 'null', '[]', '{"updates":null}', '{"updates":[]}',
+ '{"updates":{"allowTestAuthPopupWindow":"false"}}', '{"updates":{"allowTestAuthPopupWindow":1}}',
+ '{"updates":{"allowTestAuthPopupWindow":null}}'])('reports invalid settings with their path and preserves the original: %s', (raw) => {
+ const { userData, path } = fixture()
+ writeFileSync(path, raw)
+ expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
+ expect(readFileSync(path, 'utf8')).toBe(raw)
+})
+
+it('reports filesystem errors instead of replacing unreadable settings', () => {
+ const { userData, path } = fixture()
+ mkdirSync(path)
+ expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), expect.any(Error))
+ expect(fs.statSync(path).isDirectory()).toBe(true)
+})
+
+it.each(['mkdirSync', 'writeFileSync'] as const)('continues with disabled popups when %s fails', (operation) => {
+ const { userData, path } = fixture()
+ const failure = Object.assign(new Error('settings creation denied'), { code: 'EACCES' })
+ vi.spyOn(fs, operation).mockImplementation(() => { throw failure })
+ expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining(path), failure)
+})
+
+it('continues with defaults even when warning output fails', () => {
+ const { userData, path } = fixture()
+ writeFileSync(path, '{')
+ vi.mocked(console.warn).mockImplementation(() => { throw new Error('output unavailable') })
+ expect(readDesktopSettings(userData).updates.allowTestAuthPopupWindow).toBe(false)
+ expect(readFileSync(path, 'utf8')).toBe('{')
+})
diff --git a/apps/desktop/tests/welcome-startup.spec.ts b/apps/desktop/tests/welcome-startup.spec.ts
index 9848c7fb26..7915248243 100644
--- a/apps/desktop/tests/welcome-startup.spec.ts
+++ b/apps/desktop/tests/welcome-startup.spec.ts
@@ -102,6 +102,7 @@ vi.mock('electron', () => ({
vi.mock('../src/tray.ts', () => ({ DesktopTray: class { relabel() {} dispose() {} } }))
vi.mock('../src/paths.ts', () => ({ resolveDesktopPaths: () => ({ profile: '/profile' }) }))
+vi.mock('../src/settings.ts', () => ({ readDesktopSettings: () => ({ updates: { allowTestAuthPopupWindow: false } }) }))
vi.mock('../src/login-shell-environment.ts', async importOriginal => ({
...await importOriginal(),
readDesktopLoginShellEnvironment: async (base: NodeJS.ProcessEnv) => ({ environment: base, failures: [] }),
diff --git a/apps/desktop/tests/windows-pty-exit.spec.ts b/apps/desktop/tests/windows-pty-exit.spec.ts
new file mode 100644
index 0000000000..d6fa3d381e
--- /dev/null
+++ b/apps/desktop/tests/windows-pty-exit.spec.ts
@@ -0,0 +1,15 @@
+import { execFile } from 'node:child_process'
+import { resolve } from 'node:path'
+import { promisify } from 'node:util'
+import { expect, it } from 'vitest'
+
+it.skipIf(process.platform !== 'win32').each(['natural', 'kill'])('releases the terminal worker after %s exit', async (mode) => {
+ const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => (
+ /^(?:path|systemroot|windir|comspec|temp|tmp)$/iu.test(name)
+ )))
+ const result = await promisify(execFile)(process.execPath, [resolve(import.meta.dirname, 'fixtures/windows-pty-exit.cjs'), mode], {
+ env, timeout: 15_000, windowsHide: true,
+ })
+ expect(result.stderr).toBe('')
+ expect(result.stdout.trim()).toBe('pty-cleanup-complete')
+}, 20_000)
diff --git a/apps/web/package.json b/apps/web/package.json
index 6dfc75207f..f44baf5c4c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*",
"@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*",
"@deepseek-ai/dsh-remote-mock": "workspace:*",
+ "@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@types/node": "^22.20.1",
"@types/react": "~18.3.1",
diff --git a/apps/web/tests/agent-team-panel.overlay.yml b/apps/web/tests/agent-team-panel.overlay.yml
index 20c49c23a1..ecca62985b 100644
--- a/apps/web/tests/agent-team-panel.overlay.yml
+++ b/apps/web/tests/agent-team-panel.overlay.yml
@@ -17,7 +17,6 @@
config:
maxMembers: 8
maxTasks: 256
- maxPendingMessagesPerMember: 64
maxMessageBytes: 65536
disposalTimeoutMs: 5000
diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts
index f645214133..c7b7f41b65 100644
--- a/apps/web/tests/built-boot.expected.e2e.ts
+++ b/apps/web/tests/built-boot.expected.e2e.ts
@@ -97,6 +97,8 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
fireEvent.click(waitingTitle)
await waitFor(() => {
expect(document.querySelector('[data-sample="bash"]')).not.toBeNull()
+ expect(document.querySelector('[data-chat-running]')?.parentElement?.getAttribute('data-slot'))
+ .toBe('conversation.chat.flow')
}, { timeout: 10_000 })
// The generated bundle roster mounts the question UI before the approval UI.
// Skip the resident fixture's three questions, then resolve its approval so
diff --git a/apps/web/tests/chat-flow-layout.e2e.ts b/apps/web/tests/chat-flow-layout.e2e.ts
new file mode 100644
index 0000000000..d3d5e999ac
--- /dev/null
+++ b/apps/web/tests/chat-flow-layout.e2e.ts
@@ -0,0 +1,73 @@
+/** Cold history retains Chat spacing through the flow slot's display:contents anchor. */
+import { readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import { expect, it, onTestFinished } from 'vitest'
+import { launchWebScaffold, seedSession, selectedSessionFixture, watchConsole } from './scaffold.ts'
+import { newEnglishPage } from './support.ts'
+
+it.each(['turn-tail-actions', 'goal-multi-turn-actions', 'present'] as const)(
+ 'preserves process and answer spacing in cold %s history', async (scenario) => {
+ const scaffold = await launchWebScaffold({})
+ onTestFinished(() => scaffold.close())
+ if (scenario === 'present') {
+ await writeFile(join(scaffold.workspaceCwd, 'report.txt'), 'DELIVERED_REPORT\n')
+ await writeFile(join(scaffold.workspaceCwd, '说明.txt'), 'DELIVERED_NOTE\n')
+ }
+ const fixture = await selectedSessionFixture(fileURLToPath(
+ new URL(`../../../snapshots/web/${scenario}/session.v3.jsonl`, import.meta.url),
+ ))
+ await seedSession(scaffold, await readFile(fixture, 'utf8'), `flow-layout-${scenario}`)
+ const browser = await chromium.launch()
+ onTestFinished(() => browser.close())
+ const page = await newEnglishPage(browser)
+ const console = watchConsole(page)
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await page.locator('[role="treeitem"]').first().click()
+ await page.locator('[role="treeitem"]').nth(1).click()
+ const controls = page.locator('[data-turn-process]')
+ await expect.poll(() => controls.count()).toBe(scenario === 'goal-multi-turn-actions' ? 2 : 1)
+ const process = controls.first()
+ expect(await process.getAttribute('aria-expanded')).toBe('false')
+ expect(await process.evaluate(element => element.closest('[data-slot="conversation.chat.flow"]') !== null)).toBe(true)
+
+ if (scenario === 'goal-multi-turn-actions') {
+ const trigger = page.locator('[data-turn-trigger]').first()
+ await process.click()
+ for (const fontSize of [10, 14, 22]) {
+ await scaffold.ctx.settings.update('ui-theme', { fontSize })
+ await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
+ .toBe(`${String(fontSize)}px`)
+ const group = page.locator('[data-process-activity]:visible').first()
+ expect(await process.evaluate(element => element.getBoundingClientRect().top))
+ .toBe(await trigger.evaluate(element => element.getBoundingClientRect().bottom) + 16)
+ expect(await group.evaluate(element => element.getBoundingClientRect().top))
+ .toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16)
+ }
+ } else {
+ const answer = page.getByText(scenario === 'present' ? 'PRESENT_DONE' : 'DONE', { exact: true })
+ await answer.waitFor()
+ const answerBounds = await answer.evaluate((element) => {
+ const seat = element.closest('[data-chat-flow-kind="assistant-step"]')
+ if (seat === null) throw new Error('final answer has no Chat seat')
+ const { top, bottom } = seat.getBoundingClientRect()
+ return { top, bottom }
+ })
+ expect(answerBounds.top).toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16)
+ if (scenario === 'present') {
+ const geometry = await page.locator('[data-presented-files-row]').evaluate((grid) => {
+ const root = grid.parentElement
+ const tail = root?.closest('[data-turn-tail]')
+ const actions = tail?.querySelector('button[aria-label="Copy"]')?.parentElement
+ if (root === null || actions == null) throw new Error('presented files have no Turn footer')
+ return { top: root.getBoundingClientRect().top, bottom: root.getBoundingClientRect().bottom,
+ actionsTop: actions.getBoundingClientRect().top }
+ })
+ expect(geometry.top - answerBounds.bottom).toBeCloseTo(16, 1)
+ expect(geometry.actionsTop - geometry.bottom).toBeCloseTo(20, 1)
+ }
+ }
+ expect(console.pageErrors).toEqual([])
+ },
+)
diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts
index 4d22e66e98..a7b5bc4b7a 100644
--- a/apps/web/tests/chat-scroll-contract.e2e.ts
+++ b/apps/web/tests/chat-scroll-contract.e2e.ts
@@ -2,9 +2,10 @@
// deliberately virtualizer-neutral: they assert semantic-row position,
// bottom ownership, interaction state, and the real outer scroll host rather
// than DOM cardinality or implementation-specific spacer markup.
-import { access, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
@@ -524,6 +525,116 @@ describe('web e2e: long Chat scroll contract', () => {
await browser?.close()
})
+ it.skipIf(MODE === 'record')('merges a large process fold with new-input following without returning downward', async () => {
+ const fixture = createChatScrollFixture({ markerPrefix: 'FOLD', title: 'CHAT_FOLD_INPUT', turns: 8 })
+ const input = 'CHAT_FOLD_NEXT_INPUT'
+ const artifactRoot = fileURLToPath(new URL('../../../.artifacts/fold-input-e2e/', import.meta.url))
+ await mkdir(artifactRoot, { recursive: true })
+ const evidence = await mkdtemp(join(artifactRoot, 'run-'))
+ await withScrollWorld({
+ failureShot: 'web-e2e-fold-input-motion',
+ replay: [replayEntry(textStream('CHAT_FOLD_REPLY', 'CHAT_FOLD_DONE', 2))],
+ seeds: [{ fixture, id: 'chat-fold-input-e2e' }],
+ }, async (world) => {
+ const { page } = world
+ await page.setViewportSize({ width: 1280, height: 720 })
+ await page.emulateMedia({ reducedMotion: 'no-preference' })
+ await openSeed(page, fixture, fixture.markers.assistant(8))
+ await openSettings(page, 'en')
+ await page.getByRole('dialog', { name: 'Settings', exact: true })
+ .getByText('When to Collapse Work Details', { exact: true }).locator('../..')
+ .getByRole('button', { name: 'On completion', exact: true }).click()
+ await page.getByRole('menuitem', { name: 'On next message', exact: true }).click()
+ await page.keyboard.press('Escape')
+ for (const index of [1, 2]) {
+ const row = page.locator(`[data-chat-call-id="chat-scroll-008-${index}"] [data-sample="bash"]`)
+ await row.click()
+ await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
+ }
+ await wheelTranscript(page, -600)
+ await page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
+ await expectBottom(page)
+ const beforeHeight = await page.locator('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')
+ .evaluate(element => element.getBoundingClientRect().height)
+ const release = holdTextAfter(world, 0)
+ const trace = page.evaluate(marker => new Promise<{
+ readonly t: number
+ readonly top: number
+ readonly messageY: number
+ readonly columnHeight: number
+ readonly spacer: number
+ readonly folding: boolean
+ }[]>((resolve) => {
+ const host = document.querySelector('[data-conversation-scroll]')!
+ const column = host.querySelector('[data-chat-flow]:has(> [data-slot="conversation.chat.flow"])')!
+ const samples: { t: number; top: number; messageY: number; columnHeight: number; spacer: number; folding: boolean }[] = []
+ let frame = 0
+ let timer = 0
+ const sample = (): void => {
+ const message = Array.from(column.querySelectorAll('*'))
+ .find(element => element.children.length === 0 && element.textContent === marker)
+ if (message === undefined) return
+ samples.push({ t: performance.now(), top: host.scrollTop, messageY: message.getBoundingClientRect().top,
+ columnHeight: column.getBoundingClientRect().height,
+ spacer: Number.parseFloat(host.querySelector('[data-chat-turn-spacer]')?.style.height ?? '') || 0,
+ folding: column.querySelector('[data-chat-motion="collapse"]') !== null })
+ }
+ const tick = (): void => {
+ timer = window.setTimeout(() => { sample(); frame = requestAnimationFrame(tick) }, 0)
+ }
+ host.addEventListener('dsh-test-fold-stop', () => {
+ cancelAnimationFrame(frame)
+ window.clearTimeout(timer)
+ sample()
+ host.removeAttribute('data-fold-probe-ready')
+ resolve(samples)
+ }, { once: true })
+ host.setAttribute('data-fold-probe-ready', '')
+ frame = requestAnimationFrame(tick)
+ }), input)
+ await page.locator('[data-fold-probe-ready]').waitFor()
+ let settled: ReturnType | undefined
+ try {
+ await page.locator('[data-composer-input][contenteditable="true"]').last().fill(input)
+ await page.getByRole('button', { name: 'Send message', exact: true }).click()
+ settled = world.scaffold.whenTurnSettled(30_000)
+ await page.getByText(input, { exact: true }).waitFor()
+ await expect.poll(() => page.locator('[data-chat-motion="collapse"]').count()).toBe(0)
+ await expectBottom(page)
+ await nextPaint(page)
+ } finally {
+ await page.locator('[data-conversation-scroll]').evaluate(host => host.dispatchEvent(new Event('dsh-test-fold-stop')))
+ release()
+ await settled
+ }
+ const samples = await trace
+ expect(samples.length).toBeGreaterThan(4)
+ const first = samples[0]!
+ let minimum = first.messageY
+ let reversal = 0
+ let maximumScroll = first.top
+ let viewportBacktrack = 0
+ for (const sample of samples) {
+ minimum = Math.min(minimum, sample.messageY)
+ if (first.messageY - minimum > 4) reversal = Math.max(reversal, sample.messageY - minimum)
+ maximumScroll = Math.max(maximumScroll, sample.top)
+ viewportBacktrack = Math.max(viewportBacktrack, maximumScroll - sample.top)
+ }
+ const last = samples.at(-1)!
+ const afterHeight = last.columnHeight
+ await writeFile(join(evidence, 'motion.json'), JSON.stringify({ beforeHeight, afterHeight, reversal, viewportBacktrack, samples }, null, 2))
+ await page.screenshot({ path: join(evidence, 'settled.png') })
+ console.log(`fold-input e2e: message reversal=${reversal}px, viewport backtrack=${viewportBacktrack}px, spacer=${last.spacer}px, evidence=${evidence}`)
+ expect(beforeHeight - afterHeight).toBeGreaterThan(250)
+ expect(first.messageY - minimum).toBeGreaterThan(40)
+ expect(reversal).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
+ expect(viewportBacktrack).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
+ expect(Math.abs(last.spacer - (beforeHeight - afterHeight))).toBeLessThanOrEqual(GEOMETRY_TOLERANCE)
+ await expectBottom(page)
+ assertClean(world)
+ })
+ }, 180_000)
+
it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => {
await withScrollWorld({
failureShot: 'web-e2e-chat-floating-controls',
diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts
new file mode 100644
index 0000000000..a33867b404
--- /dev/null
+++ b/apps/web/tests/collapse-timing.e2e.ts
@@ -0,0 +1,191 @@
+/** Recorded work details rendered with independent completion and next-input folding. */
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { basename, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page, Route } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { deriveReplayScript } from '@deepseek-ai/dsh-llm-replay'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+ launchWebScaffold, parseSeedFixture, readPersistedEvents, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, openSettings, saveFailureShot, writeComposerDraft } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/collapse-timing', import.meta.url))
+const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
+const SCREENSHOTS = 'screenshots/collapse-timing'
+const SCREENSHOT_DIR = fileURLToPath(new URL(`../../../.artifacts/${SCREENSHOTS}`, import.meta.url))
+const SEED_ID = 'collapse-timing-web-e2e'
+const MODE = webSnapshotMode()
+const UI_WAIT = { timeout: 10_000 }
+const GOLDENS = [
+ 'completion.expected.md',
+ 'next-input.expected.md',
+ 'completion-restored.expected.md',
+ 'input-pending.expected.md',
+ 'next-message.expected.md',
+ 'verbose.expected.md',
+]
+
+describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ let fixture: string
+ let prompt: string
+ let replayDir: string
+
+ beforeAll(async () => {
+ fixture = await readFile(SEED, 'utf8')
+ const prompts = fixtureUserPrompts(fixture)
+ expect(prompts).toHaveLength(1)
+ prompt = prompts[0]!
+ const continuation = deriveReplayScript(parseSeedFixture(fixture).events).at(-1)
+ expect(continuation?.kind).toBe('chunks')
+ replayDir = await mkdtemp(join(tmpdir(), 'dsh-collapse-timing-replay-'))
+ const replayOverride = join(replayDir, 'replay.override.json')
+ // Reuse the recorded final reasoning and answer without repeating historical tool-call ids.
+ await writeFile(replayOverride, JSON.stringify([continuation]))
+ scaffold = await launchWebScaffold({
+ replayFixture: SEED,
+ replayOverride,
+ // This rendering borrows a completed turn and replays only its final response as a continuation.
+ compareReplaySession: false,
+ })
+ await seedSession(scaffold, fixture, SEED_ID)
+ browser = await chromium.launch({ headless: true })
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await page.getByRole('button', { name: 'Settings', exact: true }).waitFor()
+ })
+
+ afterAll(async () => {
+ const failures: unknown[] = []
+ await browser?.close().catch((error: unknown) => failures.push(error))
+ await scaffold?.close().catch((error: unknown) => failures.push(error))
+ if (replayDir !== undefined) {
+ await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
+ }
+ if (failures.length > 0) throw new AggregateError(failures, 'collapse-timing e2e cleanup failed')
+ })
+
+ async function selectPreference(title: string, choice: string): Promise {
+ await openSettings(page, 'en')
+ const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
+ const row = dialog.getByText(title, { exact: true }).locator('../..')
+ await row.getByRole('button').click()
+ await page.getByRole('menuitem', { name: choice, exact: true }).click()
+ await row.getByRole('button', { name: choice, exact: true }).waitFor()
+ if (title === 'Work details') {
+ await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, UI_WAIT)
+ .toMatchObject({ transcriptView: choice.toLowerCase() })
+ }
+ await dialog.getByRole('button', { name: 'Close', exact: true }).click()
+ }
+
+ async function capture(name: string): Promise {
+ await page.mouse.move(0, 0)
+ const aria = await captureStableAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(join(SNAPSHOT_DIR, name), aria, MODE)
+ }
+
+ async function expectDetails(turn: number, visible: boolean): Promise {
+ const reasoning = page.locator(`[data-chat-turn="${turn}"] [data-variant="think"]`).last()
+ expect(await reasoning.count()).toBe(1)
+ await expect.poll(() => reasoning.isVisible(), UI_WAIT).toBe(visible)
+ }
+
+ async function expectProcess(turn: number, expanded: boolean): Promise {
+ const process = page.locator(`[data-turn-process="${turn}"]`)
+ await expect.poll(() => process.getAttribute('aria-expanded'), UI_WAIT).toBe(String(expanded))
+ await expectDetails(turn, expanded)
+ }
+
+ it('folds at completion, defers the latest process until ordinary input, and keeps Verbose open', async () => {
+ onTestFailed(async () => {
+ await mkdir(SCREENSHOT_DIR, { recursive: true })
+ const directory = await mkdtemp(join(SCREENSHOT_DIR, 'recorded-'))
+ await saveFailureShot(page, `${SCREENSHOTS}/${basename(directory)}/failure`)
+ })
+ // Selecting the current client default does not create a saved Host preference.
+ await selectPreference('Work details', 'Compact')
+ await selectPreference('Work details', 'Detailed')
+ const group = page.getByRole('treeitem').first()
+ await group.waitFor()
+ if (await group.getAttribute('aria-expanded') === 'false') await group.click()
+ await page.getByRole('treeitem').nth(1).click()
+ await page.getByText('DONE', { exact: true }).waitFor()
+ await expectProcess(1, false)
+ await capture('completion.expected.md')
+
+ await selectPreference('When to Collapse Work Details', 'On next message')
+ await expectProcess(1, true)
+ await page.getByRole('button', { name: 'Read a.txt', exact: true }).waitFor()
+ await capture('next-input.expected.md')
+
+ await selectPreference('When to Collapse Work Details', 'On completion')
+ await expectProcess(1, false)
+ await capture('completion-restored.expected.md')
+
+ await selectPreference('When to Collapse Work Details', 'On next message')
+ await expectProcess(1, true)
+ const input = page.locator('[data-composer-input]').last()
+ await writeComposerDraft(page, input, prompt)
+ await expectProcess(1, true)
+ const release = Promise.withResolvers()
+ const promptBlocked = Promise.withResolvers()
+ const holdPrompt = async (route: Route): Promise => {
+ promptBlocked.resolve(undefined)
+ await release.promise
+ await route.continue()
+ }
+ await page.route('**/api/session/prompt', holdPrompt)
+ try {
+ await page.getByRole('button', { name: 'Send message', exact: true }).click()
+ const blockedTimeout = setTimeout(() => {
+ promptBlocked.reject(new Error('session prompt did not reach the route barrier'))
+ }, UI_WAIT.timeout)
+ try {
+ await promptBlocked.promise
+ } finally {
+ clearTimeout(blockedTimeout)
+ }
+ await expect.poll(() => page.locator('[data-submission-echo]').count(), UI_WAIT).toBe(1)
+ await expectProcess(1, false)
+ expect(await page.locator('[data-turn-process="2"]').count()).toBe(0)
+ await capture('input-pending.expected.md')
+ const settled = scaffold.whenTurnSettled()
+ release.resolve(undefined)
+ expect(await settled).toBe(SEED_ID)
+ } finally {
+ release.resolve(undefined)
+ await page.unrouteAll({ behavior: 'wait' })
+ }
+ await expect.poll(() => page.getByText('DONE', { exact: true }).count(), UI_WAIT).toBe(2)
+ await expect.poll(() => page.locator('[data-streaming="true"]').count(), UI_WAIT).toBe(0)
+ await expectProcess(1, false)
+ await expectProcess(2, true)
+ const persisted = await readPersistedEvents(scaffold, SessionId(SEED_ID))
+ expect(persisted.filter(event => event.type === 'turn/end').map(event => event.data)).toEqual([
+ { turn: 1, reason: { kind: 'completed' } },
+ { turn: 2, reason: { kind: 'completed' } },
+ ])
+ await capture('next-message.expected.md')
+
+ await selectPreference('Work details', 'Verbose')
+ await selectPreference('When to Collapse Work Details', 'On completion')
+ await expectDetails(1, true)
+ await expectDetails(2, true)
+ expect(await page.locator('[data-turn-process]:enabled').count()).toBe(0)
+ await capture('verbose.expected.md')
+ expect(await readFile(SEED, 'utf8')).toBe(fixture)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, GOLDENS)
+ })
+})
diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts
index 235db04364..3b04ece1e7 100644
--- a/apps/web/tests/cot-translation-paid.e2e.ts
+++ b/apps/web/tests/cot-translation-paid.e2e.ts
@@ -168,6 +168,7 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
await settings.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await settings.getByRole('button', { name: '插件', exact: true }).click()
const panel = settings.locator('[data-plugin-panel]'), card = panel.locator(`[data-plugin-package="${BUNDLE_NAME}"]`)
+ await panel.getByRole('button', { name: '更多', exact: true }).click()
const toggle = card.getByRole('switch'); await toggle.waitFor()
expect(await toggle.getAttribute('aria-checked')).toBe('false')
await toggle.click()
@@ -192,10 +193,10 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
await expect.poll(() => translated.getAttribute('data-translation-state')).toBe('ready')
expect(calls).toHaveLength(1)
await compareOrRefreshGolden(join(EXPECTED, 'paid-result.expected.md'),
- await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE)
- await translated.getByRole('button', { name: '原文', exact: true }).click()
+ await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), MODE)
+ await translated.getByRole('button', { name: '查看原文', exact: true }).click()
await translated.getByText(ORIGINAL, { exact: true }).waitFor()
- await translated.getByRole('button', { name: '译文', exact: true }).click()
+ await translated.getByRole('button', { name: '查看译文', exact: true }).click()
expect(calls).toHaveLength(1)
const saved = await readPersistedEvents(scaffold, auditSessionId)
expect(saved.filter(event => !isTranslationRecord(event))).toEqual(events)
diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts
index beb296eeda..9a804fe8b5 100644
--- a/apps/web/tests/cot-translation.e2e.ts
+++ b/apps/web/tests/cot-translation.e2e.ts
@@ -195,6 +195,7 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await settings.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await settings.getByRole('button', { name: '插件', exact: true }).click()
const panel = settings.locator('[data-plugin-panel]')
+ await panel.getByRole('button', { name: '更多', exact: true }).click()
const card = panel.locator(`[data-plugin-package="${BUNDLE_NAME}"]`)
const toggle = card.getByRole('switch')
await toggle.waitFor()
@@ -219,11 +220,35 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready')
expect(calls).toEqual([{ provider: 'bing', text: ORIGINAL, target: 'zh-Hans', hasAuthorization: false, hasCookie: false }])
await compareOrRefreshGolden(join(EXPECTED, 'bing.expected.md'),
- await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE)
- await body.getByRole('button', { name: '原文', exact: true }).click()
+ await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), MODE)
+ const markdown = body.locator('[data-markdown-variant="compact"]')
+ const typography = () => markdown.evaluate((element) => {
+ const style = getComputedStyle(element)
+ return [style.fontFamily, style.fontSize, style.fontWeight, style.lineHeight, style.color, style.padding, style.margin]
+ })
+ const translatedTypography = await typography()
+ const originalAction = body.getByRole('button', { name: '查看原文', exact: true })
+ await composer.focus()
+ await page.mouse.move(0, 0)
+ await expect.poll(() => originalAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
+ expect(await originalAction.evaluate(element => element.closest('[data-disclosure-header]') === null)).toBe(true)
+ await originalAction.focus()
+ await expect.poll(() => originalAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
+ await originalAction.press('Enter')
await body.getByText(ORIGINAL, { exact: true }).waitFor()
expect(await body.getAttribute('data-translation-view')).toBe('original')
- await body.getByRole('button', { name: '译文', exact: true }).click()
+ expect(await typography()).toEqual(translatedTypography)
+ await page.mouse.move(0, 0)
+ const translationAction = body.getByRole('button', { name: '查看译文', exact: true })
+ const titleBox = await reasoning.getByRole('button', { name: '思考', exact: true }).boundingBox()
+ const actionBox = await translationAction.boundingBox()
+ if (titleBox === null || actionBox === null) throw new Error('Reasoning title and translation action must be visible')
+ expect(actionBox.y).toBeGreaterThanOrEqual(titleBox.y + titleBox.height)
+ expect(await translationAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
+ await translationAction.click()
+ await composer.focus()
+ await reasoning.hover()
+ await expect.poll(() => originalAction.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
await body.getByText(BING_TRANSLATION, { exact: true }).waitFor()
expect(calls).toHaveLength(1)
const savedBing = await readPersistedEvents(scaffold, sessionId)
@@ -275,13 +300,13 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await save.click()
await expect.poll(() => save.isDisabled()).toBe(true)
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('failed')
- await body.getByText('翻译失败,已保留原文', { exact: true }).waitFor()
+ await body.getByText('翻译暂不可用,已显示原文', { exact: true }).waitFor()
await body.getByText(ORIGINAL, { exact: true }).waitFor()
expect(await body.getAttribute('data-translation-view')).toBe('original')
expect(calls[2]).toEqual({ provider: 'google', text: ORIGINAL, target: 'ja', hasAuthorization: false, hasCookie: false })
expect(calls).toHaveLength(3)
await compareOrRefreshGolden(join(EXPECTED, 'failure.expected.md'),
- await captureStableAria(page, '[data-cot-translation]', scaffold.workspaceCwd), MODE)
+ await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), MODE)
fail = false
await body.getByRole('button', { name: '重试', exact: true }).click()
await body.getByText(JAPANESE_TRANSLATION, { exact: true }).waitFor()
@@ -315,7 +340,7 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await expect.poll(() => body.getAttribute('data-translation-state')).toBe('ready')
expect(calls).toHaveLength(6)
- await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
+ await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false')
await body.waitFor({ state: 'detached' })
diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
index 4b5ac3b259..7275ed50b2 100644
--- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
+++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md
@@ -1,4 +1,4 @@
-- button "返回插件列表": 插件列表
+- button "返回 实验性插件": 实验性插件
- button "卸载 思考过程机器翻译" [disabled]: 卸载
- switch "启用 思考过程机器翻译" [checked]
- heading "思考过程机器翻译" [level=3]
diff --git a/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md
index 07d4103eff..314344b0e0 100644
--- a/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md
+++ b/apps/web/tests/expected/cot-translation-paid/paid-result.expected.md
@@ -1,3 +1,3 @@
-- button "译文" [pressed]
-- button "原文"
+- button "思考" [expanded]
+- button "查看原文"
- paragraph: 用户要求只回复一个词,我会照做。
diff --git a/apps/web/tests/expected/cot-translation/bing.expected.md b/apps/web/tests/expected/cot-translation/bing.expected.md
index 3e7616bb7f..0a9dc0e8a2 100644
--- a/apps/web/tests/expected/cot-translation/bing.expected.md
+++ b/apps/web/tests/expected/cot-translation/bing.expected.md
@@ -1,3 +1,3 @@
-- button "译文" [pressed]
-- button "原文"
+- button "思考" [expanded]
+- button "查看原文"
- paragraph: 用户要求我回复一个单词。
diff --git a/apps/web/tests/expected/cot-translation/failure.expected.md b/apps/web/tests/expected/cot-translation/failure.expected.md
index 2b3e993532..b35237dbfa 100644
--- a/apps/web/tests/expected/cot-translation/failure.expected.md
+++ b/apps/web/tests/expected/cot-translation/failure.expected.md
@@ -1,5 +1,5 @@
-- button "译文" [disabled]
-- button "原文" [pressed]
-- status: 翻译失败,已保留原文
+- button "思考" [expanded]
+- button "查看译文" [disabled]
+- status: 翻译暂不可用,已显示原文
- button "重试"
- paragraph: The user wants me to reply with a single word. Let me comply.
diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md
index 48f64db554..f4735cc02e 100644
--- a/apps/web/tests/expected/cot-translation/preferences.expected.md
+++ b/apps/web/tests/expected/cot-translation/preferences.expected.md
@@ -1,4 +1,4 @@
-- button "返回插件列表": 插件列表
+- button "返回 实验性插件": 实验性插件
- button "卸载 思考过程机器翻译" [disabled]: 卸载
- switch "启用 思考过程机器翻译" [checked]
- heading "思考过程机器翻译" [level=3]
diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
index 47440f7c24..271acfa0a2 100644
--- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
+++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md
@@ -29,7 +29,7 @@
- text: deepseek-flash
- textbox "显示名称 1":
- /placeholder: 显示名称
- - text: DeepSeek-V41-Flash
+ - text: DeepSeek-V4.1-Flash
- button "模型选项 1"
- button "删除模型 1"
- textbox "模型 ID 2":
diff --git a/apps/web/tests/expected/desktop-locale/automatic.expected.md b/apps/web/tests/expected/desktop-locale/automatic.expected.md
index e0e2e8a50b..b24ebca8d2 100644
--- a/apps/web/tests/expected/desktop-locale/automatic.expected.md
+++ b/apps/web/tests/expected/desktop-locale/automatic.expected.md
@@ -22,6 +22,8 @@
- button "更多字体设置"
- text: 工作步骤展示 选择希望看到多少工具调用细节
- button "标准"
+ - text: 工作步骤收起时机 选择何时自动收起工作步骤
+ - button "回答结束后"
- text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换
- switch "显示代码工作视图" [checked]
- text: 快捷键
diff --git a/apps/web/tests/expected/desktop-locale/selected.expected.md b/apps/web/tests/expected/desktop-locale/selected.expected.md
index fff8f92651..801a3d9a5d 100644
--- a/apps/web/tests/expected/desktop-locale/selected.expected.md
+++ b/apps/web/tests/expected/desktop-locale/selected.expected.md
@@ -22,6 +22,8 @@
- button "More font settings"
- text: Work details Choose how much detail to show for tool calls
- button "Standard"
+ - text: When to Collapse Work Details Choose when to automatically collapse work details
+ - button "On completion"
- text: Show coding view Shows trajectory, code diffs, and all Agent presets
- switch "Show coding view" [checked]
- text: Keyboard shortcuts
diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
index 803e82b6f0..a4dfae4aa1 100644
--- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
+++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md
@@ -29,7 +29,7 @@
- text: deepseek-flash
- textbox "显示名称 1":
- /placeholder: 显示名称
- - text: DeepSeek-V41-Flash
+ - text: DeepSeek-V4.1-Flash
- button "模型选项 1" [expanded]
- button "删除模型 1"
- text: 上下文窗口
diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md
index f71e0dad28..2e90c68ac0 100644
--- a/apps/web/tests/expected/plugin-config/bundle.expected.md
+++ b/apps/web/tests/expected/plugin-config/bundle.expected.md
@@ -1,4 +1,4 @@
-- button "返回插件列表": 插件列表
+- button "返回插件": 插件
- button "夹具操作"
- button "卸载 @fixture/live-client": 卸载
- switch "启用 @fixture/live-client" [checked]
diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md
index aee4cb8cdf..37d3a232b4 100644
--- a/apps/web/tests/expected/plugin-config/official.expected.md
+++ b/apps/web/tests/expected/plugin-config/official.expected.md
@@ -5,75 +5,40 @@
- group "添加插件":
- button "添加插件"
- button "选择添加插件方式"
-- heading "官方" [level=3]
-- text: "17"
+- heading "内置插件配置" [level=3]
- list:
- - listitem:
- - button "查看 智能体团队": 智能体团队
- - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- - switch "启用 智能体团队"
- - listitem:
- - button "查看 自动授权审查": 自动授权审查
- - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- - switch "启用 自动授权审查"
- - listitem:
- - button "查看 DSH 徽章技能": DSH 徽章技能
- - text: 实验性 提供可发现的 powered-by-DSH 徽章技能。
- - switch "启用 DSH 徽章技能"
- - listitem:
- - button "查看 思考过程机器翻译": 思考过程机器翻译
- - text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务
- - switch "启用 思考过程机器翻译"
- - listitem:
- - button "查看 开发者工具": 开发者工具
- - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- - switch "启用 开发者工具"
- - listitem:
- - button "查看 Ralph 循环": Ralph 循环
- - text: 实验性 使用新智能体执行用户明确要求的循环工作。
- - switch "启用 Ralph 循环"
- - listitem:
- - button "查看 会话搜索": 会话搜索
- - text: 实验性 为智能体提供搜索和读取当前工作区历史会话的工具。
- - switch "启用 会话搜索"
- - listitem:
- - button "查看 随对话更新的标题": 随对话更新的标题
- - text: 实验性 在收到新的用户提示时更新会话标题。
- - switch "启用 随对话更新的标题"
- - listitem:
- - button "查看 持久终端": 持久终端
- - text: 实验性 让 shell 进程在多次工具调用之间保持运行。
- - switch "启用 持久终端"
- - listitem:
- - button "查看 Git 工作树": Git 工作树
- - text: 实验性 让智能体创建新的 Git 分支与工作树,并在其中继续工作。
- - switch "启用 Git 工作树"
- - listitem:
- - button "查看 语音输入": 语音输入
- - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- - switch "启用 语音输入"
- - listitem:
- - button "查看 Claude Code 子智能体": Claude Code 子智能体
- - text: 未安装 使用 Claude Code 原生认证和设置委派单个任务。
- - switch "启用 Claude Code 子智能体"
- - listitem:
- - button "查看 Codex 子智能体": Codex 子智能体
- - text: 未安装 使用 Codex 原生认证和设置委派单个任务。
- - switch "启用 Codex 子智能体"
- - listitem:
- - button "查看 终端": 终端
- - text: 限制每条命令最多能跑多久、最多输出多少内容。
- - listitem:
- - button "查看 Agent 循环": Agent 循环
- - text: 控制 Agent 派发工具调用的方式。
- listitem:
- button "查看 子智能体": 子智能体
- text: 设置子智能体的递归层级、数量和模型。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: 设置 DeepSeek 的搜索提供方。
-- heading "已安装" [level=3]
-- text: "2"
+ - listitem:
+ - button "查看 Agent 循环": Agent 循环
+ - text: 控制 Agent 派发工具调用的方式。
+ - listitem:
+ - button "查看 终端": 终端
+ - text: 限制每条命令最多能跑多久、最多输出多少内容。
+- heading "实验性插件" [level=3]
+- button "更多"
+- list:
+ - listitem:
+ - button "查看 智能体团队": 智能体团队
+ - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
+ - switch "启用 智能体团队"
+ - listitem:
+ - button "查看 语音输入": 语音输入
+ - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
+ - switch "启用 语音输入"
+ - listitem:
+ - button "查看 自动授权审查": 自动授权审查
+ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
+ - switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 开发者工具": 开发者工具
+ - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
+ - switch "启用 开发者工具"
+- heading "已安装插件" [level=3]
- list:
- listitem:
- button "查看 @fixture/live-client": "@fixture/live-client"
diff --git a/apps/web/tests/expected/plugin-config/subagent.expected.md b/apps/web/tests/expected/plugin-config/subagent.expected.md
index 1f324d7161..74bf83d3ea 100644
--- a/apps/web/tests/expected/plugin-config/subagent.expected.md
+++ b/apps/web/tests/expected/plugin-config/subagent.expected.md
@@ -1,4 +1,4 @@
-- button "返回插件列表": 插件列表
+- button "返回插件": 插件
- heading "子智能体" [level=3]
- paragraph: 设置子智能体的递归层级、数量和模型。
- region "运行限制":
diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md
index 2a86287511..702150c359 100644
--- a/apps/web/tests/expected/plugin-manager/exports.expected.md
+++ b/apps/web/tests/expected/plugin-manager/exports.expected.md
@@ -1,4 +1,4 @@
-- button "返回插件列表": 插件列表
+- button "返回插件": 插件
- button "卸载 @fixture/bundle": 卸载
- switch "启用 @fixture/bundle"
- heading "@fixture/bundle" [level=3]
diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
index 975951d9eb..eab9dd48e1 100644
--- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
+++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md
@@ -5,75 +5,40 @@
- group "添加插件":
- button "添加插件"
- button "选择添加插件方式"
-- heading "官方" [level=3]
-- text: "17"
+- heading "内置插件配置" [level=3]
- list:
- - listitem:
- - button "查看 智能体团队": 智能体团队
- - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- - switch "启用 智能体团队"
- - listitem:
- - button "查看 自动授权审查": 自动授权审查
- - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- - switch "启用 自动授权审查"
- - listitem:
- - button "查看 DSH 徽章技能": DSH 徽章技能
- - text: 实验性 提供可发现的 powered-by-DSH 徽章技能。
- - switch "启用 DSH 徽章技能"
- - listitem:
- - button "查看 思考过程机器翻译": 思考过程机器翻译
- - text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务
- - switch "启用 思考过程机器翻译"
- - listitem:
- - button "查看 开发者工具": 开发者工具
- - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- - switch "启用 开发者工具"
- - listitem:
- - button "查看 Ralph 循环": Ralph 循环
- - text: 实验性 使用新智能体执行用户明确要求的循环工作。
- - switch "启用 Ralph 循环"
- - listitem:
- - button "查看 会话搜索": 会话搜索
- - text: 实验性 为智能体提供搜索和读取当前工作区历史会话的工具。
- - switch "启用 会话搜索"
- - listitem:
- - button "查看 随对话更新的标题": 随对话更新的标题
- - text: 实验性 在收到新的用户提示时更新会话标题。
- - switch "启用 随对话更新的标题"
- - listitem:
- - button "查看 持久终端": 持久终端
- - text: 实验性 让 shell 进程在多次工具调用之间保持运行。
- - switch "启用 持久终端"
- - listitem:
- - button "查看 Git 工作树": Git 工作树
- - text: 实验性 让智能体创建新的 Git 分支与工作树,并在其中继续工作。
- - switch "启用 Git 工作树"
- - listitem:
- - button "查看 语音输入": 语音输入
- - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- - switch "启用 语音输入"
- - listitem:
- - button "查看 Claude Code 子智能体": Claude Code 子智能体
- - text: 未安装 使用 Claude Code 原生认证和设置委派单个任务。
- - switch "启用 Claude Code 子智能体"
- - listitem:
- - button "查看 Codex 子智能体": Codex 子智能体
- - text: 未安装 使用 Codex 原生认证和设置委派单个任务。
- - switch "启用 Codex 子智能体"
- - listitem:
- - button "查看 终端": 终端
- - text: 限制每条命令最多能跑多久、最多输出多少内容。
- - listitem:
- - button "查看 Agent 循环": Agent 循环
- - text: 控制 Agent 派发工具调用的方式。
- listitem:
- button "查看 子智能体": 子智能体
- text: 设置子智能体的递归层级、数量和模型。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: 设置 DeepSeek 的搜索提供方。
-- heading "已安装" [level=3]
-- text: "2"
+ - listitem:
+ - button "查看 Agent 循环": Agent 循环
+ - text: 控制 Agent 派发工具调用的方式。
+ - listitem:
+ - button "查看 终端": 终端
+ - text: 限制每条命令最多能跑多久、最多输出多少内容。
+- heading "实验性插件" [level=3]
+- button "更多"
+- list:
+ - listitem:
+ - button "查看 智能体团队": 智能体团队
+ - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
+ - switch "启用 智能体团队"
+ - listitem:
+ - button "查看 语音输入": 语音输入
+ - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
+ - switch "启用 语音输入"
+ - listitem:
+ - button "查看 自动授权审查": 自动授权审查
+ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
+ - switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 开发者工具": 开发者工具
+ - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
+ - switch "启用 开发者工具"
+- heading "已安装插件" [level=3]
- list:
- listitem:
- button "查看 @fixture/bundle": "@fixture/bundle"
diff --git a/apps/web/tests/expected/plugin-manager/loading.expected.md b/apps/web/tests/expected/plugin-manager/loading.expected.md
index 4cf8d9ea23..01f06171a3 100644
--- a/apps/web/tests/expected/plugin-manager/loading.expected.md
+++ b/apps/web/tests/expected/plugin-manager/loading.expected.md
@@ -7,8 +7,8 @@
- button "选择添加插件方式" [disabled]
- status "正在读取插件…"
-1680px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
-1000px: 4 rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px
+1680px: 4 rows; page/group headers, rows, icons and text line origins align within 0.1px
+1000px: 4 rows; page/group headers, rows, icons and text line origins align within 0.1px
Loading: header actions disabled; action spaces are empty 36×20 areas, with no switch placeholders
Loaded: skeleton removed; header actions enabled
Motion: 13 opacity-only pulses, 2000ms; reduced motion stops all pulses
diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md
index 33570a25d2..f3dd4a7bc6 100644
--- a/apps/web/tests/expected/plugin-manager/manager.expected.md
+++ b/apps/web/tests/expected/plugin-manager/manager.expected.md
@@ -5,75 +5,40 @@
- group "添加插件":
- button "添加插件"
- button "选择添加插件方式"
-- heading "官方" [level=3]
-- text: "17"
+- heading "内置插件配置" [level=3]
- list:
- - listitem:
- - button "查看 智能体团队": 智能体团队
- - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- - switch "启用 智能体团队"
- - listitem:
- - button "查看 自动授权审查": 自动授权审查
- - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
- - switch "启用 自动授权审查"
- - listitem:
- - button "查看 DSH 徽章技能": DSH 徽章技能
- - text: 实验性 提供可发现的 powered-by-DSH 徽章技能。
- - switch "启用 DSH 徽章技能"
- - listitem:
- - button "查看 思考过程机器翻译": 思考过程机器翻译
- - text: 实验性 机器翻译展开的思考内容;原文会发送给所选服务
- - switch "启用 思考过程机器翻译"
- - listitem:
- - button "查看 开发者工具": 开发者工具
- - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
- - switch "启用 开发者工具"
- - listitem:
- - button "查看 Ralph 循环": Ralph 循环
- - text: 实验性 使用新智能体执行用户明确要求的循环工作。
- - switch "启用 Ralph 循环"
- - listitem:
- - button "查看 会话搜索": 会话搜索
- - text: 实验性 为智能体提供搜索和读取当前工作区历史会话的工具。
- - switch "启用 会话搜索"
- - listitem:
- - button "查看 随对话更新的标题": 随对话更新的标题
- - text: 实验性 在收到新的用户提示时更新会话标题。
- - switch "启用 随对话更新的标题"
- - listitem:
- - button "查看 持久终端": 持久终端
- - text: 实验性 让 shell 进程在多次工具调用之间保持运行。
- - switch "启用 持久终端"
- - listitem:
- - button "查看 Git 工作树": Git 工作树
- - text: 实验性 让智能体创建新的 Git 分支与工作树,并在其中继续工作。
- - switch "启用 Git 工作树"
- - listitem:
- - button "查看 语音输入": 语音输入
- - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
- - switch "启用 语音输入"
- - listitem:
- - button "查看 Claude Code 子智能体": Claude Code 子智能体
- - text: 未安装 使用 Claude Code 原生认证和设置委派单个任务。
- - switch "启用 Claude Code 子智能体"
- - listitem:
- - button "查看 Codex 子智能体": Codex 子智能体
- - text: 未安装 使用 Codex 原生认证和设置委派单个任务。
- - switch "启用 Codex 子智能体"
- - listitem:
- - button "查看 终端": 终端
- - text: 限制每条命令最多能跑多久、最多输出多少内容。
- - listitem:
- - button "查看 Agent 循环": Agent 循环
- - text: 控制 Agent 派发工具调用的方式。
- listitem:
- button "查看 子智能体": 子智能体
- text: 设置子智能体的递归层级、数量和模型。
- listitem:
- button "查看 网页搜索": 网页搜索
- text: 设置 DeepSeek 的搜索提供方。
-- heading "已安装" [level=3]
-- text: "2"
+ - listitem:
+ - button "查看 Agent 循环": Agent 循环
+ - text: 控制 Agent 派发工具调用的方式。
+ - listitem:
+ - button "查看 终端": 终端
+ - text: 限制每条命令最多能跑多久、最多输出多少内容。
+- heading "实验性插件" [level=3]
+- button "更多"
+- list:
+ - listitem:
+ - button "查看 智能体团队": 智能体团队
+ - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
+ - switch "启用 智能体团队"
+ - listitem:
+ - button "查看 语音输入": 语音输入
+ - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。
+ - switch "启用 语音输入"
+ - listitem:
+ - button "查看 自动授权审查": 自动授权审查
+ - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。
+ - switch "启用 自动授权审查"
+ - listitem:
+ - button "查看 开发者工具": 开发者工具
+ - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端
+ - switch "启用 开发者工具"
+- heading "已安装插件" [level=3]
- list:
- listitem:
- button "查看 @fixture/bundle": "@fixture/bundle"
diff --git a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
index c1658e9fe2..268ad18e27 100644
--- a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
+++ b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md
@@ -1,4 +1,4 @@
-- button "返回插件列表": 插件列表
+- button "返回插件": 插件
- button "卸载 @fixture/missing-bundle": 卸载
- switch "启用 @fixture/missing-bundle" [checked]
- heading "@fixture/missing-bundle" [level=3]
diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md
index 1ed42daaa5..75dda78845 100644
--- a/apps/web/tests/expected/plugin-manager/refresh.expected.md
+++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md
@@ -6,7 +6,7 @@ Focus at 500ms:
- tooltip "刷新"
failure: pending
- button "刷新" [disabled]
-{"busy":"true","spinners":1,"cards":20,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
+{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
failure: settled
- button "刷新"
-{"busy":"false","spinners":0,"cards":20,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
+{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
diff --git a/apps/web/tests/expected/settings-chrome/collapse-timing-en.expected.md b/apps/web/tests/expected/settings-chrome/collapse-timing-en.expected.md
new file mode 100644
index 0000000000..f06030ef91
--- /dev/null
+++ b/apps/web/tests/expected/settings-chrome/collapse-timing-en.expected.md
@@ -0,0 +1,3 @@
+- menu:
+ - menuitem "On completion"
+ - menuitem "On next message"
diff --git a/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md b/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md
new file mode 100644
index 0000000000..e7b47ba72f
--- /dev/null
+++ b/apps/web/tests/expected/settings-chrome/collapse-timing-zh.expected.md
@@ -0,0 +1,3 @@
+- menu:
+ - menuitem "回答结束后"
+ - menuitem "下次有新消息时"
diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
index c59d632773..13cd42f9d7 100644
--- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md
@@ -22,6 +22,8 @@
- button "More font settings"
- text: Work details Choose how much detail to show for tool calls
- button "Detailed"
+ - text: When to Collapse Work Details Choose when to automatically collapse work details
+ - button "On completion"
- text: Show coding view Shows trajectory, code diffs, and all Agent presets
- switch "Show coding view"
- text: Keyboard shortcuts
diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
index c59d632773..13cd42f9d7 100644
--- a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md
@@ -22,6 +22,8 @@
- button "More font settings"
- text: Work details Choose how much detail to show for tool calls
- button "Detailed"
+ - text: When to Collapse Work Details Choose when to automatically collapse work details
+ - button "On completion"
- text: Show coding view Shows trajectory, code diffs, and all Agent presets
- switch "Show coding view"
- text: Keyboard shortcuts
diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md
index a9705c27f8..df60e07cde 100644
--- a/apps/web/tests/expected/settings-chrome/dialog.expected.md
+++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md
@@ -22,6 +22,8 @@
- button "更多字体设置"
- text: 工作步骤展示 选择希望看到多少工具调用细节
- button "详细"
+ - text: 工作步骤收起时机 选择何时自动收起工作步骤
+ - button "回答结束后"
- text: 显示代码工作视图 开启后,显示轨迹、本轮代码差异,可选择完整的 Agent 预设切换
- switch "显示代码工作视图"
- text: 快捷键
diff --git a/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md
new file mode 100644
index 0000000000..0e4f8f018b
--- /dev/null
+++ b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md
@@ -0,0 +1,13 @@
+# Before opening the child
+
+- tree "Subagent sessions":
+ - treeitem "historical researcher Migration required · not running" [level=1]:
+ - text: historical researcher Migration required · not running
+ - button "Open historical researcher in sidebar"
+
+# After opening the child
+
+- tree "Subagent sessions":
+ - treeitem "historical editor Migration required · not running" [level=1]:
+ - text: historical editor Migration required · not running
+ - button "Open historical editor in sidebar"
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index 4f9fba2f43..a9226b7fb7 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -399,14 +399,13 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const processWasOpen = await process.getAttribute('aria-expanded') === 'true'
try {
await expandOwningTurnProcess(page, thinkRow)
- const collapsedHeader = thinkRow.locator('[data-disclosure-row]').first()
+ const collapsedHeader = thinkRow.locator('[data-disclosure-header]').first()
await collapsedHeader.waitFor({ timeout: 10_000 })
// Collapsed, the rule's `data-open` gate is absent and the header stays in
- // flow. It is `relative` here — the row is the sweep-glare overlay anchor
- // — so the assertion is the absence of `sticky`, not a specific value.
+ // flow, so assert the absence of `sticky`, not a specific value.
expect(await collapsedHeader.evaluate(element => getComputedStyle(element).position)).not.toBe('sticky')
- await collapsedHeader.click()
- const openHeader = page.locator('[data-variant="think"] [data-open] [data-disclosure-row]').first()
+ await collapsedHeader.locator('[data-disclosure-row]').click()
+ const openHeader = page.locator('[data-variant="think"] [data-open] [data-disclosure-header]').first()
await openHeader.waitFor({ timeout: 10_000 })
const openStyle = await openHeader.evaluate((element) => {
const style = getComputedStyle(element)
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index 2af1c89b66..1eedb317fd 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -41,6 +41,7 @@ const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md')
const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md')
+const RETRY_REQUEST_EXPECTED = join(SNAPSHOT_DIR, 'retry-request.expected.md')
const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md')
const MODE = webSnapshotMode()
const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
@@ -342,11 +343,22 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// sourced from the recording, never copied into a committed sidecar.
{ at: 1, entry: derived[0]! },
],
- }))
+ }), {
+ mode: 'normal', maxRetries: 5, retryableCodes: ['SERVER'],
+ backoff: { initialDelayMs: 25, maxDelayMs: 25, jitterRatio: 0 },
+ })
onTestFailed(() => saveFailureShot(page, 'web-e2e-retry'))
const releaseFailure = Promise.withResolvers()
+ const retryReady = Promise.withResolvers()
+ const releaseRetry = Promise.withResolvers()
+ const requestStates: string[] = []
+ let attempts = 0
// Retirement must follow a visible partial, not race the first browser paint.
const stopHolding = scaffold!.ctx.on('llm/stream', async function* (_request, next) {
+ if (++attempts === 2) {
+ retryReady.resolve(undefined)
+ await releaseRetry.promise
+ }
for await (const chunk of next()) {
yield chunk
if (chunk.type === 'text-delta' && chunk.text === RETRY_PARTIAL) await releaseFailure.promise
@@ -356,9 +368,20 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
const { settled } = await sendPrompt(60_000)
await page.getByText(RETRY_PARTIAL, { exact: true }).waitFor({ timeout: 15_000 })
releaseFailure.resolve(undefined)
+ await retryReady.promise
+ await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+ await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click()
+ const overview = page.getByRole('tabpanel').locator('dl').first()
+ await overview.getByText('Pending', { exact: true }).waitFor()
+ await overview.getByText('Last attempt error', { exact: true }).waitFor()
+ expect(await overview.getByText('Error', { exact: true }).count()).toBe(0)
+ requestStates.push('# Retrying request', await overview.ariaSnapshot())
+ await page.getByRole('tab', { name: 'Chat', exact: true }).click()
+ releaseRetry.resolve(undefined)
await settled
} finally {
releaseFailure.resolve(undefined)
+ releaseRetry.resolve(undefined)
stopHolding()
}
expect(turnEndReasons(sessionEvents).at(-1)).toBe('completed')
@@ -379,6 +402,14 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
scaffold!.workspaceCwd,
)
await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE)
+ await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+ await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click()
+ const overview = page.getByRole('tabpanel').locator('dl').first()
+ await overview.getByText('Completed', { exact: true }).waitFor()
+ await overview.getByText('Last attempt error', { exact: true }).waitFor()
+ expect(await overview.getByText('Error', { exact: true }).count()).toBe(0)
+ requestStates.push('# Recovered request', await overview.ariaSnapshot())
+ await compareOrRefreshGolden(RETRY_REQUEST_EXPECTED, requestStates.join('\n\n'), MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
@@ -420,7 +451,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md',
- 'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md',
+ 'retry.expected.md', 'retry-expanded.expected.md', 'retry-request.expected.md', 'retry-exhausted.expected.md',
])
})
})
diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts
index 122abebc73..390675a3d1 100644
--- a/apps/web/tests/markdown-images.e2e.ts
+++ b/apps/web/tests/markdown-images.e2e.ts
@@ -289,7 +289,18 @@ describe('web e2e: Markdown image rendering', () => {
await page.getByRole('button', { name: `View full image: ${WORKSPACE_ALT}`, exact: true }).click()
const lightbox = page.getByRole('dialog', { name: 'Image preview', exact: true })
await lightbox.waitFor()
- await lightbox.getByRole('button', { name: 'Close image preview' }).click()
+ const lightboxMask = lightbox.locator(':scope > div > [aria-hidden="true"]')
+ const lightboxClose = lightbox.getByRole('button', { name: 'Close image preview' })
+ for (const windows of [true, false]) {
+ await page.evaluate((value) => {
+ document.documentElement.toggleAttribute('data-windows-titlebar', value)
+ document.documentElement.style.setProperty('--dsh-windows-titlebar-height', value ? '40px' : '')
+ }, windows)
+ // The Windows caption stays unpainted and the close control sits below it.
+ expect((await lightboxMask.boundingBox())?.y).toBe(windows ? 40 : 0)
+ expect((await lightboxClose.boundingBox())?.y).toBe(windows ? 60 : 20)
+ }
+ await lightboxClose.click()
await lightbox.waitFor({ state: 'detached' })
const link = page.getByRole('button', { name: 'View comparison', exact: true })
await link.hover()
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
index c8b9c35b55..2254c9dafe 100644
--- a/apps/web/tests/message-actions.e2e.ts
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -3,16 +3,17 @@
// calls) and pins the settled conversation aria after the footers are
// focus-revealed — the surface package jsdom tests cannot substitute for
// (docs/testing.md snapshot rule).
-import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
-import type { Browser, Page } from 'playwright'
+import { zstdCompressSync } from 'node:zlib'
+import type { Browser, Page, Request as BrowserRequest } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import { SessionId } from '@deepseek-ai/dsh-session'
+import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
- launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+ launchWebScaffold, parseSeedFixture, realizeSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts'
@@ -22,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actio
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md')
+const UNOPENED_FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork-unopened.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'message-actions-web-e2e'
@@ -192,7 +194,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
let unpinBrowserClock: (() => void) | undefined
beforeAll(async () => {
- scaffold = await launchWebScaffold({})
+ scaffold = await launchWebScaffold()
+ await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true })
const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
await mkdir(sessionCwd, { recursive: true })
await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n')
@@ -222,7 +225,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
})
it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => {
- onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions'))
+ onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-message-actions'))
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
@@ -423,9 +426,83 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE)
})
+ it.skipIf(MODE === 'record')('forks an unopened current-format Session from its sidebar row', async () => {
+ onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/current-cold-fork'))
+ const id = SessionId('message-actions-current-cold')
+ await seedSession(scaffold, completedTailFixture(await readFile(SEED, 'utf8')), id)
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ const row = page.locator(`[data-row-key="session:${id}"]`)
+ await row.waitFor()
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ expect(scaffold.ctx.agents.get(id)).toBeUndefined()
+ await row.hover()
+ await row.locator('button[aria-label^="Session actions for "]').click()
+ await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click()
+ await expect.poll(() => scaffold.ctx.agents.list().some(agent => agent.session.header.parentSession === id)).toBe(true)
+ expect(scaffold.ctx.agents.get(id)).toBeUndefined()
+ expect(await row.getAttribute('aria-selected')).not.toBe('true')
+ })
+
+ it.skipIf(MODE === 'record')('guides only a migration-required sidebar fork to opening its source', async () => {
+ onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-fork'))
+ const id = SessionId('message-actions-needs-migration')
+ const [header, ...events] = realizeSeedFixture(scaffold, await readFile(SEED, 'utf8'), id).trimEnd().split('\n')
+ .map((line): Record => JSON.parse(line) as Record)
+ expect(header?.version).toBe(3)
+ const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory())
+ expect(projects).toHaveLength(1)
+ const directory = join(scaffold.persistenceRoot, projects[0]!.name, id)
+ const predecessor = join(directory, 'session.v3.jsonl.zstd')
+ const createdAt = WEB_FIXTURE_TIME - 90_000
+ const rows = [{ ...header, id, cwd: scaffold.workspaceCwd, createdAt },
+ ...events.map((event, seq) => ({ ...event, seq, time: createdAt + seq + 1 }))]
+ const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`))))
+ await mkdir(directory, { recursive: true })
+ await writeFile(predecessor, source)
+ await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'migration-required' })
+
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await page.getByRole('button', { name: /^Show \d+ more sessions$/ }).click()
+ const row = page.locator(`[data-row-key="session:${id}"]`)
+ await row.waitFor()
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ const forkRequests: string[] = []
+ const trackFork = (request: BrowserRequest): void => {
+ const path = new URL(request.url()).pathname
+ if (path === '/api/session/fork') forkRequests.push(path)
+ }
+ page.on('request', trackFork)
+ try {
+ await row.hover()
+ await row.locator('button[aria-label^="Session actions for "]').click()
+ await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click()
+ const notice = page.getByRole('alert').filter({ hasText: 'Open the original session to complete migration before creating a branch.' })
+ await notice.waitFor()
+ expect(await page.getByRole('menuitem', { name: 'Fork session', exact: true }).count()).toBe(0)
+ expect(forkRequests).toEqual(['/api/session/fork'])
+ expect(scaffold.ctx.agents.get(id)).toBeUndefined()
+ expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd'])
+ expect(await readFile(predecessor)).toEqual(source)
+ await compareOrRefreshGolden(UNOPENED_FORK_EXPECTED,
+ await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd), MODE)
+ await notice.getByRole('button', { name: 'Open original session', exact: true }).click()
+ await expect.poll(() => row.getAttribute('aria-selected')).toBe('true')
+ await expect.poll(() => scaffold.ctx.agents.get(id)).toBeDefined()
+ await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'current' })
+ expect(await readdir(directory)).toContain(`session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`)
+ expect(await readFile(predecessor)).toEqual(source)
+ expect(forkRequests).toEqual(['/api/session/fork'])
+ expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === id)).toEqual([])
+ } finally {
+ page.off('request', trackFork)
+ }
+ })
+
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
- await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'ui.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'fork-unopened.expected.md', 'ui.expected.md'])
})
})
diff --git a/apps/web/tests/official-subagent-bundle.e2e.ts b/apps/web/tests/official-subagent-bundle.e2e.ts
index aab040691a..8a21671b29 100644
--- a/apps/web/tests/official-subagent-bundle.e2e.ts
+++ b/apps/web/tests/official-subagent-bundle.e2e.ts
@@ -100,10 +100,10 @@ describe.skipIf(MODE === 'record')('web snapshot: Official native subagent bundl
})
expect((await scaffold.ctx.pluginManager.listBundles()).find(bundle => bundle.name === BUNDLE))
.toMatchObject({ official: true, availability: 'profile', enabled: false })
- expect((await scaffold.ctx.agentPresets.compositionInventory()).find(preset => preset.id === 'ptc')?.rows
- .some(row => row.entryId === 'tool-subagent-claude-code')).toBe(false)
+ expect(scaffold.ctx.tools.schemas().some(tool => tool.name === 'subagent_claude_code')).toBe(false)
expect(await scaffold.ctx.pluginManager.setBundleEnabled(BUNDLE, true))
.toMatchObject({ application: 'applied', changed: true })
+ expect(scaffold.ctx.tools.schemas().some(tool => tool.name === 'subagent_claude_code')).toBe(true)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts
index ff5f239a8c..30fc3ee99a 100644
--- a/apps/web/tests/onboarding-deepseek-config.e2e.ts
+++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts
@@ -208,7 +208,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
await deepSeek.locator('xpath=ancestor::li').getByRole('button', { name: '编辑' }).click()
await settings.getByText('自定义设置').click()
expect(await settings.getByLabel('模型 ID 1').inputValue()).toBe('deepseek-flash')
- expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V41-Flash')
+ expect(await settings.getByLabel('显示名称 1').inputValue()).toBe('DeepSeek-V4.1-Flash')
expect(await settings.getByLabel('模型 ID 2').inputValue()).toBe('deepseek-v4-pro')
expect(await settings.getByRole('button', { name: /删除模型/ }).count()).toBe(2)
await settings.getByRole('button', { name: '模型选项 1' }).click()
diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts
index ea2a30d938..fd8217a2d8 100644
--- a/apps/web/tests/plan-review.e2e.ts
+++ b/apps/web/tests/plan-review.e2e.ts
@@ -7,12 +7,14 @@
// Replay is deterministic: the plan content arrives from replayed chunks, the
// review wait is real, and the approve click is the test's own gesture (the
// turn cannot complete without it, in record and replay alike).
-import { readFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, ConsoleMessage, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
@@ -390,8 +392,12 @@ describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar
describe('web e2e: dismissed plan history', () => {
it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => {
- // Replay supplies a finite continuation; the real question rejection and retained document are the assertions.
- const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false, paceMs: 15 })
+ // A dismissed review concludes the turn, so only the call presenting the plan replays.
+ const replayRoot = await mkdtemp(join(tmpdir(), 'dsh-plan-review-dismiss-replay-'))
+ const replayOverride = join(replayRoot, 'replay.override.json')
+ const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
+ await writeFile(replayOverride, JSON.stringify(script.slice(0, 1)))
+ const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride, compareReplaySession: false, paceMs: 15 })
let browser: Browser | undefined
const events: SessionEvent[] = []
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
@@ -417,9 +423,10 @@ describe('web e2e: dismissed plan history', () => {
expect(call).toBeDefined()
const results = events.filter(event => event.type === 'tool/result')
const result = results.find(event => event.data.message.source.callId === call?.data.callId)
- expect(result?.data.message).toMatchObject({ role: 'tool', toolCallId: call?.data.callId, isError: true })
+ expect(result?.data.message).toMatchObject({ role: 'tool', toolCallId: call?.data.callId, isError: false })
expect(JSON.stringify(result)).toContain('dismissed the plan review')
expect(results.some(event => JSON.stringify(event).includes('Plan approved'))).toBe(false)
+ expect(events.filter(event => event.type === 'assistant/message')).toHaveLength(1)
const modes = events.filter(event => event.type === 'plan/mode')
expect(modes).toHaveLength(1)
expect(modes[0]).toMatchObject({ data: { active: true } })
@@ -443,6 +450,7 @@ describe('web e2e: dismissed plan history', () => {
} finally {
await browser?.close()
await scaffold.close()
+ await rm(replayRoot, { recursive: true, force: true })
}
})
})
diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts
index 3afec1b745..21eddc0fee 100644
--- a/apps/web/tests/plugin-config.e2e.ts
+++ b/apps/web/tests/plugin-config.e2e.ts
@@ -68,7 +68,7 @@ describe('web e2e: plugin configuration pages', () => {
while (await panel.getByRole('button', { name: /^返回/ }).count() > 0) {
await panel.getByRole('button', { name: /^返回/ }).first().click()
}
- await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 20_000 })
+ await panel.getByRole('heading', { name: '内置插件配置', exact: true }).waitFor({ timeout: 20_000 })
return panel
}
@@ -83,24 +83,52 @@ describe('web e2e: plugin configuration pages', () => {
return readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8').catch(() => '')
}
- it('lists one official page per exposed host-plane namespace after the official bundles', async () => {
+ it('keeps configuration pages first and opens the full experimental plugin list separately', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards'))
const panel = await openPlugins()
// Every page the shipped web composition exposes: the shell executor, the
- // agent loop, subagent selection, and the DeepSeek search provider, after
- // the shipped optional bundles and offline on-demand catalog.
+ // agent loop, subagent selection, and the DeepSeek search provider.
await panel.getByRole('button', { name: '查看 网页搜索', exact: true }).waitFor({ timeout: 20_000 })
- const official = panel.locator('[data-plugin-group="official"]')
- expect(await official.locator('[data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
- expect(await official.locator('[data-plugin-item]').count()).toBe(4)
- for (const title of ['终端', 'Agent 循环', '子智能体', '网页搜索']) {
- expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
+ const basic = panel.locator('[data-plugin-group="basic"]')
+ expect(await basic.locator('[data-plugin-item]').count()).toBe(4)
+ expect(await basic.locator(':scope > ul > li').evaluateAll(cards => cards.map(card => card.getAttribute('data-plugin-item'))))
+ .toEqual(['subagent', 'web-search', 'agent-loop', 'shell'])
+ for (const title of ['子智能体', '网页搜索', 'Agent 循环', '终端']) {
+ expect(await basic.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1)
}
// A card carries the one-liner; the fields wait for the page.
- expect(await official.getByText('限制每条命令最多能跑多久、最多输出多少内容。', { exact: true }).count()).toBe(1)
+ expect(await basic.getByText('限制每条命令最多能跑多久、最多输出多少内容。', { exact: true }).count()).toBe(1)
expect(await panel.getByLabel('命令超时(毫秒)').count()).toBe(0)
+ const extensions = panel.locator('[data-plugin-group="extensions"]')
+ const more = panel.getByRole('button', { name: '更多', exact: true })
+ expect(await extensions.getByRole('heading', { name: '实验性插件', exact: true }).count()).toBe(1)
+ expect(await extensions.getByRole('button', { name: '更多', exact: true }).textContent()).toBe('更多')
+ expect(await extensions.getByRole('list').isVisible()).toBe(true)
+ expect(await panel.locator('[data-plugin-group="more"]').count()).toBe(0)
+ await more.focus()
+ await page.keyboard.press('Enter')
+ await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
+ expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
+ .toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
+ expect(await basic.count()).toBe(0)
+ expect(await extensions.count()).toBe(0)
+ expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
+ await panel.getByRole('button', { name: '查看 Git 工作树', exact: true }).click()
+ await panel.locator('[data-plugin-detail]').waitFor()
+ await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click()
+ await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
+ await panel.getByRole('button', { name: '返回插件', exact: true }).focus()
+ await page.keyboard.press('Space')
+ expect(await extensions.getByRole('list').isVisible()).toBe(true)
+ expect(await panel.locator('[data-plugin-group="more"]').count()).toBe(0)
+ await more.click()
+ await page.reload({ waitUntil: 'load' })
+ await openPlugins()
+ expect(await extensions.getByRole('list').isVisible()).toBe(true)
+ expect(await panel.locator('[data-plugin-group="more"]').count()).toBe(0)
+
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(OFFICIAL_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
@@ -136,7 +164,7 @@ describe('web e2e: plugin configuration pages', () => {
await openPage(panel, '子智能体')
expect(await depth.inputValue()).toBe('1')
expect(await capacity.inputValue()).toBe('8')
- await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
+ await panel.getByRole('button', { name: '返回插件', exact: true }).click()
})
it('opens field explanations with the keyboard and retains unsaved edits', async () => {
@@ -160,7 +188,7 @@ describe('web e2e: plugin configuration pages', () => {
await panel.getByRole('button', { name: '子智能体并行数量上限说明', exact: true }).click()
const capacityRules = panel.getByRole('region', { name: '子智能体并行数量上限说明', exact: true })
expect(await capacityRules.getByText('同一主 Agent 下,所有递归层级同时存活的子智能体总数,主 Agent 不计入。达到上限时,新的启动请求会被拒绝。', { exact: true }).count()).toBe(1)
- await panel.getByRole('button', { name: '返回插件列表', exact: true }).click()
+ await panel.getByRole('button', { name: '返回插件', exact: true }).click()
await openPage(panel, '子智能体')
expect(await depth.inputValue()).toBe('1')
})
@@ -241,8 +269,8 @@ describe('web e2e: plugin configuration pages', () => {
await timeout.waitFor({ timeout: 10_000 })
await timeout.fill('7000')
- await panel.getByRole('button', { name: '返回插件列表' }).click()
- await panel.getByRole('heading', { name: '官方', exact: true }).waitFor({ timeout: 10_000 })
+ await panel.getByRole('button', { name: '返回插件' }).click()
+ await panel.getByRole('heading', { name: '内置插件配置', exact: true }).waitFor({ timeout: 10_000 })
await openPage(panel, '终端')
await expect.poll(() => panel.getByLabel('命令超时(毫秒)').inputValue(), { timeout: 5_000 }).toBe('12000')
@@ -342,7 +370,7 @@ describe('web e2e: plugin configuration pages', () => {
await bundlePage.waitFor({ timeout: 10_000 })
// An official plugin's page is another subject; the fixture's entries render nothing for it.
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
await openPage(panel, '终端')
const itemPage = panel.locator('[data-plugin-item-detail]')
expect(await itemPage.locator('[data-live-action], [data-live-badge], [data-live-section]').count()).toBe(0)
diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts
index b0fa589d55..fa61e50def 100644
--- a/apps/web/tests/plugin-manager.e2e.ts
+++ b/apps/web/tests/plugin-manager.e2e.ts
@@ -148,7 +148,7 @@ describe('web e2e: plugin manager', () => {
return {
row: rect(row), head: rect(head), icon: rect(head?.children[0]), main: rect(main),
titleRow: rect(main?.children[0]), title: rect(main?.children[0]?.firstElementChild),
- description: rect(main?.children[1]), actions: rect(head?.children[2]),
+ description: rect(main?.children[1]),
}
}),
}
@@ -184,13 +184,11 @@ describe('web e2e: plugin manager', () => {
await skeleton.waitFor({ state: 'detached' })
expect(await panel.getAttribute('aria-busy')).toBe('false')
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(false)
- const official = panel.locator('[data-plugin-group="official"]')
- await official.locator('[data-plugin-package]').first().waitFor()
- // The Official group lists the shipped bundles and then the
- // configuration-only items; an item card carries no action cell, so the
- // placeholder rows model the first four bundle cards.
- const loaded = await measure(official, ':scope > ul > li[data-plugin-package]')
- expect(await official.locator(':scope > ul > li[data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
+ const basic = panel.locator('[data-plugin-group="basic"]')
+ await basic.locator('[data-plugin-item]').first().waitFor()
+ // Configuration cards lead the list; their absent switches give the text column more width.
+ const loaded = await measure(basic)
+ expect(await basic.locator(':scope > ul > li[data-plugin-item]').count()).toBe(4)
expect(loaded.rows).toHaveLength(loading.rows.length)
const compare = (name: string, a: typeof loading.pageHeader, b: typeof loaded.pageHeader, axes: readonly (keyof typeof a)[] = ['x', 'y', 'width', 'height']) => {
for (const axis of axes) {
@@ -202,11 +200,11 @@ describe('web e2e: plugin manager', () => {
for (const [index, row] of loading.rows.entries()) {
const real = loaded.rows[index]
if (real === undefined) break
- for (const part of ['row', 'head', 'icon', 'main', 'titleRow', 'actions'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part])
+ for (const part of ['row', 'head', 'icon'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part])
// Painted text bars are deliberately shorter than real copy; their line origins and heights align.
- for (const part of ['title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height'])
+ for (const part of ['main', 'titleRow', 'title', 'description'] as const) compare(`row ${index + 1} ${part}`, row[part], real[part], ['x', 'y', 'height'])
}
- facts.push(`${width}px: ${loaded.rows.length} rows; page/group headers, rows, icons, text lines and blank action spaces align within 0.1px`)
+ facts.push(`${width}px: ${loaded.rows.length} rows; page/group headers, rows, icons and text line origins align within 0.1px`)
expect(consoleWatch.pageErrors).toEqual([])
} finally {
release.resolve(undefined)
@@ -436,25 +434,29 @@ describe('web e2e: plugin manager', () => {
expect(focusRing.radius).toBe(focusRing.cardRadius)
expect(focusRing.outlineStyle).toBe('solid')
expect(focusRing.outlineWidth).toBeGreaterThan(0)
- // The profile's own group holds its fixture bundle and the scaffold's defaults bundle; the installation's
- // optional bundles open the Official group, followed by the official plugins that registered their
- // configuration, and its other bundles stay off the page.
+ // Profile bundles stay in Installed; configuration pages and offered bundles have their own groups.
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2)
- expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
- expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
- expect(await panel.getByText('实验性', { exact: true }).count())
- .toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length)
+ expect(await panel.locator('[data-plugin-group="basic"] [data-plugin-item]').count()).toBe(4)
expect(await panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-inspector"]').count()).toBe(0)
expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false')
expect(await panel.getByRole('switch', { name: '启用 开发者工具', exact: true }).getAttribute('aria-checked')).toBe('false')
expect(await panel.getByText('查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端', { exact: true }).count()).toBe(1)
expect(await panel.getByText(/Cordis|Chrome DevTools/).count()).toBe(0)
+ await panel.getByRole('button', { name: '更多', exact: true }).click()
+ await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor()
+ expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count())
+ .toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length)
+ expect(await panel.getByText('实验性', { exact: true }).count())
+ .toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length)
+ expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0)
+ await panel.getByRole('button', { name: '返回插件', exact: true }).click()
+ expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2)
// A bundle that is off still shows the rows its patch declares, without switches.
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).count()).toBe(0)
await panel.getByRole('button', { name: '卸载 @fixture/bundle' }).waitFor({ timeout: 5_000 })
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
await expect.poll(() => panel.getByRole('button', { name: '卸载 @fixture/bundle' }).count(), { timeout: 5_000 }).toBe(0)
const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd)
@@ -538,7 +540,7 @@ describe('web e2e: plugin manager', () => {
}
await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click()
await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail')
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click()
await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail')
await checkImage('[data-plugin-row="fixture-search"]', fallbackIcon, 'Independent search row')
@@ -546,7 +548,7 @@ describe('web e2e: plugin manager', () => {
expect(await panel.locator('[data-plugin-row="fixture-review"] svg').count()).toBeGreaterThan(0)
images.push('Independent review row: generic artwork')
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE)
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
expect(tripwire.pageErrors).toEqual([])
})
@@ -579,7 +581,7 @@ describe('web e2e: plugin manager', () => {
} finally {
await setLanguage('zh')
}
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
expect(tripwire.pageErrors).toEqual([])
})
@@ -662,9 +664,9 @@ describe('web e2e: plugin manager', () => {
for (const id of ['agent-team', 'tool-agent-team', 'ui-agent-team']) {
await panel.locator('[data-plugin-row]', { hasText: id }).first().waitFor()
}
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
} finally {
- const back = panel.getByRole('button', { name: '返回插件列表' })
+ const back = panel.getByRole('button', { name: '返回插件' })
if (await back.count() > 0) await back.click()
if (await toggle.getAttribute('aria-checked') === 'true') await toggle.click()
await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(0)
@@ -918,7 +920,7 @@ describe('web e2e: plugin manager', () => {
await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2)
await rowSwitch.click()
await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).toBe(2)
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
await toggle.click()
await expect.poll(() => mounted()?.fiber?.state, { timeout: 20_000 }).not.toBe(2)
@@ -969,7 +971,7 @@ describe('web e2e: startup-applied plugin management', () => {
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
await panel.locator('[data-plugin-row]', { hasText: 'fixture-row' }).waitFor({ timeout: 10_000 })
expect(await panel.getByRole('switch', { name: '启用组件 @fixture/bundle' }).isDisabled()).toBe(true)
- await panel.getByRole('button', { name: '返回插件列表' }).click()
+ await panel.getByRole('button', { name: '返回插件' }).click()
await toggle.click()
await expect.poll(bundles, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toEqual(SCAFFOLD_BUNDLES)
diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts
index ff8b5f8f4a..575b3ad645 100644
--- a/apps/web/tests/ptc-round.e2e.ts
+++ b/apps/web/tests/ptc-round.e2e.ts
@@ -18,6 +18,7 @@ const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.e
const TRAJECTORY_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/trajectory.expected.md', import.meta.url))
const CODE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/code.expected.md', import.meta.url))
const PREPARING_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/preparing.expected.md', import.meta.url))
+const REQUEST_LIFECYCLE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/request-lifecycle.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// Elicits the successful and failed sub-rows this scenario asserts.
@@ -55,6 +56,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
it('drives the recorded prompt to a settled turn (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-drive'))
+ const requestStates: string[] = []
if (MODE !== 'record') {
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
}
@@ -62,7 +64,11 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
await input.waitFor({ timeout: 10_000 })
const settled = scaffold.whenTurnSettled()
const gate = Promise.withResolvers()
+ const waiting = Promise.withResolvers()
+ const start = Promise.withResolvers()
const dispose = scaffold.ctx.on('llm/stream', async function* (_options, next) {
+ waiting.resolve(undefined)
+ await start.promise
let held = false
for await (const chunk of next()) {
yield chunk
@@ -72,11 +78,21 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
}
}
}, { prepend: true })
- releasePreparation = () => { gate.resolve(undefined); dispose() }
+ releasePreparation = () => { start.resolve(undefined); gate.resolve(undefined); dispose() }
const observePreparation = async () => {
try {
await input.fill(PROMPT)
await input.press('Enter')
+ await waiting.promise
+ await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+ const request = page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true })
+ await request.click()
+ const panel = page.getByRole('tabpanel')
+ await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor()
+ expect(await panel.getByRole('button', { name: 'Assistant Message', exact: true }).count()).toBe(0)
+ requestStates.push('# Waiting for output', await panel.locator('dl').first().ariaSnapshot())
+ await page.getByRole('tab', { name: 'Chat', exact: true }).click()
+ start.resolve(undefined)
const row = page.locator('[data-chat-call-id] [data-state="preparing"]').first()
await row.waitFor({ state: 'attached', timeout: 30_000 })
await expandOwningTurnProcess(page, row)
@@ -88,14 +104,45 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
expect(await row.locator('pre').count()).toBe(0)
const group = row.locator('xpath=ancestor::*[@data-chat-group-key][1]')
await compareOrRefreshGolden(PREPARING_EXPECTED, await group.ariaSnapshot(), MODE)
+ await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+ const assistant = page.locator('tr[data-kind="message"][data-running="true"]').first()
+ await assistant.click()
+ await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor()
+ expect(await panel.getByText('Completed', { exact: true }).count()).toBe(0)
+ requestStates.push('# Streaming assistant', await panel.locator('dl').first().ariaSnapshot())
+ await panel.getByRole('button', { name: 'Request Timing', exact: true }).click()
+ expect(await panel.getByText('Not recorded', { exact: true }).count()).toBe(0)
+ expect(await panel.locator('dt').filter({ hasText: /^Started$/ }).locator('..').getByRole('button').count()).toBe(1)
+ await request.click()
+ await panel.locator('dl').first().getByText('Pending', { exact: true }).waitFor()
+ requestStates.push('# Streaming request', await panel.locator('dl').first().ariaSnapshot())
+ await page.getByRole('tab', { name: 'Chat', exact: true }).click()
} finally {
releasePreparation?.()
}
}
- const [sessionId] = await Promise.all([settled, observePreparation()])
- await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0)
- if (MODE === 'record') {
- await recordFixture(scaffold, sessionId, FIXTURE)
+ try {
+ const [sessionId] = await Promise.all([settled, observePreparation()])
+ expect(sessionEvents.filter(event => event.type === 'turn/end')).toMatchObject([
+ { data: { reason: { kind: 'completed' } } },
+ ])
+ await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0)
+ if (MODE === 'record') {
+ await recordFixture(scaffold, sessionId, FIXTURE)
+ } else {
+ await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+ await page.getByRole('table').getByRole('button', { name: 'Request #1', exact: true }).click()
+ const panel = page.getByRole('tabpanel')
+ await panel.getByText('Completed', { exact: true }).waitFor()
+ requestStates.push('# Settled request', await panel.locator('dl').first().ariaSnapshot())
+ await panel.getByRole('button', { name: 'Assistant Message', exact: true }).click()
+ await panel.getByText('Completed', { exact: true }).waitFor()
+ requestStates.push('# Settled assistant', await panel.locator('dl').first().ariaSnapshot())
+ await compareOrRefreshGolden(REQUEST_LIFECYCLE_EXPECTED, requestStates.join('\n\n'), MODE)
+ }
+ } finally {
+ releasePreparation?.()
+ await page.getByRole('tab', { name: 'Chat', exact: true }).click()
}
}, 200_000)
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index bc6e6302e2..2b64c107fc 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -8,12 +8,12 @@
// Zero model calls: everything is pure client + persistence state on a blank
// frame, so there is no fixture and a stray stream would fail loud on the
// open llm seam.
-import { readFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
-import type { Browser, Locator, Page } from 'playwright'
+import type { Browser, Locator, Page, Request } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
-import { join } from 'node:path'
+import { basename, join } from 'node:path'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -29,9 +29,17 @@ const PLUGIN_INSTANCES_EXPECTED = join(SNAPSHOT_DIR, 'plugin-instances.expected.
const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md')
const PLUGIN_ROW_SELECTOR = '[data-plugin-scope="preset"] [data-plugin-entry="tool-subagent"]'
const MODE = webSnapshotMode()
+const SCREENSHOTS = 'screenshots/collapse-timing-settings'
+const SCREENSHOT_DIR = fileURLToPath(new URL(`../../../.artifacts/${SCREENSHOTS}`, import.meta.url))
const { version } = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { version: string }
const versionCapture = { replacements: [[version, '{{version}}']] as const }
+async function saveSettingsFailureShot(page: Page, name: string): Promise {
+ await mkdir(SCREENSHOT_DIR, { recursive: true })
+ const directory = await mkdtemp(join(SCREENSHOT_DIR, `${name}-`))
+ await saveFailureShot(page, `${SCREENSHOTS}/${basename(directory)}/failure`)
+}
+
describe('web e2e: settings modal and General preferences', () => {
let scaffold: WebScaffold
let browser: Browser
@@ -53,12 +61,15 @@ describe('web e2e: settings modal and General preferences', () => {
}, 120_000)
afterAll(async () => {
- await browser?.close()
- await scaffold?.close()
+ try {
+ await browser?.close()
+ } finally {
+ await scaffold?.close()
+ }
})
it('opens the settings dialog, switches sections, and closes by every path', async () => {
- onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-shell'))
+ onTestFailed(() => saveSettingsFailureShot(page, 'settings-shell'))
onTestFinished(async () => {
const dialog = page.getByRole('dialog', { name: '设置' })
if (await dialog.isVisible()) await page.keyboard.press('Escape')
@@ -100,10 +111,13 @@ describe('web e2e: settings modal and General preferences', () => {
}),
})
})
- await openDocument.click()
- await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
- await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
- await page.unroute('**/api/settings/openSettingsDocument')
+ try {
+ await openDocument.click()
+ await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1)
+ await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true)
+ } finally {
+ await page.unrouteAll({ behavior: 'wait' })
+ }
await dialog.getByText(`当前版本:${version}`, { exact: true }).waitFor()
// Golden of the freshly opened dialog (default zh, General active).
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd, versionCapture)
@@ -864,7 +878,7 @@ describe('web e2e: settings modal and General preferences', () => {
const fresh = await launchWebScaffold({ developerTools: false })
const frPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'fr-FR' })
const frTripwire = watchConsole(frPage)
- onTestFailed(() => saveFailureShot(frPage, 'web-e2e-settings-unshipped-language'))
+ onTestFailed(() => saveSettingsFailureShot(frPage, 'settings-unshipped-language'))
try {
await frPage.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
await frPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -901,6 +915,7 @@ describe('web e2e: settings modal and General preferences', () => {
onTestFinished(() => fresh.close())
const withoutBrowser = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
onTestFinished(() => withoutBrowser.close())
+ onTestFailed(() => saveSettingsFailureShot(withoutBrowser, 'settings-no-browser'))
const browserConsole = watchConsole(withoutBrowser)
await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' })
await openSettings(withoutBrowser, 'en')
@@ -920,8 +935,211 @@ describe('web e2e: settings modal and General preferences', () => {
'dialog-en.expected.md',
'dialog-no-browser.expected.md',
'dialog.expected.md',
+ 'collapse-timing-en.expected.md',
+ 'collapse-timing-zh.expected.md',
'plugin-instances.expected.md',
'plugins.expected.md',
])
})
})
+
+describe('web e2e: browser-local collapse timing preference', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ const uiWait = { timeout: 10_000 }
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({ developerTools: false })
+ browser = await chromium.launch({ headless: true })
+ })
+
+ afterAll(async () => {
+ try {
+ await browser?.close()
+ } finally {
+ await scaffold?.close()
+ }
+ })
+
+ it.each([
+ {
+ locale: 'zh', browserLocale: ZH_BROWSER_LOCALE, theme: 'dark',
+ settings: '设置', close: '关闭', details: '工作步骤展示', compact: '简洁', detailed: '详细',
+ title: '工作步骤收起时机', completion: '回答结束后', nextInput: '下次有新消息时',
+ description: '选择何时自动收起工作步骤',
+ },
+ {
+ locale: 'en', browserLocale: 'en-US', theme: 'light',
+ settings: 'Settings', close: 'Close', details: 'Work details', compact: 'Compact', detailed: 'Detailed',
+ title: 'When to Collapse Work Details', completion: 'On completion', nextInput: 'On next message',
+ description: 'Choose when to automatically collapse work details',
+ },
+ ] as const)('keeps $locale collapse timing on $theme only for the current Client', async (copy) => {
+ const contextOptions = {
+ viewport: { width: 1680, height: 1000 }, locale: copy.browserLocale,
+ colorScheme: copy.theme, timezoneId: 'Asia/Shanghai',
+ }
+ const context = await browser.newContext(contextOptions)
+ onTestFinished(() => context.close())
+ const page = await context.newPage()
+ onTestFailed(() => saveSettingsFailureShot(page, `settings-${copy.locale}`))
+ const tripwire = watchConsole(page)
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await openSettings(page, copy.locale)
+ const dialog = page.getByRole('dialog', { name: copy.settings, exact: true })
+ const details = dialog.getByText(copy.details, { exact: true }).locator('../..')
+ await details.getByRole('button').click()
+ expect(await page.getByRole('menuitem').count()).toBe(4)
+ // The persistence checks require an explicit saved mode, not the client's default.
+ await page.getByRole('menuitem', { name: copy.compact, exact: true }).click()
+ await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait)
+ .toMatchObject({ transcriptView: 'compact' })
+ await details.getByRole('button', { name: copy.compact, exact: true }).click()
+ await page.getByRole('menuitem', { name: copy.detailed, exact: true }).click()
+ await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value, uiWait)
+ .toMatchObject({ transcriptView: 'detailed' })
+ await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), uiWait)
+ .toBe(copy.theme === 'dark')
+ const timing = dialog.getByText(copy.title, { exact: true }).locator('../..')
+ expect(await timing.count()).toBe(1)
+ expect(await timing.evaluate(element => element.previousElementSibling?.textContent)).toContain(copy.details)
+ await timing.getByText(copy.description, { exact: true }).waitFor()
+ await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor()
+ let screenshotDir: string | undefined
+ if (MODE !== 'record') {
+ await mkdir(SCREENSHOT_DIR, { recursive: true })
+ screenshotDir = await mkdtemp(join(SCREENSHOT_DIR, `settings-${copy.locale}-${copy.theme}-`))
+ await page.screenshot({ path: join(screenshotDir, 'panel.png'), fullPage: true })
+ }
+
+ await page.evaluate(() => {
+ localStorage.setItem('collapse-timing-e2e-unrelated', 'retained')
+ sessionStorage.setItem('collapse-timing-e2e-unrelated', 'retained')
+ })
+ const readPersistence = async (target: Page = page) => ({
+ host: await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8'),
+ settings: structuredClone(scaffold.ctx.settings.describe({ redactSecrets: true }).map(({ ns, value }) => ({ ns, value }))),
+ storage: await target.evaluate(() => ({
+ local: Object.fromEntries(Object.entries(localStorage)),
+ session: Object.fromEntries(Object.entries(sessionStorage)),
+ })),
+ })
+ const before = await readPersistence()
+ const settingsWrites: string[] = []
+ const recordSettingsWrite = (request: Request): void => {
+ const path = new URL(request.url()).pathname
+ if (request.method() === 'POST' && path === '/api/settings/mutate') settingsWrites.push(path)
+ }
+ context.on('request', recordSettingsWrite)
+ await timing.getByRole('button', { name: copy.completion, exact: true }).click()
+ await page.getByRole('menuitem', { name: copy.nextInput, exact: true }).waitFor()
+ expect(await page.getByRole('menuitem').allTextContents()).toEqual([copy.completion, copy.nextInput])
+ await compareOrRefreshGolden(
+ join(SNAPSHOT_DIR, `collapse-timing-${copy.locale}.expected.md`),
+ await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd),
+ MODE,
+ )
+ if (screenshotDir !== undefined) {
+ await page.screenshot({ path: join(screenshotDir, 'menu.png'), fullPage: true })
+ }
+ const menu = page.getByRole('menu')
+ await page.keyboard.press('Escape')
+ await menu.waitFor({ state: 'hidden' })
+ expect(await dialog.isVisible()).toBe(true)
+ await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor()
+ await timing.getByRole('button').click()
+ await menu.waitFor()
+ await timing.getByText(copy.title, { exact: true }).click()
+ await menu.waitFor({ state: 'hidden' })
+ expect(await dialog.isVisible()).toBe(true)
+ await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor()
+
+ await page.setViewportSize({ width: 640, height: 480 })
+ await timing.getByRole('button').click()
+ await menu.waitFor()
+ await expect.poll(() => menu.evaluate((element) => {
+ const rect = element.getBoundingClientRect()
+ return {
+ fits: rect.width > 0 && rect.height > 0
+ && rect.left > 0 && rect.top > 0
+ && rect.right < window.innerWidth && rect.bottom < window.innerHeight,
+ uncovered: [...element.querySelectorAll('[role="menuitem"]')].every((item) => {
+ const bounds = item.getBoundingClientRect()
+ return item.contains(document.elementFromPoint(bounds.left + bounds.width / 2, bounds.top + bounds.height / 2))
+ }),
+ }
+ }), uiWait).toEqual({ fits: true, uncovered: true })
+ if (screenshotDir !== undefined) {
+ await page.screenshot({ path: join(screenshotDir, 'menu-compact.png'), fullPage: true })
+ }
+ await page.getByRole('menuitem', { name: copy.nextInput, exact: true }).click()
+ await menu.waitFor({ state: 'hidden' })
+ await page.setViewportSize({ width: 1680, height: 1000 })
+ await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor()
+ expect(await readPersistence()).toEqual(before)
+ await dialog.getByRole('button', { name: copy.close, exact: true }).click()
+ await openSettings(page, copy.locale)
+ await timing.getByRole('button', { name: copy.nextInput, exact: true }).waitFor()
+ expect(await readPersistence()).toEqual(before)
+ await dialog.getByRole('button', { name: copy.close, exact: true }).click()
+
+ const warningStart = tripwire.warnings.length
+ await page.reload({ waitUntil: 'load' })
+ await openSettings(page, copy.locale)
+ await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor()
+ acknowledgeReloadConnectionLoss(tripwire, warningStart)
+ expect(await readPersistence()).toEqual(before)
+ if (screenshotDir !== undefined) {
+ await page.screenshot({ path: join(screenshotDir, 'reloaded.png'), fullPage: true })
+ }
+
+ const sharedPage = await context.newPage()
+ onTestFailed(() => saveSettingsFailureShot(sharedPage, `settings-${copy.locale}-shared-tab`))
+ const sharedTripwire = watchConsole(sharedPage)
+ await sharedPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await openSettings(sharedPage, copy.locale)
+ const sharedTiming = sharedPage.getByRole('dialog', { name: copy.settings, exact: true })
+ .getByText(copy.title, { exact: true }).locator('../..')
+ await sharedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor()
+ const sharedPersistence = await readPersistence(sharedPage)
+ expect(sharedPersistence.host).toBe(before.host)
+ expect(sharedPersistence.settings).toEqual(before.settings)
+ expect(sharedPersistence.storage.local).toEqual(before.storage.local)
+ if (screenshotDir !== undefined) {
+ await sharedPage.screenshot({ path: join(screenshotDir, 'shared-tab.png'), fullPage: true })
+ }
+
+ const isolatedContext = await browser.newContext(contextOptions)
+ onTestFinished(() => isolatedContext.close())
+ isolatedContext.on('request', recordSettingsWrite)
+ const isolatedPage = await isolatedContext.newPage()
+ onTestFailed(() => saveSettingsFailureShot(isolatedPage, `settings-${copy.locale}-isolated-context`))
+ const isolatedTripwire = watchConsole(isolatedPage)
+ await isolatedPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ await openSettings(isolatedPage, copy.locale)
+ const isolatedTiming = isolatedPage.getByRole('dialog', { name: copy.settings, exact: true })
+ .getByText(copy.title, { exact: true }).locator('../..')
+ await isolatedTiming.getByRole('button', { name: copy.completion, exact: true }).waitFor()
+ const isolatedPersistence = await readPersistence(isolatedPage)
+ expect(isolatedPersistence.host).toBe(before.host)
+ expect(isolatedPersistence.settings).toEqual(before.settings)
+ if (screenshotDir !== undefined) {
+ await isolatedPage.screenshot({ path: join(screenshotDir, 'isolated-context.png'), fullPage: true })
+ }
+
+ expect(await readPersistence()).toEqual(before)
+ await timing.getByRole('button', { name: copy.completion, exact: true }).click()
+ await page.getByRole('menuitem', { name: copy.nextInput, exact: true }).click()
+ await timing.getByRole('button', { name: copy.nextInput, exact: true }).click()
+ await page.getByRole('menuitem', { name: copy.completion, exact: true }).click()
+ await timing.getByRole('button', { name: copy.completion, exact: true }).waitFor()
+ expect(await readPersistence()).toEqual(before)
+ expect(settingsWrites).toEqual([])
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ expect(sharedTripwire.pageErrors).toEqual([])
+ expect(sharedTripwire.warnings).toEqual([])
+ expect(isolatedTripwire.pageErrors).toEqual([])
+ expect(isolatedTripwire.warnings).toEqual([])
+ })
+})
diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts
index 9723ae3494..bc77481b99 100644
--- a/apps/web/tests/skill-invocation-policy.e2e.ts
+++ b/apps/web/tests/skill-invocation-policy.e2e.ts
@@ -140,6 +140,7 @@ describe('web e2e: skill invocation policy through the real host', () => {
await option.click()
const skill = input.locator('[data-composer-text-ref]').filter({ hasText: '/policy-shared' })
const file = input.locator('[data-composer-chip]')
+ await file.locator('span').first().waitFor({ state: 'visible' })
const draft = await input.textContent()
const alignment = await input.evaluate((el) => {
const skill = el.querySelector('[data-composer-text-ref]')!
diff --git a/apps/web/tests/standalone-string-editor.e2e.ts b/apps/web/tests/standalone-string-editor.e2e.ts
index 79e1efed80..a04dc6e96c 100644
--- a/apps/web/tests/standalone-string-editor.e2e.ts
+++ b/apps/web/tests/standalone-string-editor.e2e.ts
@@ -1,4 +1,4 @@
-/** A standalone tool in a custom scoped patch reaches the PTC SDK and a persisted Session. */
+/** A standalone global tool from a custom Host patch reaches the PTC SDK and a persisted Session. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -32,8 +32,7 @@ describe('web snapshot: standalone string editor through PTC', () => {
compareReplaySession: true,
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
})
- expect((await scaffold.ctx.agentPresets.compositionInventory()).find(preset => preset.id === 'ptc')?.rows
- .some(row => row.moduleName === '@deepseek-ai/dsh-tool-str-replace-editor')).toBe(true)
+ expect(scaffold.ctx.tools.schemas().some(tool => tool.name === 'str_replace_editor')).toBe(true)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
diff --git a/apps/web/tests/standalone-string-editor.overlay.yml b/apps/web/tests/standalone-string-editor.overlay.yml
index 7522f1800c..78d68e1f1f 100644
--- a/apps/web/tests/standalone-string-editor.overlay.yml
+++ b/apps/web/tests/standalone-string-editor.overlay.yml
@@ -1,4 +1,3 @@
-- preset: preset-ptc
- insert:
+- insert:
- id: optional-tool-str-replace-editor
name: '@deepseek-ai/dsh-tool-str-replace-editor'
diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts
index 6b6cd918b7..49fe63b3d0 100644
--- a/apps/web/tests/subagent-conversation.e2e.ts
+++ b/apps/web/tests/subagent-conversation.e2e.ts
@@ -1,10 +1,11 @@
-import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
+import { dirname, join } from 'node:path'
+import { zstdCompressSync } from 'node:zlib'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay'
import {
SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq,
@@ -12,10 +13,11 @@ import {
} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
+import { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache'
import {
acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden,
- launchWebScaffold, readPersistedEvents, selectedSessionFixture, watchConsole,
+ launchWebScaffold, readPersistedEvents, realizeSeedFixture, seedSession, selectedSessionFixture, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
@@ -32,6 +34,9 @@ const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-
const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url))
const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url))
const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url))
+const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL(
+ './expected/subagent-conversation/migration-required-child.expected.md', import.meta.url,
+))
const MODE = webSnapshotMode()
const LABEL = 'event-sourcing researcher'
const ONE_SHOT_LABEL = 'event-sourcing reviewer'
@@ -120,6 +125,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
paceMs: 25,
})
browser = await chromium.launch()
+ await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true })
page = await newEnglishPage(browser)
page.on('request', (request) => {
const path = new URL(request.url()).pathname
@@ -320,6 +326,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
},
])
}
+ await waitForCacheRow(scaffold, childHeader)
+ const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name)
+ if (cache === undefined) throw new Error('projection cache is not open')
+ const table = cache.table('sessions')
+ const record = checkpointRecord.parse(table.get(childId))
+ const { subagentCatalog: _catalog, ...rows } = record.rows
+ await table.put(childId, { ...record, rows })
// These two cold fixtures were authored after the page's initial
// session.list and intentionally emitted no api-session/added event. Reload
// to exercise the restart baseline that discovers their full lineage.
@@ -362,8 +375,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await writeComposerDraft(page, input, '')
})
- it('expands a persisted grandchild progressively without activating either level', async () => {
- onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree'))
+ it('recovers a current-format cold child catalog cache miss without activating either level', async () => {
+ onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-subagent-tree'))
await page.getByRole('button', { name: '2 subagents' }).hover()
const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
expect(await catalogTree.evaluate((element) => {
@@ -379,12 +392,22 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect(await oneShotRow.locator('[data-state="done"]').count()).toBe(1)
expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1)
expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s')
- await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
+ const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
+ try {
+ const projections = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/projections')
+ await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click()
+ await projections
+ await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
+ expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true)
+ expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false)
+ } finally {
+ observe.mockRestore()
+ }
const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) })
const childLabel = await childRow.getAttribute('aria-label')
await page.waitForTimeout(1_100)
expect(await childRow.getAttribute('aria-label')).toBe(childLabel)
- await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 })
+ expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).textContent()).toContain('continuable')
expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()
const snapshot = await captureStableAria(
@@ -657,3 +680,143 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined()
})
})
+
+describe('web e2e: migration-required subagent catalog', () => {
+ const parentId = sessionId('migration-catalog-parent')
+ const childId = sessionId('migration-catalog-child')
+ const grandchildId = sessionId('migration-catalog-grandchild')
+ const childLabel = 'historical researcher'
+ const grandchildLabel = 'historical editor'
+ const predecessors: { id: SessionId; directory: string; path: string; source: Buffer }[] = []
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ if (MODE === 'record') throw new Error('migration-required subagent catalog is a keyless assembled snapshot')
+ scaffold = await launchWebScaffold()
+ const fixture = await readFile(BASE_FIXTURE, 'utf8')
+ const createdAt = 1784998084442
+ await seedSession(scaffold, fixture, parentId, undefined, { createdAt })
+ const parentHandle = await scaffold.ctx.sessionPersistence.open(parentId, 'write')
+ try {
+ const { events } = await parentHandle.read()
+ const last = events.at(-1)
+ if (last === undefined) throw new Error('parent seed has no events')
+ await parentHandle.append([{
+ type: 'subagent/catalog', seq: SessionSeq(last.seq + 1), time: last.time + 1,
+ data: { version: 1, childId, childCreatedAt: createdAt + 1, mode: 'continuable', label: childLabel },
+ }])
+ } finally {
+ await parentHandle.close()
+ }
+ const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory())
+ expect(projects).toHaveLength(1)
+ const project = projects[0]
+ if (project === undefined) throw new Error('parent seed has no project directory')
+ // Keep historical bytes on disk; seeding through the writer would migrate them before the browser opens.
+ for (const [id, parentSession, label, depth] of [
+ [childId, parentId, childLabel, 1],
+ [grandchildId, childId, grandchildLabel, 2],
+ ] as const) {
+ const [header, ...events] = realizeSeedFixture(scaffold, fixture, id).trimEnd().split('\n')
+ .map((line): Record => JSON.parse(line) as Record)
+ expect(header?.version).toBe(3)
+ const body = [
+ ...events,
+ { type: 'subagent/descriptor', data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'spawn', label }) },
+ ...id === childId ? [{
+ type: 'subagent/catalog',
+ data: { version: 0, childId: grandchildId, childCreatedAt: createdAt + 2, mode: 'continuable', label: grandchildLabel },
+ }] : [],
+ ]
+ const rows = [
+ { ...header, id, createdAt: createdAt + depth, cwd: scaffold.workspaceCwd, parentSession, origin: 'subagent', delegationDepth: depth },
+ ...body.map((event, seq) => ({ ...event, seq, time: createdAt + depth + seq + 1 })),
+ ]
+ const directory = join(scaffold.persistenceRoot, project.name, id)
+ const path = join(directory, 'session.v3.jsonl.zstd')
+ const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`))))
+ await mkdir(directory, { recursive: true })
+ await writeFile(path, source)
+ predecessors.push({ id, directory, path, source })
+ }
+ for (const { id } of predecessors) {
+ const stored = await scaffold.ctx.sessionPersistence.stat(id)
+ expect(stored?.formatStatus).toBe('migration-required')
+ if (stored === undefined) throw new Error(`historical fixture ${id} is missing`)
+ expect(scaffold.ctx.sessionProjectionCache.cachedSnapshot(stored.header)).toBeUndefined()
+ }
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ })
+
+ afterAll(async () => {
+ const failures: unknown[] = []
+ await browser?.close().catch((error: unknown) => failures.push(error))
+ await scaffold?.close().catch((error: unknown) => failures.push(error))
+ if (failures.length === 1) throw failures[0]
+ if (failures.length > 1) throw new AggregateError(failures, 'migration-required subagent Web teardown failed')
+ })
+
+ it('stops at a migration-required child until its history opens', async () => {
+ onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-subagent'))
+ const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession')
+ const projections = vi.spyOn(scaffold.ctx.sessionController, 'projections')
+ const model = vi.spyOn(scaffold.ctx.llm, 'stream')
+ try {
+ await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+ const workspaceRow = page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').first()
+ await workspaceRow.click()
+ await page.locator(`[data-row-key="session:${parentId}"]`).click()
+ await page.getByRole('button', { name: '1 subagent', exact: true }).hover()
+ const tree = page.getByRole('tree', { name: 'Subagent sessions' })
+ const childRow = tree.getByRole('treeitem', { name: new RegExp(childLabel) })
+ await childRow.waitFor()
+ await expect.poll(() => childRow.textContent()).toContain('Migration required')
+ await expect.poll(() => childRow.getByRole('button', { name: `Expand ${childLabel} descendants`, exact: true }).count()).toBe(0)
+ expect(await childRow.getAttribute('aria-expanded')).toBeNull()
+ expect(await childRow.textContent()).toContain('Migration required')
+ expect(await childRow.textContent()).not.toContain('unknown mode')
+ expect(await childRow.getByRole('button', { name: `Open ${childLabel} in sidebar`, exact: true }).isEnabled()).toBe(true)
+ expect(await tree.getByRole('treeitem').count()).toBe(1)
+ for (const { id, directory, path, source } of predecessors) {
+ expect(scaffold.ctx.agents.get(id)).toBeUndefined()
+ expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd'])
+ expect(await readFile(path)).toEqual(source)
+ }
+ const before = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd)
+ expect(observe.mock.calls.some(([id]) => id === childId || id === grandchildId)).toBe(false)
+ expect(projections).not.toHaveBeenCalled()
+
+ await childRow.click()
+ await page.getByRole('button', { name: `Switch subagent: ${childLabel}`, exact: true }).waitFor()
+ await page.getByRole('button', { name: '1 subagent', exact: true }).hover()
+ const grandchildRow = tree.getByRole('treeitem', { name: new RegExp(grandchildLabel) })
+ await grandchildRow.waitFor()
+ expect(await grandchildRow.textContent()).toContain('Migration required')
+ expect(await grandchildRow.getAttribute('aria-expanded')).toBeNull()
+ expect(await grandchildRow.getByRole('button', { name: `Expand ${grandchildLabel} descendants`, exact: true }).count()).toBe(0)
+ expect(projections).not.toHaveBeenCalled()
+ expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true)
+ const after = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd)
+ if (MODE === 'refresh') await mkdir(dirname(MIGRATING_CHILD_EXPECTED), { recursive: true })
+ await compareOrRefreshGolden(MIGRATING_CHILD_EXPECTED,
+ `# Before opening the child\n\n${before}\n\n# After opening the child\n\n${after}`, MODE)
+ for (const { id, path, source } of predecessors) {
+ expect(scaffold.ctx.agents.get(id)).toBeUndefined()
+ expect(await readFile(path)).toEqual(source)
+ }
+ expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false)
+ expect(model).not.toHaveBeenCalled()
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ } finally {
+ projections.mockRestore()
+ model.mockRestore()
+ observe.mockRestore()
+ }
+ })
+})
diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts
index 4f1a9e5f80..7afeaa9a10 100644
--- a/apps/web/tests/subagent-interrupt-ui.e2e.ts
+++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts
@@ -31,6 +31,8 @@ const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interact
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/subagent-interrupt', import.meta.url))
const OFFLINE_COMPOSER_EXPECTED = join(SNAPSHOT_DIR, 'offline-composer.expected.md')
+const IMAGE_QUEUE_EXPECTED = join(SNAPSHOT_DIR, 'image-queue.expected.md')
+const PNG = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url))
const MODE = webSnapshotMode()
const LABEL = 'event-sourcing researcher'
const INITIAL = 'Explain event sourcing in one sentence.'
@@ -41,6 +43,7 @@ const EDITED_FOLLOWUP = 'Explain the same idea for a human reader.'
const WAKING = 'And add one concrete example.'
const REARMED_ANSWER = 're-armed setup answer'
const PARKED_ANSWER = 'parked follow-up answer'
+const IMAGE_ANSWER = 'image follow-up answer'
const WAKING_ANSWER = 'waking answer'
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
@@ -105,6 +108,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
{ kind: 'hang', readyFile: rearmedReadyFile },
textCompletion(REARMED_ANSWER),
textCompletion(PARKED_ANSWER),
+ textCompletion(IMAGE_ANSWER),
textCompletion(WAKING_ANSWER),
]))
await writeFile(
@@ -311,6 +315,30 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
await page.getByText(EDITED_FOLLOWUP, { exact: true }).waitFor()
expect(apiCalls.filter(path => path === '/api/subagents/updateQueue')).toEqual([])
+ // A separate image-only follow-up proves attachment admission without
+ // changing the existing text queue's edit coverage.
+ const imageBytes = [...await readFile(PNG)]
+ await input.evaluate((surface, data) => {
+ const transfer = new DataTransfer()
+ transfer.items.add(new File([new Uint8Array(data)], 'child-followup.png', { type: 'image/png' }))
+ surface.dispatchEvent(new ClipboardEvent('paste', {
+ clipboardData: transfer, bubbles: true, cancelable: true,
+ }))
+ }, imageBytes)
+ await page.getByRole('img', { name: 'child-followup.png', exact: true }).waitFor()
+ const imageResponse = page.waitForResponse(response =>
+ new URL(response.url()).pathname === '/api/subagents/prompt')
+ await page.getByRole('button', { name: 'Queue message' }).click()
+ expect(((await (await imageResponse).json()) as { result: { ok: boolean } }).result).toMatchObject({ ok: true })
+ const thumbnail = page.locator('[data-queue-dock]').getByRole('img', { name: 'Queued message image' })
+ await thumbnail.waitFor()
+ await expect.poll(() => thumbnail.evaluate((image: HTMLImageElement) => image.complete && image.naturalWidth > 0)).toBe(true)
+ await compareOrRefreshGolden(
+ IMAGE_QUEUE_EXPECTED,
+ await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
+ MODE,
+ )
+
const aborted = waitForAbortedTurn(scaffold, childId)
const stop = page.getByRole('button', { name: 'Stop generating' })
expect(await stop.count()).toBe(1)
@@ -329,7 +357,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
await expect.poll(() => scaffold.ctx.agents.get(childId)?.status, { timeout: 15_000 }).toBe('idle')
const child = scaffold.ctx.agents.get(childId)
expect(child).toBeDefined()
- expect(child!.inbox.nextTurn).toHaveLength(2)
+ expect(child!.inbox.nextTurn).toHaveLength(3)
expect(child!.session.snapshotEvents().filter(event => event.type === 'turn/start')).toHaveLength(2)
await page.getByRole('button', { name: 'Send message' }).waitFor({ timeout: 15_000 })
@@ -338,6 +366,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
await input.press('Enter')
await expect.poll(() => page.getByText(REARMED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => page.getByText(PARKED_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
+ await expect.poll(() => page.getByText(IMAGE_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => page.getByText(WAKING_ANSWER, { exact: true }).count(), { timeout: 30_000 }).toBe(1)
await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 60_000 }).toBeUndefined()
@@ -351,14 +380,23 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
expect(userTexts[0]).toBe(INITIAL)
expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /)
expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, EDITED_FOLLOWUP, WAKING])
+ const imageMessage = events.find(event => event.type === 'user/message'
+ && event.data.content.some(block => block.type === 'image'))
+ expect(imageMessage?.type === 'user/message' && imageMessage.data.content.map(block => block.type)).toEqual(['image'])
+ const image = imageMessage?.type === 'user/message'
+ ? imageMessage.data.content.find(block => block.type === 'image')
+ : undefined
+ expect(image?.type === 'image' && image.attachment.name).toBe('child-followup.png')
+ expect(JSON.stringify(events)).not.toContain('base64')
+ await page.locator('[class*="userRow"] img').first().waitFor()
const turnEndKinds = events
.filter(event => event.type === 'turn/end')
.map(event => event.data.reason.kind)
- expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed'])
+ expect(turnEndKinds).toEqual(['aborted', 'aborted', 'completed', 'completed', 'completed', 'completed'])
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps its snapshot inventory closed', async () => {
- await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['offline-composer.expected.md', 'image-queue.expected.md'])
})
})
diff --git a/apps/web/tests/voice-input.e2e.ts b/apps/web/tests/voice-input.e2e.ts
index 82634fa7eb..133bcb416a 100644
--- a/apps/web/tests/voice-input.e2e.ts
+++ b/apps/web/tests/voice-input.e2e.ts
@@ -62,10 +62,27 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
await page.addInitScript(() => {
- const holder = window as Window & { voiceTestRecorder?: MediaRecorder }
- const NativeRecorder = window.MediaRecorder
- window.MediaRecorder = class extends NativeRecorder {
- constructor(stream: MediaStream, options?: MediaRecorderOptions) { super(stream, options); holder.voiceTestRecorder = this }
+ const holder = window as Window & {
+ voiceTestStreams?: MediaStream[]
+ voiceTestExcluded?: string[]
+ voiceTestNames?: Record
+ }
+ holder.voiceTestStreams = []
+ holder.voiceTestExcluded = []
+ holder.voiceTestNames = {}
+ const enumerateDevices = navigator.mediaDevices.enumerateDevices.bind(navigator.mediaDevices)
+ navigator.mediaDevices.enumerateDevices = async () => (await enumerateDevices())
+ .filter(device => !holder.voiceTestExcluded!.includes(device.deviceId))
+ .map((device) => {
+ const label = holder.voiceTestNames![device.deviceId]
+ if (label) Object.defineProperty(device, 'label', { value: label })
+ return device
+ })
+ const getUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices)
+ navigator.mediaDevices.getUserMedia = async (constraints) => {
+ const stream = await getUserMedia(constraints)
+ holder.voiceTestStreams!.push(stream)
+ return stream
}
})
await page.goto(scaffold.authenticatedUrl)
@@ -120,8 +137,75 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
expect(recognize).not.toHaveBeenCalled()
preparation = { phase: 'standby' }
for (const listener of readinessListeners) listener()
+ const devicePicker = page.getByRole('combobox', { name: 'Input device', exact: true })
+ const level = page.getByRole('img', { name: 'Microphone input level', exact: true })
+ await level.waitFor()
+ const devices = await page.evaluate(async () => (await navigator.mediaDevices.enumerateDevices())
+ .filter(device => device.kind === 'audioinput' && device.deviceId !== 'default'))
+ const selectedDevice = devices[0]!
+ await devicePicker.selectOption(selectedDevice.deviceId)
+ await expect.poll(() => devicePicker.inputValue()).toBe(selectedDevice.deviceId)
+ await expect.poll(() => page.evaluate(() => {
+ const streams = (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!
+ return streams.filter(stream => stream.getTracks().some(track => track.readyState === 'live')).length
+ })).toBe(1)
+ await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/device.expected.md', import.meta.url)),
+ await captureStableAria(page, '[data-voice-input-device]', scaffold.workspaceCwd), webSnapshotMode())
+ const acquisitions = await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!.length)
+ const otherDevice = devices[1]!
+ for (const excluded of [[otherDevice.deviceId], []]) {
+ await page.evaluate((ids) => {
+ (window as Window & { voiceTestExcluded?: string[] }).voiceTestExcluded = ids
+ navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
+ }, excluded)
+ await expect.poll(() => page.getByRole('option', { name: otherDevice.label, exact: true }).count()).toBe(excluded.length ? 0 : 1)
+ expect(await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!.length)).toBe(acquisitions)
+ await level.waitFor()
+ }
+ await page.evaluate((id) => {
+ const holder = window as Window & { voiceTestExcluded?: string[]; voiceTestStreams?: MediaStream[] }
+ holder.voiceTestExcluded = [id]
+ const track = holder.voiceTestStreams!.at(-1)!.getAudioTracks()[0]!
+ track.stop(); track.dispatchEvent(new Event('ended'))
+ navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
+ }, selectedDevice.deviceId)
+ const missing = page.getByRole('option', { name: `${selectedDevice.label} — Unavailable`, exact: true })
+ await expect.poll(() => missing.count()).toBe(1)
+ expect(await missing.isDisabled()).toBe(true)
+ await level.waitFor({ state: 'hidden' })
+ expect(await devicePicker.evaluate(element => (element as HTMLSelectElement).selectedOptions[0]?.textContent)).toContain('Unavailable')
+ await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/device-missing.expected.md', import.meta.url)),
+ await captureStableAria(page, '[data-voice-input-device]', scaffold.workspaceCwd), webSnapshotMode())
+ await page.evaluate(() => {
+ (window as Window & { voiceTestExcluded?: string[] }).voiceTestExcluded = []
+ navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
+ })
+ await expect.poll(() => devicePicker.evaluate(element => (element as HTMLSelectElement).selectedOptions[0]?.textContent))
+ .not.toContain('Unavailable')
+ await level.waitFor()
+ expect(await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] })
+ .voiceTestStreams!.length)).toBe(acquisitions + 1)
+ await devicePicker.blur()
+ await level.waitFor()
+ const longName = 'USB studio microphone — conference room recording input'
+ await page.evaluate(({ id, label }) => {
+ (window as Window & { voiceTestNames?: Record }).voiceTestNames![id] = label
+ navigator.mediaDevices.dispatchEvent(new Event('devicechange'))
+ }, { id: selectedDevice.deviceId, label: longName })
+ await expect.poll(() => devicePicker.evaluate(element => (element as HTMLSelectElement).selectedOptions[0]?.textContent)).toBe(longName)
+ await page.setViewportSize({ width: 360, height: 900 })
+ await level.waitFor()
+ await devicePicker.scrollIntoViewIfNeeded()
+ await compareOrRefreshGolden(fileURLToPath(new URL('../../../snapshots/web/voice-input/device-long.expected.md', import.meta.url)),
+ await captureStableAria(page, '[data-voice-input-device]', scaffold.workspaceCwd), webSnapshotMode())
+ const pickerBounds = (await devicePicker.boundingBox())!, levelBounds = (await level.boundingBox())!
+ expect(levelBounds.x).toBeGreaterThan(pickerBounds.x + pickerBounds.width / 2)
+ expect(levelBounds.x + levelBounds.width).toBeLessThan(pickerBounds.x + pickerBounds.width)
+ await page.setViewportSize({ width: 1280, height: 900 })
await page.getByRole('button', { name: 'New session', exact: true }).filter({ hasText: 'New Session' }).click()
await mic.waitFor()
+ await expect.poll(() => page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!
+ .every(stream => stream.getTracks().every(track => track.readyState === 'ended')))).toBe(true)
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').waitFor()
expect(await page.locator('[data-plugin-detail]').count()).toBe(0)
@@ -134,6 +218,8 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
await input.fill(prefix)
await mic.click()
await page.getByRole('button', { name: 'Stop and transcribe', exact: true }).waitFor()
+ expect(await page.evaluate(() => (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!
+ .at(-1)!.getAudioTracks()[0]!.getSettings().deviceId)).toBe(selectedDevice.deviceId)
await compareOrRefreshGolden(recordingExpected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
expect(await page.getByRole('dialog').count()).toBe(0)
@@ -187,11 +273,11 @@ it.skipIf(webSnapshotMode() === 'record')('guides voice setup, records from stan
await mic.click()
await stop.waitFor()
await page.evaluate(() => {
- const recorder = (window as Window & { voiceTestRecorder?: MediaRecorder }).voiceTestRecorder
- if (!recorder) throw new Error('No active test microphone')
- recorder.dispatchEvent(new Event('error'))
+ const track = (window as Window & { voiceTestStreams?: MediaStream[] }).voiceTestStreams!.at(-1)!.getAudioTracks()[0]!
+ track.stop(); track.dispatchEvent(new Event('ended'))
})
await page.getByRole('button', { name: 'Record again', exact: true }).waitFor()
+ await page.evaluate(() => { navigator.mediaDevices.dispatchEvent(new Event('devicechange')) })
expect(await stop.count()).toBe(0)
await compareOrRefreshGolden(interruptedExpected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
diff --git a/apps/web/tests/worktree-opt-in.e2e.ts b/apps/web/tests/worktree-opt-in.e2e.ts
index c2f2e186a8..40d4e58857 100644
--- a/apps/web/tests/worktree-opt-in.e2e.ts
+++ b/apps/web/tests/worktree-opt-in.e2e.ts
@@ -65,10 +65,11 @@ it('persists the Git Worktrees switch and removes its capability while retaining
await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click()
const panel = page.locator('[data-plugin-panel]')
await panel.getByRole('heading', { name: 'Plugins', exact: true }).waitFor()
+ await panel.getByRole('button', { name: 'More', exact: true }).click()
return panel
}
let panel = await openPlugins()
- let card = panel.locator(`[data-plugin-group="official"] [data-plugin-package="${BUNDLE}"]`)
+ let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`)
await card.getByRole('button', { name: 'View Git Worktrees', exact: true }).waitFor()
expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1)
let toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
@@ -90,7 +91,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining
await page.reload({ waitUntil: 'load' })
panel = await openPlugins()
- card = panel.locator(`[data-plugin-package="${BUNDLE}"]`)
+ card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`)
toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true })
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
expect(tools()).toContain('create_worktree')
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 295c9d1c56..9c0350a2be 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -22,6 +22,8 @@
// this client-registered project must not also hold them (one program
// cannot see both sides of the cordis Context merges).
"exclude": [
+ "tests/collapse-timing.e2e.ts",
+ "tests/chat-flow-layout.e2e.ts",
"tests/layout-bottom.e2e.ts",
"tests/shortcuts-desktop.e2e.ts",
"tests/shortcuts-workspace.e2e.ts",
diff --git a/benchmarks/AGENTS.md b/benchmarks/AGENTS.md
index 4ea4000402..eda9f8cad3 100644
--- a/benchmarks/AGENTS.md
+++ b/benchmarks/AGENTS.md
@@ -8,6 +8,7 @@ This tree owns required, repository-level performance gates whose measured user
- Browser workflow cases drive built Client bundles through the shared shipped-composition Web scaffold. Report its source-resolved test Host separately from published-Host evidence; two animation frames prove a rendering opportunity, not hardware presentation. Use fresh browsers and private scaffold worlds per sample.
- Synthesize fixed inputs from reviewed constants. Never use recorded Sessions, user material, ambient repositories, or network services.
- Run process-level wall-clock and retained-memory samples in fresh children with private `mkdtemp` roots. Pure synchronous folds create a fresh object graph per sample and must not mutate process-global state. Bound every child, await exit, and remove owned roots after failure as well as success.
+- Pass every budgeted wall-clock endpoint to `recordTimings` from `support/scaling-report.ts` before asserting it, with a `BenchmarkCase` stating what it measures and which user scenario it affects. Give each endpoint its process CPU time over the same interval when the worker can measure it, otherwise a commented storage-wait share; omit endpoints that include paced or fixed waits. Pass whole-process peak RSS to `recordPeakMemory`. Profiles live in `support/machine-profiles.ts`; each run writes `.dsh-report/report.html`, which the `node 24 / benchmarks` job publishes as the `benchmark-report` artifact.
- Record reference-machine expectations separately from the shared CI time scale and variance headroom. Do not apply the time scale to memory or dimensionless ratios.
- Report enough raw and aggregate measurements to explain each verdict, including whether a budget uses a median, minimum, absolute value, or ratio. Enforce reviewed source constants; environment variables must not override performance budgets.
- Keep scenario-specific support beside its benchmark. Move a helper into `benchmarks/support/` only after at least two benchmark directories require the same behavior.
diff --git a/benchmarks/active-stream-reconnect/reconnect.bench.client.ts b/benchmarks/active-stream-reconnect/reconnect.bench.client.ts
index 0d25974013..b1c4f322c0 100644
--- a/benchmarks/active-stream-reconnect/reconnect.bench.client.ts
+++ b/benchmarks/active-stream-reconnect/reconnect.bench.client.ts
@@ -3,12 +3,20 @@ import { join } from 'node:path'
import { expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts'
import type { ReconnectReport } from './reconnect.worker.client.ts'
const REFERENCE_REPLACE_MS = 50
const REPLACE_BUDGET_MS = ciTimeBudget(REFERENCE_REPLACE_MS)
const REFERENCE_RETAINED_MB = 24
const SAMPLES = 3
+/** Replacement folds in-memory Client state; no storage access is timed. */
+const IO_SHARE = 0
+const CASE: BenchmarkCase = {
+ id: 'active-stream-reconnect',
+ measures: 'Client rebuild of an unfinished Assistant reply from a 100,000-delta reasoning prefix delivered on reconnect.',
+ affects: 'Reloading or reconnecting the Web GUI while a long reply is still streaming.',
+}
function expectReplacementWithinBudget(value: number, budget: number): void {
expect(value).toBeLessThanOrEqual(budget)
@@ -49,6 +57,7 @@ it('reconstructs a 100000-delta live prefix within baseline time and retained-me
const budgetMs = REPLACE_BUDGET_MS
const budgetMb = REFERENCE_RETAINED_MB * PERFORMANCE_BUDGET_HEADROOM
console.log(JSON.stringify({ benchmark: 'active-stream-reconnect', samples, median: { replaceMs, retainedMb }, referenceMs: REFERENCE_REPLACE_MS, referenceMb: REFERENCE_RETAINED_MB, budgetMs, budgetMb }))
+ recordTimings(CASE, { replaceMs: { ms: replaceMs, ioShare: IO_SHARE } }, { replaceMs: budgetMs })
expectReplacementWithinBudget(replaceMs, budgetMs)
expect.soft(retainedMb).toBeLessThanOrEqual(budgetMb)
})
diff --git a/benchmarks/agent-continuation/agent-continuation.bench.ts b/benchmarks/agent-continuation/agent-continuation.bench.ts
index 0377bd6789..12958ac151 100644
--- a/benchmarks/agent-continuation/agent-continuation.bench.ts
+++ b/benchmarks/agent-continuation/agent-continuation.bench.ts
@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts'
import type { ContinuationReport } from './agent-continuation.worker.ts'
import type { CatalogReport } from './child-catalog.worker.ts'
import type { ProfileReport } from './profile-continuation.worker.ts'
@@ -28,6 +29,30 @@ const CATALOG_BUDGET_MS = Math.ceil(EXPECTED_CATALOG_CI_MS * PERFORMANCE_BUDGET_
const REQUEST_HISTORY_BUDGET_MS = 297
const EXPECTED_RETAINED_HEAP_MB = 23
const WORKERS = join(import.meta.dirname, '..', '.dsh-build', 'agent-continuation')
+/** Estimated storage wait of the SDK profile case, whose worker does not report CPU time: it reads files and appends synchronized log records. */
+const PROFILE_IO_SHARE = 0.15
+const CASES: Readonly> = {
+ 'request-history': {
+ id: 'agent-continuation/request-history',
+ measures: 'Building 40 model requests over an 800-turn tool-heavy history, without provider or network work.',
+ affects: 'Every new turn in a long Session; the model request leaves later.',
+ },
+ 'tool-continuation': {
+ id: 'agent-continuation/tool-continuation',
+ measures: 'Cold resume of the 800-turn Session, then 20 turns with 8 synthetic tool calls each through the real tool pipeline.',
+ affects: 'Continuing a long tool-heavy Session after restarting DSH.',
+ },
+ 'catalog': {
+ id: 'agent-continuation/catalog',
+ measures: 'First and repeated discovery of 16 inactive fork children with 80-turn histories.',
+ affects: 'Listing forks and subagents of a Session that has many children.',
+ },
+ 'profile-continuation': {
+ id: 'agent-continuation/profile-continuation',
+ measures: 'Boot plus 100 turns with 800 real file reads through the shipped sdk-minimal profile.',
+ affects: 'Headless SDK automation running a long agent task.',
+ },
+}
type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | keyof typeof EXPECTED_MS
type Report = ContinuationReport | CatalogReport | ProfileReport
@@ -175,6 +200,11 @@ describe('continuing tool-heavy Sessions with large histories', () => {
samples, totalMs: { min: Math.min(...totalMs), median: median(totalMs), max: Math.max(...totalMs) },
budgetMs, ...(scenario === 'tool-continuation' ? { retainedHeapBudgetMb } : {}),
}))
+ recordTimings(CASES[scenario], {
+ totalMs: scenario === 'profile-continuation'
+ ? { ms: median(totalMs), ioShare: PROFILE_IO_SHARE }
+ : { ms: median(totalMs), cpuMs: median((samples as (ContinuationReport | CatalogReport)[]).map(sample => sample.cpuUserMs + sample.cpuSystemMs)) },
+ }, { totalMs: budgetMs })
if (scenario === 'request-history') assertRequestHistoryBudget(median(totalMs))
else expectTotalWithinBudget(median(totalMs), budgetMs)
if (scenario === 'tool-continuation') {
diff --git a/benchmarks/conversation-fold/conversation-fold.bench.client.ts b/benchmarks/conversation-fold/conversation-fold.bench.client.ts
index bee31c5b30..add5a34629 100644
--- a/benchmarks/conversation-fold/conversation-fold.bench.client.ts
+++ b/benchmarks/conversation-fold/conversation-fold.bench.client.ts
@@ -10,6 +10,7 @@ import {
ciTimeBudget,
PERFORMANCE_BUDGET_HEADROOM,
} from '../support/calibration.ts'
+import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts'
import type { ConversationFoldWorkerReport, PreparingToolWorkerReport } from './conversation-fold.worker.client.ts'
/** Replies in the folded window; each carries one reasoning block and one text block. */
@@ -20,6 +21,14 @@ const LARGE_DELTAS = 2_000
const SMALL_DELTAS = 100
/** Fresh object graphs measured in one compiled worker; the fastest sample removes scheduler delay. */
const ATTEMPTS = 3
+/** Folding and argument preparation run on in-memory Client state without storage access. */
+const IO_SHARE = 0
+const LARGE_WINDOW_CASE: BenchmarkCase = {
+ id: 'conversation-fold/large-window',
+ measures: 'Client fold of 200 replies whose 500,000 streamed deltas are compacted into 1,600 records.',
+ affects: 'Opening or scrolling a long conversation in the Web GUI.',
+}
+const PREPARING_AFFECTS = { write: 'Watching the model stream a large file write.', bash: 'Watching the model stream a long shell command.' } as const
/** A stuck fold worker is reaped before the outer benchmark deadline. */
const WORKER_TIMEOUT_MS = 60_000
@@ -77,6 +86,7 @@ describe('cold Chat fold of a large v2 history window', () => {
budgetMs: LARGE_FOLD_BUDGET_MS,
maxScaling: MAX_DELTA_SCALING,
}))
+ recordTimings(LARGE_WINDOW_CASE, { largeFoldMs: { ms: report.largeFoldMs, ioShare: IO_SHARE } }, { largeFoldMs: LARGE_FOLD_BUDGET_MS })
expect(report.chatNodes).toBeGreaterThan(0)
expect(report.largeFoldMs).toBeLessThanOrEqual(LARGE_FOLD_BUDGET_MS)
expect(report.scaling).toBeLessThanOrEqual(MAX_DELTA_SCALING)
@@ -108,6 +118,11 @@ describe('preparing tool arguments', () => {
const budgetMs = ciTimeBudget(workload.expectedMs)
const budgetMb = workload.expectedMb * PERFORMANCE_BUDGET_HEADROOM
console.log(JSON.stringify({ benchmark: `conversation-fold/preparing-${workload.tool}`, samples, medianMs, retainedMb, budgetMs, budgetMb }))
+ recordTimings({
+ id: `conversation-fold/preparing-${workload.tool}`,
+ measures: `Client publishing of ${String(workload.characters / 1024)} KiB of streamed ${workload.tool} tool arguments.`,
+ affects: PREPARING_AFFECTS[workload.tool],
+ }, { medianMs: { ms: medianMs, ioShare: IO_SHARE } }, { medianMs: budgetMs })
expect(medianMs).toBeLessThanOrEqual(budgetMs)
expect(retainedMb).toBeLessThanOrEqual(budgetMb)
})
diff --git a/benchmarks/long-session-browser/long-session.bench.ts b/benchmarks/long-session-browser/long-session.bench.ts
index c14f9a299d..56fc53b601 100644
--- a/benchmarks/long-session-browser/long-session.bench.ts
+++ b/benchmarks/long-session-browser/long-session.bench.ts
@@ -8,6 +8,7 @@ import { expect, it, vi } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from '../../apps/web/tests/scaffold.ts'
import { newEnglishPage } from '../../apps/web/tests/support.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import { recordTimings, type BenchmarkCase } from '../support/scaling-report.ts'
import { HISTORY_TURNS, SESSION_ID, FIRST, DONE, DELTAS, PACE_MS, syntheticHistory, syntheticReply } from './synthetic-history.ts'
const SAMPLES = 3
@@ -20,6 +21,15 @@ const OPEN_BUDGET_MS = Math.ceil(EXPECTED_OPEN_CI_MS * PERFORMANCE_BUDGET_HEADRO
const PAGE_BUDGET_MS = Math.ceil(EXPECTED_PAGE_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
const TRAJECTORY_BUDGET_MS = Math.ceil(EXPECTED_TRAJECTORY_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
const REPLAY_DURATION_MS = (DELTAS + 4) * PACE_MS
+/** Endpoints without paced replay waits, so CPU scaling applies to their whole duration. */
+const SCALED_ENDPOINTS = ['open', 'page', 'trajectory', 'streamTask'] as const
+/** Estimated storage wait: the Host reads the seeded Session log, which the page cache usually holds. */
+const IO_SHARE = 0.05
+const CASE: BenchmarkCase = {
+ id: 'long-session-browser',
+ measures: 'Chromium on the built Web GUI: opening a 240-turn Session, the slowest older page, first Trajectory view, and main-thread task time while a reply streams.',
+ affects: 'Everyday Web GUI use with a long Session.',
+}
async function painted(page: Page): Promise {
// Two rAF callbacks include a rendering opportunity, not a GPU presentation timestamp.
@@ -439,5 +449,6 @@ it('opens, pages, navigates and streams into a 240-turn browser history', async
open: OPEN_BUDGET_MS, page: PAGE_BUDGET_MS, trajectory: TRAJECTORY_BUDGET_MS,
}
console.log(JSON.stringify({ benchmark: 'long-session-browser/median', turns: HISTORY_TURNS, deltas: DELTAS, paceMs: PACE_MS, samples, aggregate, referenceMs: REFERENCE, expectedOpenCiMs: EXPECTED_OPEN_CI_MS, expectedPageCiMs: EXPECTED_PAGE_CI_MS, expectedTrajectoryCiMs: EXPECTED_TRAJECTORY_CI_MS, budgets }))
+ recordTimings(CASE, Object.fromEntries(SCALED_ENDPOINTS.map(key => [key, { ms: aggregate[key]!, ioShare: IO_SHARE }])), budgets)
for (const [key, value] of Object.entries(aggregate)) expectEndpointWithinBudget(value, budgets[key]!)
})
diff --git a/benchmarks/session-corpus/projection-list.bench.ts b/benchmarks/session-corpus/projection-list.bench.ts
index 26af1e52b3..d176b68dd2 100644
--- a/benchmarks/session-corpus/projection-list.bench.ts
+++ b/benchmarks/session-corpus/projection-list.bench.ts
@@ -6,6 +6,7 @@ import { join } from 'node:path'
import { expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import { recordTimings } from '../support/scaling-report.ts'
import type { ProjectionListReport } from './projection-list.worker.ts'
const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'session-corpus', 'projection-list.worker.js')
@@ -17,6 +18,10 @@ const LIST_REFERENCE_MS = { modest: 50, tail: 500, cheap: 150 } as const
const CALLBACK_REFERENCE_MS = 30
/** Reference retained-heap allowance; memory receives variance headroom but no CPU scaling. */
const RETAINED_HEAP_REFERENCE_BYTES = 240 * 1024 * 1024
+/** Queue delay is time other callbacks wait behind list work on the Host thread, which is CPU work. */
+const CALLBACK_IO_SHARE = 0
+/** Sessions per workload. */
+const SESSIONS = { modest: 50, tail: 300, cheap: 3_000 } as const
function median(values: readonly number[]): number {
return [...values].sort((left, right) => left - right)[Math.floor(values.length / 2)] as number
@@ -68,9 +73,18 @@ it.each(['modest', 'tail', 'cheap'] as const)('serves the %s projection list wit
console.log(JSON.stringify({ benchmark: `session-corpus/projection-list-${workload}`,
reports, medians, budgets, environment: { node: process.version, platform: process.platform, arch: process.arch, cpu: cpus()[0]?.model } }))
+ recordTimings({
+ id: `session-corpus/projection-list-${workload}`,
+ measures: `Host Session-list projection and JSON for ${SESSIONS[workload].toLocaleString('en-US')} Sessions, and the longest Host queue delay meanwhile.`,
+ affects: 'Showing the Session sidebar; a long queue delay stalls other Web GUI requests.',
+ }, {
+ firstListAndJsonMs: { ms: medians.firstListAndJsonMs, cpuMs: medians.firstListAndJsonCpuMs },
+ repeatListAndJsonMs: { ms: medians.repeatListAndJsonMs, cpuMs: medians.repeatListAndJsonCpuMs },
+ worstCallbackDelayMs: { ms: medians.worstCallbackDelayMs, ioShare: CALLBACK_IO_SHARE },
+ }, { firstListAndJsonMs: budgets.listAndJsonMs, repeatListAndJsonMs: budgets.listAndJsonMs, worstCallbackDelayMs: budgets.callbackDelayMs })
for (const report of reports) {
expect(report.workload).toBe(workload)
- expect(report.fixture.sessions).toBe({ modest: 50, tail: 300, cheap: 3_000 }[workload])
+ expect(report.fixture.sessions).toBe(SESSIONS[workload])
expect(report.fixture.baseRows).toBe({ modest: 100, tail: 1_000, cheap: 0 }[workload])
expect(report.samples).toHaveLength(4)
for (const sample of [...report.samples, report.responsiveness]) {
diff --git a/benchmarks/session-corpus/session-corpus.bench.ts b/benchmarks/session-corpus/session-corpus.bench.ts
index 1cc7e2b10f..00bc2d2979 100644
--- a/benchmarks/session-corpus/session-corpus.bench.ts
+++ b/benchmarks/session-corpus/session-corpus.bench.ts
@@ -7,12 +7,14 @@ import { performance } from 'node:perf_hooks'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import { recordPeakMemory, recordTimings, type BenchmarkCase, type Timing } from '../support/scaling-report.ts'
import { anchorShape, ANCHOR_COUNT, sessionShape } from './corpus-shape.ts'
import { subagentRank } from './synthetic-corpus.ts'
import type {
AnchorsReport,
ForkReport,
ListReport,
+ PhaseCpu,
SearchReport,
SeedReport,
SessionCorpusReport,
@@ -69,8 +71,40 @@ const EXPECTED_CI_PEAK_RSS_MB = {
fork: 750,
} as const satisfies Record
+const CASES: Readonly> = {
+ list: {
+ id: `session-corpus/list-${String(CORPUS.list)}`,
+ measures: `Cold Web Host boot, then first and repeated session.list over ${CORPUS.list.toLocaleString('en-US')} Sessions of realistic length.`,
+ affects: 'Starting DSH and showing the Session list for a heavy user.',
+ },
+ search: {
+ id: 'session-corpus/search',
+ measures: `First session.search, which builds the content index over ${CORPUS.search.toLocaleString('en-US')} Sessions, then a second query.`,
+ affects: 'Searching Session content in deployments that enable search; shipped profiles disable it.',
+ },
+ fork: {
+ id: 'session-corpus/fork',
+ measures: `session.fork for ten length strata, the p99 Session, and the longest of ${CORPUS.fork.toLocaleString('en-US')} Sessions.`,
+ affects: 'Forking a conversation to try another direction.',
+ },
+}
+
type CorpusName = keyof typeof CORPUS
+/**
+ * Pair the median wall time of each list phase with the median process CPU time of the same phase.
+ * @param samples - Wall-clock samples per phase.
+ * @param cpu - CPU time per sample.
+ * @returns Endpoints for the scaled report.
+ */
+function phaseTimings(
+ samples: Readonly>,
+ cpu: readonly PhaseCpu[],
+): Record {
+ const phase = (key: keyof PhaseCpu): Timing => ({ ms: median(samples[key]), cpuMs: median(cpu.map(entry => entry[key])) })
+ return { bootMs: phase('bootMs'), firstMs: phase('firstMs'), repeatMs: phase('repeatMs') }
+}
+
function topLevelRank(rank: number): number {
return subagentRank(rank) ? rank - 1 : rank
}
@@ -221,6 +255,9 @@ describe('Session corpus operations', () => {
heapUsedMb: reports.map(report => report.memory.heapUsedMb),
}
console.log(JSON.stringify({ benchmark: `session-corpus/list-${String(CORPUS.list)}`, samples, budgets, environment: environment() }))
+ recordTimings(CASES.list, phaseTimings(samples, reports.map(report => report.cpu)),
+ { bootMs: budgets.bootMs, firstMs: budgets.firstMs, repeatMs: budgets.repeatMs })
+ recordPeakMemory(CASES.list, { peakRssMb: Math.max(...samples.peakRssMb) })
for (const report of reports) expect(report.itemsWithProjections).toBe(CORPUS.list)
expectWithinBudget(median(samples.bootMs), budgets.bootMs)
expectWithinBudget(median(samples.firstMs), budgets.firstMs)
@@ -237,6 +274,11 @@ describe('Session corpus operations', () => {
// One sample: the cold index build dominates this file's time.
const report = await run([root('search'), 'search'])
console.log(JSON.stringify({ benchmark: 'session-corpus/search', report, budgets, environment: environment() }))
+ recordTimings(CASES.search, {
+ firstMs: { ms: report.firstMs, cpuMs: report.cpu.firstMs },
+ repeatMs: { ms: report.repeatMs, cpuMs: report.cpu.repeatMs },
+ }, { firstMs: budgets.firstMs, repeatMs: budgets.repeatMs })
+ recordPeakMemory(CASES.search, { peakRssMb: report.memory.peakRssMb })
expectWithinBudget(report.firstMs, budgets.firstMs)
expectWithinBudget(report.repeatMs, budgets.repeatMs)
expectWithinBudget(report.memory.peakRssMb, budgets.peakRssMb)
@@ -254,11 +296,13 @@ describe('Session corpus operations', () => {
reports.push(await run([root('fork'), 'fork', ...[...FORK_STRATA, FORK_P99_RANK].map(String)]))
}
const longest = await run([root('fork'), 'fork', String(FORK_LONGEST_RANK)])
- const forkMs = (report: ForkReport, rank: number): number => {
+ const forkEntry = (report: ForkReport, rank: number): ForkReport['forks'][number] => {
const fork = report.forks.find(entry => entry.rank === rank)
if (fork === undefined) throw new Error(`fork report omits rank ${String(rank)}`)
- return fork.forkMs
+ return fork
}
+ const forkMs = (report: ForkReport, rank: number): number => forkEntry(report, rank).forkMs
+ const forkCpuMs = (report: ForkReport, rank: number): number => forkEntry(report, rank).forkCpuMs
const samples = {
stratumMedianMs: rounded(reports.map(report => median(FORK_STRATA.map(rank => forkMs(report, rank))))),
p99Ms: rounded(reports.map(report => forkMs(report, FORK_P99_RANK))),
@@ -267,6 +311,16 @@ describe('Session corpus operations', () => {
peakRssMb: [...reports, longest].map(report => report.memory.peakRssMb),
}
console.log(JSON.stringify({ benchmark: 'session-corpus/fork', samples, budgets, environment: environment() }))
+ recordTimings(CASES.fork, {
+ stratumMedianMs: {
+ ms: median(samples.stratumMedianMs),
+ cpuMs: median(reports.map(report => median(FORK_STRATA.map(rank => forkCpuMs(report, rank))))),
+ },
+ p99Ms: { ms: median(samples.p99Ms), cpuMs: median(reports.map(report => forkCpuMs(report, FORK_P99_RANK))) },
+ longestMs: { ms: samples.longestMs, cpuMs: forkCpuMs(longest, FORK_LONGEST_RANK) },
+ },
+ { stratumMedianMs: budgets.stratumMedianMs, p99Ms: budgets.p99Ms, longestMs: budgets.longestMs })
+ recordPeakMemory(CASES.fork, { peakRssMb: Math.max(...samples.peakRssMb) })
expectWithinBudget(median(samples.stratumMedianMs), budgets.stratumMedianMs)
expectWithinBudget(median(samples.p99Ms), budgets.p99Ms)
expectWithinBudget(samples.longestMs, budgets.longestMs)
diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts
index aed7297aa3..8dabac10ca 100644
--- a/benchmarks/session-corpus/session-corpus.worker.ts
+++ b/benchmarks/session-corpus/session-corpus.worker.ts
@@ -60,12 +60,20 @@ export interface SeedReport {
readonly seedMs: number
}
+/** Process CPU milliseconds, user plus system, over the same intervals as the wall-clock endpoints. */
+export interface PhaseCpu {
+ readonly bootMs: number
+ readonly firstMs: number
+ readonly repeatMs: number
+}
+
/** List report: Host boot, then a cold and a repeated Session list. */
export interface ListReport {
readonly mode: 'list'
readonly bootMs: number
readonly firstMs: number
readonly repeatMs: number
+ readonly cpu: PhaseCpu
readonly items: number
readonly itemsWithProjections: number
readonly memory: CorpusMemory
@@ -77,6 +85,7 @@ export interface SearchReport {
readonly bootMs: number
readonly firstMs: number
readonly repeatMs: number
+ readonly cpu: PhaseCpu
readonly firstItems: number
readonly repeatItems: number
readonly memory: CorpusMemory
@@ -90,6 +99,7 @@ export interface ForkReport {
readonly rank: number
readonly sourceEvents: number
readonly forkMs: number
+ readonly forkCpuMs: number
}[]
readonly memory: CorpusMemory
}
@@ -251,16 +261,25 @@ async function seed(root: string, name: string, count: number): Promise {
const started = performance.now()
+ const cpuStarted = cpuMs()
const { ctx, controller } = await mountHost(root)
try {
const booted = performance.now()
+ const cpuBooted = cpuMs()
const signal = new AbortController().signal
const first = await controller.list({}, signal)
const firstDone = performance.now()
+ const cpuFirstDone = cpuMs()
const repeated = await controller.list({}, signal)
const done = performance.now()
+ const cpuDone = cpuMs()
if (first.items.length !== expected || repeated.items.length !== expected) {
throw new Error(`Session list returned ${String(first.items.length)} of ${String(expected)} Sessions`)
}
@@ -269,6 +288,7 @@ async function measureList(root: string, expected: number): Promise
bootMs: booted - started,
firstMs: firstDone - booted,
repeatMs: done - firstDone,
+ cpu: { bootMs: cpuBooted - cpuStarted, firstMs: cpuFirstDone - cpuBooted, repeatMs: cpuDone - cpuFirstDone },
items: first.items.length,
itemsWithProjections: first.items.filter(item => item.projections !== undefined).length,
memory: await memory(),
@@ -280,14 +300,18 @@ async function measureList(root: string, expected: number): Promise
async function measureSearch(root: string): Promise {
const started = performance.now()
+ const cpuStarted = cpuMs()
const { ctx, controller } = await mountHost(root)
try {
const booted = performance.now()
+ const cpuBooted = cpuMs()
const signal = new AbortController().signal
const first = await controller.search({ query: SEARCH_QUERIES[0] }, signal)
const firstDone = performance.now()
+ const cpuFirstDone = cpuMs()
const repeated = await controller.search({ query: SEARCH_QUERIES[1] }, signal)
const done = performance.now()
+ const cpuDone = cpuMs()
if (first.items.length === 0 || repeated.items.length === 0) {
throw new Error('Session search matched no synthetic Session')
}
@@ -296,6 +320,7 @@ async function measureSearch(root: string): Promise {
bootMs: booted - started,
firstMs: firstDone - booted,
repeatMs: done - firstDone,
+ cpu: { bootMs: cpuBooted - cpuStarted, firstMs: cpuFirstDone - cpuBooted, repeatMs: cpuDone - cpuFirstDone },
firstItems: first.items.length,
repeatItems: repeated.items.length,
memory: await memory(),
@@ -313,11 +338,13 @@ async function measureFork(root: string, ranks: readonly number[]): Promise> = {
+ 'first-open': 'Opening a large Session for the first time after upgrading DSH.',
+ 'post-upgrade-reopen': 'Opening a large Session again in a new DSH process.',
+}
+
+/**
+ * Describe one Session-open case.
+ * @param access - Access kind and its label.
+ * @param endpoint - Case suffix and what it measures.
+ * @returns Case metadata for the scaled report.
+ */
+function openCase(access: AccessBenchmarkSpec, endpoint: { readonly suffix: string; readonly measures: string }): BenchmarkCase {
+ return {
+ id: `session-open/${access.accessKind}/${endpoint.suffix}`,
+ measures: `${endpoint.measures} One 127,400-event Session, ${access.label}.`,
+ affects: ACCESS_AFFECTS[access.accessKind],
+ }
+}
type WorkerRun = BuiltBenchmarkWorkerRun
@@ -393,6 +419,12 @@ describe('opening a large Session for first open and post-upgrade reopen', () =>
projection: PROJECTION_BUDGET_MS,
},
}))
+ recordTimings(openCase(access, { suffix: 'phases', measures: 'File open, read, Session restore, and projection phases.' }), {
+ openMs: { ms: result.openMs.median, ioShare: PHASE_IO_SHARE.file },
+ readMs: { ms: result.readMs.median, ioShare: PHASE_IO_SHARE.file },
+ sessionRestoreMs: { ms: result.sessionRestoreMs.median, ioShare: PHASE_IO_SHARE.memory },
+ projectionMs: { ms: result.projectionMs.median, ioShare: PHASE_IO_SHARE.memory },
+ }, { openMs: access.openBudgetMs, readMs: READ_BUDGET_MS, sessionRestoreMs: SESSION_RESTORE_BUDGET_MS, projectionMs: PROJECTION_BUDGET_MS })
expectOpenWithinBudget(result.openMs.median, access.openBudgetMs)
expect(result.readMs.median).toBeLessThanOrEqual(READ_BUDGET_MS)
expect(result.sessionRestoreMs.median).toBeLessThanOrEqual(SESSION_RESTORE_BUDGET_MS)
@@ -417,6 +449,9 @@ describe('opening a large Session for first open and post-upgrade reopen', () =>
result,
budgetMs: access.firstHistoryBudgetMs,
}))
+ recordTimings(openCase(access, { suffix: 'first-history', measures: 'Host delivery of the first history snapshot.' }),
+ { totalMs: { ms: result.totalMs.median, cpuMs: result.cpuUserMs.median + result.cpuSystemMs.median } },
+ { totalMs: access.firstHistoryBudgetMs })
expect(result.totalMs.median).toBeLessThanOrEqual(access.firstHistoryBudgetMs)
})
@@ -439,6 +474,9 @@ describe('opening a large Session for first open and post-upgrade reopen', () =>
budgetMs: access.agentResumeBudgetMs,
retainedHeapBudgetMb: AGENT_RETAINED_HEAP_BUDGET_MB,
}))
+ recordTimings(openCase(access, { suffix: 'agent-resume', measures: 'Cold Agent resume until the resumed Agent handle returns.' }),
+ { totalMs: { ms: result.totalMs.median, cpuMs: result.cpuUserMs.median + result.cpuSystemMs.median } },
+ { totalMs: access.agentResumeBudgetMs })
expect(result.totalMs.median).toBeLessThanOrEqual(access.agentResumeBudgetMs)
expect(result.retainedHeapMb.median).toBeLessThanOrEqual(AGENT_RETAINED_HEAP_BUDGET_MB)
})
diff --git a/benchmarks/support/machine-profiles.ts b/benchmarks/support/machine-profiles.ts
new file mode 100644
index 0000000000..0ceb3417ba
--- /dev/null
+++ b/benchmarks/support/machine-profiles.ts
@@ -0,0 +1,58 @@
+/**
+ * Reviewed machine profiles for the scaled-scenario benchmark report.
+ *
+ * `cpuScore` is the PassMark single-thread rating retrieved on 2026-10-07 from cpubenchmark.net. Measured
+ * endpoints run on one JavaScript thread, so the report scales their CPU-bound share by the single-thread
+ * ratio. `cores` limits endpoints whose measured CPU time shows more than one busy CPU; memory size is not
+ * modelled. PassMark ratings across ISAs, for burstable or shared vCPUs, and for hybrid efficiency cores, are
+ * coarse.
+ *
+ * `ioLatencyUs` is a typical synchronous 4 KiB random-access latency at queue depth 1 for the profile's
+ * default storage. Laptop values come from published SSD reviews for the interface generation; cloud values
+ * come from the provider's documented latency class for its default system disk. They are estimates.
+ */
+
+/** One modelled machine with its CPU and default-storage performance. */
+export interface MachineProfile {
+ readonly id: string
+ readonly label: string
+ readonly cpu: string
+ readonly cpuScore: number
+ /** Logical CPUs: hardware threads on laptops, vCPUs on cloud instances. */
+ readonly cores: number
+ readonly storage: string
+ readonly ioLatencyUs: number
+ /** Lower-case substrings of `os.cpus()[0].model` that identify the measuring machine as this profile. */
+ readonly cpuModelMatches?: readonly string[]
+}
+
+/** Profiles in report order. */
+export const MACHINE_PROFILES: readonly MachineProfile[] = [
+ { id: 'laptop-2015-win', label: '2015 Windows laptop', cpu: 'Intel Core i5-5200U', cpuScore: 1486, cores: 4, storage: 'SATA SSD', ioLatencyUs: 90 },
+ { id: 'laptop-2015-mac', label: '2015 MacBook Pro 13"', cpu: 'Intel Core i5-5257U', cpuScore: 1737, cores: 4, storage: 'PCIe AHCI SSD', ioLatencyUs: 90 },
+ { id: 'laptop-2019-win', label: '2019 Windows laptop', cpu: 'Intel Core i5-8265U', cpuScore: 2003, cores: 8, storage: 'NVMe PCIe 3.0 SSD', ioLatencyUs: 70 },
+ { id: 'laptop-2023-win', label: '2023 Windows laptop', cpu: 'Intel Core i5-1335U', cpuScore: 3241, cores: 12, storage: 'NVMe PCIe 4.0 SSD', ioLatencyUs: 55 },
+ { id: 'laptop-2023-mac', label: '2023 MacBook Air', cpu: 'Apple M2', cpuScore: 4208, cores: 8, storage: 'Apple SSD', ioLatencyUs: 55, cpuModelMatches: ['apple m2'] },
+ { id: 'laptop-2026-win', label: '2026 Windows laptop', cpu: 'Intel Core Ultra 5 225H', cpuScore: 4247, cores: 14, storage: 'NVMe PCIe 4.0 SSD', ioLatencyUs: 50 },
+ { id: 'laptop-2026-mac', label: '2026 MacBook', cpu: 'Apple M5', cpuScore: 6341, cores: 10, storage: 'Apple SSD', ioLatencyUs: 50, cpuModelMatches: ['apple m5'] },
+ { id: 'aws-t3', label: 'AWS t3.medium', cpu: 'Intel Xeon Platinum 8259CL', cpuScore: 1948, cores: 2, storage: 'EBS gp3', ioLatencyUs: 500 },
+ { id: 'aws-m6i', label: 'AWS m6i.large', cpu: 'Intel Xeon Platinum 8375C', cpuScore: 2474, cores: 2, storage: 'EBS gp3', ioLatencyUs: 500 },
+ { id: 'aws-m7i', label: 'AWS m7i.large', cpu: 'Intel Xeon Platinum 8488C', cpuScore: 3113, cores: 2, storage: 'EBS gp3', ioLatencyUs: 500 },
+ { id: 'aws-m7g', label: 'AWS m7g.large', cpu: 'AWS Graviton3 (Neoverse V1)', cpuScore: 1554, cores: 2, storage: 'EBS gp3', ioLatencyUs: 500 },
+ { id: 'gcp-n2', label: 'GCP n2-standard-4', cpu: 'Intel Xeon Platinum 8273CL', cpuScore: 2200, cores: 4, storage: 'pd-balanced', ioLatencyUs: 800 },
+ { id: 'azure-d4s-v5', label: 'Azure D4s v5', cpu: 'Intel Xeon Ice Lake (8375C rating)', cpuScore: 2474, cores: 4, storage: 'Premium SSD P10', ioLatencyUs: 1000 },
+ { id: 'aliyun-g7', label: 'Alibaba Cloud ecs.g7.xlarge', cpu: 'Intel Xeon Ice Lake (8375C rating)', cpuScore: 2474, cores: 4, storage: 'ESSD PL0', ioLatencyUs: 400 },
+ { id: 'github-ubuntu-24.04', label: 'GitHub ubuntu-24.04 runner', cpu: 'AMD EPYC 7763', cpuScore: 2517, cores: 2, storage: 'Azure managed disk', ioLatencyUs: 300, cpuModelMatches: ['epyc 7763'] },
+ { id: 'github-ubuntu-24.04-genoa', label: 'GitHub ubuntu-24.04 runner (Genoa)', cpu: 'AMD EPYC 9V74', cpuScore: 2888, cores: 2, storage: 'Azure managed disk', ioLatencyUs: 300, cpuModelMatches: ['epyc 9v74'] },
+ { id: 'reference-m4-pro', label: 'Calibration reference', cpu: 'Apple M4 Pro', cpuScore: 4547, cores: 14, storage: 'Apple SSD', ioLatencyUs: 55, cpuModelMatches: ['apple m4'] },
+]
+
+/**
+ * Identify the measuring machine among the reviewed profiles.
+ * @param cpuModel - `os.cpus()[0].model` of the machine that ran the benchmarks.
+ * @returns the matching profile id, or `undefined` when no profile describes the machine.
+ */
+export function matchMachineProfile(cpuModel: string): string | undefined {
+ const model = cpuModel.toLowerCase()
+ return MACHINE_PROFILES.find(profile => profile.cpuModelMatches?.some(match => model.includes(match)) === true)?.id
+}
diff --git a/benchmarks/support/scaling-report.ts b/benchmarks/support/scaling-report.ts
new file mode 100644
index 0000000000..876e197222
--- /dev/null
+++ b/benchmarks/support/scaling-report.ts
@@ -0,0 +1,294 @@
+/**
+ * Collects budgeted benchmark wall-clock endpoints and renders them as a self-contained interactive HTML
+ * report that rescales each endpoint to the reviewed machine profiles.
+ *
+ * Bench files call {@link recordTimings} in the Vitest worker; the Vitest global setup of
+ * `vitest.bench.config.ts` clears {@link REPORT_DIRECTORY} before the run and renders `report.html` after
+ * it, including after failed cases.
+ *
+ * A scaled endpoint is `ms × ((1 − io) × cpuScore(measured) / cpuScore(target) × coreFactor + io ×
+ * ioLatency(target) / ioLatency(measured))`. When the endpoint reports process CPU time, `io` is the measured
+ * share of wall time not spent on CPU, `max(0, 1 − cpuMs / ms)`, and its busy parallelism is
+ * `max(1, cpuMs / ms)`; otherwise `io` is a declared estimate and parallelism is 1. `coreFactor` is
+ * `max(1, parallelism / cores(target)) / max(1, parallelism / cores(measured))`. Readers can override `io`
+ * per endpoint. Memory and dimensionless ratios are not scaled.
+ *
+ * Bench files also pass peak memory to {@link recordPeakMemory}. The report compares those unscaled peaks
+ * with 4 GB and 8 GB of available memory; it does not run cases under an operating-system memory limit.
+ */
+
+import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { availableParallelism, cpus } from 'node:os'
+import { join } from 'node:path'
+import { MACHINE_PROFILES, matchMachineProfile } from './machine-profiles.ts'
+
+/** Directory owning `results.jsonl` and `report.html`; ignored by Git. */
+export const REPORT_DIRECTORY = join(import.meta.dirname, '..', '.dsh-report')
+const RESULTS_PATH = join(REPORT_DIRECTORY, 'results.jsonl')
+const REPORT_PATH = join(REPORT_DIRECTORY, 'report.html')
+
+/** Identity and reader-facing description of one benchmark case, shown when hovering its rows in the report. */
+export interface BenchmarkCase {
+ /** Case id, such as `session-open/first-open/phases`. */
+ readonly id: string
+ /** What the timed or sampled work is, including workload size. */
+ readonly measures: string
+ /** Which user-visible scenario the measurement represents. */
+ readonly affects: string
+}
+
+/** Fields that every record copies from its {@link BenchmarkCase}. */
+interface CaseFields {
+ readonly benchmark: string
+ readonly measures: string
+ readonly affects: string
+}
+
+function caseFields(benchmarkCase: BenchmarkCase): CaseFields {
+ return { benchmark: benchmarkCase.id, measures: benchmarkCase.measures, affects: benchmarkCase.affects }
+}
+
+/**
+ * One endpoint: its wall-clock milliseconds and either the process CPU milliseconds measured over the same
+ * interval or, where the worker cannot measure CPU time, a declared share from 0 to 1 of wall time spent
+ * waiting on storage.
+ */
+export type Timing =
+ | { readonly ms: number; readonly cpuMs: number }
+ | { readonly ms: number; readonly ioShare: number }
+
+/** One recorded wall-clock endpoint. */
+export type TimingRecord = CaseFields & Timing & {
+ readonly kind: 'time'
+ readonly metric: string
+ readonly budgetMs?: number
+}
+
+/**
+ * Append measured endpoints of one benchmark case to the report results.
+ * @param benchmarkCase - Case id and description.
+ * @param timings - Endpoints keyed by name; record before asserting budgets.
+ * @param budgetsMs - CI budgets keyed by the same endpoint names, where the case enforces one.
+ */
+export function recordTimings(
+ benchmarkCase: BenchmarkCase,
+ timings: Readonly>,
+ budgetsMs: Readonly> = {},
+): void {
+ appendRecords(Object.entries(timings).map(([metric, timing]): TimingRecord => {
+ const budgetMs = budgetsMs[metric]
+ return { kind: 'time', ...caseFields(benchmarkCase), metric, ...timing, ...budgetMs === undefined ? {} : { budgetMs } }
+ }))
+}
+
+/** One recorded peak memory measurement. */
+export interface MemoryRecord extends CaseFields {
+ readonly kind: 'memory'
+ readonly metric: string
+ readonly mb: number
+}
+
+/**
+ * Append peak memory of one benchmark case to the report results.
+ * @param benchmarkCase - Case id and description.
+ * @param peaksMb - Peak megabytes keyed by measurement name, such as `peakRssMb`.
+ */
+export function recordPeakMemory(benchmarkCase: BenchmarkCase, peaksMb: Readonly>): void {
+ appendRecords(Object.entries(peaksMb).map(([metric, mb]): MemoryRecord => ({ kind: 'memory', ...caseFields(benchmarkCase), metric, mb })))
+}
+
+function appendRecords(records: readonly (TimingRecord | MemoryRecord)[]): void {
+ mkdirSync(REPORT_DIRECTORY, { recursive: true })
+ appendFileSync(RESULTS_PATH, records.map(record => JSON.stringify(record) + '\n').join(''))
+}
+
+/** Vitest global setup: discard results of an earlier run. */
+export function setup(): void {
+ rmSync(REPORT_DIRECTORY, { recursive: true, force: true })
+}
+
+/** Vitest global teardown: render the report when any case recorded a timing. */
+export function teardown(): void {
+ if (!existsSync(RESULTS_PATH)) return
+ const records = readFileSync(RESULTS_PATH, 'utf8').split('\n').filter(line => line !== '').map(line => JSON.parse(line) as TimingRecord | MemoryRecord)
+ const cpuModel = cpus()[0]?.model.trim() ?? 'unknown CPU'
+ writeFileSync(REPORT_PATH, renderReport({
+ records,
+ cpuModel,
+ platform: `${process.platform}-${process.arch}`,
+ node: process.version,
+ revision: process.env['GITHUB_SHA'],
+ generatedAt: new Date().toISOString(),
+ cores: availableParallelism(),
+ measuredProfile: matchMachineProfile(cpuModel),
+ }))
+ console.log(`benchmark report: ${REPORT_PATH}`)
+}
+
+/** Inputs embedded in one rendered report. */
+export interface ReportData {
+ readonly records: readonly (TimingRecord | MemoryRecord)[]
+ readonly cpuModel: string
+ readonly platform: string
+ readonly node: string
+ readonly revision: string | undefined
+ readonly generatedAt: string
+ /** Logical CPUs available to the measuring process; replaces the matched profile's core count. */
+ readonly cores: number
+ /** Profile id describing the measuring machine; the reader chooses one when absent. */
+ readonly measuredProfile: string | undefined
+}
+
+/**
+ * Render the self-contained report page.
+ * @param data - Recorded endpoints and the measuring environment.
+ * @returns HTML without external resources.
+ */
+export function renderReport(data: ReportData): string {
+ const payload = JSON.stringify({ ...data, profiles: MACHINE_PROFILES }).replaceAll('<', '\\u003c')
+ return `
+
+
+
+
+DeepSeek Harness benchmark scenarios
+
+
+
+
DeepSeek Harness benchmark scenarios
+
+
Machines
+
Each measured endpoint is rescaled to other machines. Its CPU share scales by the PassMark single-thread rating ratio and slows further when the endpoint kept more CPUs busy than the target has. Its storage share scales by the typical 4 KiB queue-depth-1 latency ratio of the default disk. Endpoints that report process CPU time use their measured share of wall time not spent on CPU as the storage share; the others use a declared estimate. Profiles are estimates; memory size is not modelled.
+
+
+
+
+
+
+
+
+
< 100 ms< 1 s< 10 s≥ 10 s Hover an endpoint for what it measures; click a row to chart it.
+
+
Memory pressure
+
Measured peak memory of each case against 4 GB and 8 GB of memory available to DeepSeek Harness. Peaks are not scaled. Cases were not run under an operating-system memory limit, so swapping and reclaim costs are not measured.
+
+
< 50%< 75%< 100%≥ 100% of available memory
+
+
+
+
+`
+}
diff --git a/benchmarks/terminal-io/terminal-io.bench.ts b/benchmarks/terminal-io/terminal-io.bench.ts
index 631a00a24a..bb00ff832c 100644
--- a/benchmarks/terminal-io/terminal-io.bench.ts
+++ b/benchmarks/terminal-io/terminal-io.bench.ts
@@ -3,6 +3,7 @@ import { join } from 'node:path'
import { expect, it } from 'vitest'
import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
import { ciTimeBudget } from '../support/calibration.ts'
+import { recordTimings, type BenchmarkCase, type Timing } from '../support/scaling-report.ts'
import type { TerminalIoReport } from './terminal-io.worker.ts'
const MIB = 1024 * 1024
@@ -11,6 +12,18 @@ const WORKER = join(import.meta.dirname, '..', '.dsh-build', 'terminal-io', 'ter
/** M5 Pro / Node 26.5 reference expectations, before shared CI scaling and headroom. */
const EXPECTED_MS = { steadyIngest: 20, steadyComplete: 50, fullComplete: 120 }
const MAX_CAPACITY_RATIO = 4
+/** Terminal output is buffered in memory; no storage access is timed. */
+const IO_SHARE = 0
+const STEADY_CASE: BenchmarkCase = {
+ id: 'terminal-io/steady',
+ measures: 'Terminal ingest and completion of 1 MiB of output into 128 KiB and 4 MiB retained windows.',
+ affects: 'Commands that keep printing output in the terminal tool.',
+}
+const FIVE_MIB_CASE: BenchmarkCase = {
+ id: 'terminal-io/five-mib',
+ measures: 'Complete terminal send of 5 MiB of output with a 4 MiB retained window.',
+ affects: 'Commands that print large logs.',
+}
const MAX_RETAINED_HEAP_BYTES = 16 * MIB
function median(values: readonly number[]): number {
@@ -38,6 +51,11 @@ it('bounds steady overflow cost as retained terminal capacity grows 32 times', a
const ingestBudgetMs = ciTimeBudget(EXPECTED_MS.steadyIngest)
const completeBudgetMs = ciTimeBudget(EXPECTED_MS.steadyComplete)
console.log(JSON.stringify({ scenario: 'terminal-steady', small, large, capacityRatio, ingestBudgetMs, completeBudgetMs }))
+ const declared = (ms: number): Timing => ({ ms, ioShare: IO_SHARE })
+ recordTimings(STEADY_CASE, {
+ smallIngestMs: declared(median(small.map(row => row.ingestMs))), largeIngestMs: declared(median(large.map(row => row.ingestMs))),
+ smallCompleteMs: declared(median(small.map(row => row.completeMs))), largeCompleteMs: declared(median(large.map(row => row.completeMs))),
+ }, { smallIngestMs: ingestBudgetMs, largeIngestMs: ingestBudgetMs, smallCompleteMs: completeBudgetMs, largeCompleteMs: completeBudgetMs })
expect(capacityRatio).toBeLessThanOrEqual(MAX_CAPACITY_RATIO)
for (const rows of [small, large]) {
expect(median(rows.map(row => row.ingestMs))).toBeLessThanOrEqual(ingestBudgetMs)
@@ -64,6 +82,7 @@ it('completes a five MiB terminal send with bounded retained output', async () =
const completeMedianMs = median(samples.map(row => row.completeMs))
const completeBudgetMs = ciTimeBudget(EXPECTED_MS.fullComplete)
console.log(JSON.stringify({ scenario: 'terminal-five-mib', samples, completeMedianMs, completeBudgetMs }))
+ recordTimings(FIVE_MIB_CASE, { completeMs: { ms: completeMedianMs, ioShare: IO_SHARE } }, { completeMs: completeBudgetMs })
expect(completeMedianMs).toBeLessThanOrEqual(completeBudgetMs)
expect(Math.max(...samples.map(row => row.retainedHeapBytes))).toBeLessThanOrEqual(MAX_RETAINED_HEAP_BYTES)
})
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index 0b096186d7..8206f3e6dc 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.i18n.yaml
@@ -21,8 +21,8 @@
en: 20ef882e44b06a10
zh: c10c3934ed5570e9
/deepseek-harness-architecture/events:
- en: 214c3c81c21df6f9
- zh: 75d2cdc3c9fc9930
+ en: e89802f066e4955d
+ zh: ea2fb4bef94e086d
/deepseek-harness-architecture/turn-flow:
en: e6402b28c0ed15fa
zh: 6c07be9047b42056
@@ -30,8 +30,8 @@
en: a2e35c43438fec6d
zh: e27bdac8e9feff32
/deepseek-harness-architecture/capability-seams:
- en: 93d1dc2eeda4aec2
- zh: ee7a9da89151ea17
+ en: a5bc20c3f2a1b370
+ zh: 91773a281410b2f4
/deepseek-harness-architecture/where-new-behavior-goes:
en: 9b2eb0539a9cd0d6
zh: 4f575277b91d4e5c
diff --git a/docs/architecture.md b/docs/architecture.md
index 51dcfa7e0f..03d88148c7 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -72,7 +72,7 @@ Events are the extension points, and picking the right domain is the first decis
- **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
- **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
-AgentLoop awaits serial `agent/created` initialization before starting queued work. Initialization failure rolls back creation; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines teardown ordering.
+AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering.
The [event map](event-producer-consumer.md) lists every event's producers and consumers.
@@ -133,7 +133,7 @@ A **seam** is a swappable capability with three roles: a **Service Definition**
Seams are why one provider swap changes the whole product. Filesystem and subprocess providers share one execution world, so pointing them at a remote sandbox moves Bash, PTY, and LSP with them, with no provider forks. [Subagent providers](subsystems/subagent.md) vary just as widely behind one interface, from a fresh child agent to a delegated turn in another product.
-[Experimental Agent Teams](subsystems/agent-team.md) is a published opt-in coordination seam on `ctx.agentTeams`, with a durable roster, task board, and mailbox layered over continuable subagents.
+[Experimental Agent Teams](subsystems/agent-team.md) exposes opt-in `ctx.agentTeams` coordination with durable roster and task state plus direct inbox messaging over continuable subagents.
## Where new behavior goes
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index 8836217bd1..870fa1c996 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -74,7 +74,7 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
- **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
- **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
-AgentLoop 在启动已排队工作前等待串行 `agent/created` 初始化。初始化失败会回滚创建;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义 teardown 顺序。
+AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。
[事件映射](event-producer-consumer.zh.md)列出每个事件的生产方与消费方。
@@ -137,7 +137,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list`
seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。[subagent 提供方](subsystems/subagent.zh.md)在同一个接口之后同样千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。
-[实验性 Agent Teams](subsystems/agent-team.zh.md) 是 `ctx.agentTeams` 上公开发布、显式启用的协作 seam,在可继续 subagent 之上提供持久 roster、任务板和 mailbox。
+[实验性 Agent Teams](subsystems/agent-team.zh.md) 通过显式启用的 `ctx.agentTeams`,在可继续 subagent 之上提供持久 roster、任务状态和直接 inbox 消息协作。
## 新行为的归属位置
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index 1c39cb4a31..182bb9349e 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.i18n.yaml
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
- en: 5bdd69045ebd8e3c
- zh: f791f38cf578f9c9
+ en: 912784044de3337d
+ zh: 6a03f65751cda74a
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index f240b94937..7619be90f4 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -674,7 +674,7 @@ flowchart LR
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. |
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. |
-| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. |
+| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, direct peer inbox delivery, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. |
| `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote. |
| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh and PTY sends) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index 17b488deeb..b02ec2e41c 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -676,7 +676,7 @@ flowchart LR
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 提供方准备本地子级或执行外部任务;一个 activation 管理器持有两类生命周期。委派工具选择后端,控制工具投递本地后续消息,工作流等待 activation 结果。 |
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 |
-| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 |
+| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、直接 peer inbox 投递、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 |
| `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | 加载 defineMod 插件添加的模组,从 harness 扩展点触发其钩子链,并通过其 Remote 绘制提示框上方的横幅。 |
| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh 与 PTY 发送)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
diff --git a/docs/cli-help.md b/docs/cli-help.md
index 4539636c4a..9796709587 100644
--- a/docs/cli-help.md
+++ b/docs/cli-help.md
@@ -173,7 +173,9 @@ Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
- --host bind host
+ --host bind address: one concrete IPv4 or IPv6 literal
+ of a local interface; wildcard addresses are
+ rejected
--no-open do not open the Web UI in the default browser
--port listen port; pass 0 to let the OS pick a free
one
@@ -182,13 +184,20 @@ Options:
grants no trust
--trusted-host extra authority the /api browser-trust fence
accepts (host or host:port; repeatable)
+ --tls-cert PEM server certificate chain; requires
+ --tls-key
+ --tls-key unencrypted PEM private key; requires
+ --tls-cert
-h, --help show this help
Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
+ dsh --profile web --host 10.0.0.7 bind one local interface address
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
+ dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
+ serve HTTPS with an existing certificate and key
```
diff --git a/docs/cli-help.zh.md b/docs/cli-help.zh.md
index f875c988e1..54b10f9d16 100644
--- a/docs/cli-help.zh.md
+++ b/docs/cli-help.zh.md
@@ -175,7 +175,9 @@ Usage: dsh --profile web [options]
Serve the DeepSeek Harness browser UI.
Options:
- --host bind host
+ --host bind address: one concrete IPv4 or IPv6 literal
+ of a local interface; wildcard addresses are
+ rejected
--no-open do not open the Web UI in the default browser
--port listen port; pass 0 to let the OS pick a free
one
@@ -184,13 +186,20 @@ Options:
grants no trust
--trusted-host extra authority the /api browser-trust fence
accepts (host or host:port; repeatable)
+ --tls-cert PEM server certificate chain; requires
+ --tls-key
+ --tls-key unencrypted PEM private key; requires
+ --tls-cert
-h, --help show this help
Examples:
dsh --profile web serve on the composed host and port
dsh --profile web --no-open serve without opening a browser
dsh --profile web --port 8080 serve on another port
+ dsh --profile web --host 10.0.0.7 bind one local interface address
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
advertise a prefix-stripping HTTPS proxy entry and admit its authority
+ dsh --profile web --tls-cert ./server-chain.pem --tls-key ./server-key.pem
+ serve HTTPS with an existing certificate and key
```
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 3de445a55b..17856848d5 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -92,7 +92,7 @@ export interface Config {
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`)
-- `source`: [`packages/core/agent-loop/src/index.ts:292`](../packages/core/agent-loop/src/index.ts)
+- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts)
```ts config-catalog
/** Agent-loop plugin configuration. */
@@ -139,7 +139,7 @@ export type Config = PresetDefinition
- `inject`: `loader` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:15`](../packages/preset/agent-preset-registry/src/preset.ts)
+- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts)
```ts config-catalog
/** Registry selection policy. */
@@ -220,7 +220,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
-- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts)
+- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -444,11 +444,9 @@ export interface ConnectionConfig {
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
- * port-less `host` matching any port. The /api trust fence refuses any
- * request whose Host is neither loopback nor listed here, so a
- * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
- * by; the Web runtime derives LAN IP literals from an active all-interface
- * bind. An entry that is not a bare, canonical authority fails plugin load.
+ * port-less `host` matching any port. The fence accepts loopback and the
+ * listener's own bind IP independently; other remote authorities require
+ * an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
@@ -757,7 +755,7 @@ export interface ToolResultPruneConfig {
## `@deepseek-ai/dsh-cordis-host-runner`
- `inject`: `tools`
-- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:93`](../packages/extensions/cordis-host-runner/src/index.ts)
+- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:103`](../packages/extensions/cordis-host-runner/src/index.ts)
```ts config-catalog
/** Runner configuration. */
@@ -839,7 +837,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-agent-team`
- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents`
-- `source`: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts)
+- `source`: [`packages/experimental/agent-team/src/types.ts:153`](../packages/experimental/agent-team/src/types.ts)
```ts config-catalog
/** Team-service deployment limits. */
@@ -848,11 +846,9 @@ export interface Config {
readonly maxMembers?: number
/** Maximum non-deleted tasks retained by one Team. */
readonly maxTasks?: number
- /** Maximum queued-minus-delivered messages for one target member. */
- readonly maxPendingMessagesPerMember?: number
/** Maximum UTF-8 bytes in one complete sender-framed delivery. */
readonly maxMessageBytes?: number
- /** Maximum milliseconds allowed for Team-owned runtime disposal. */
+ /** Maximum milliseconds for shared operation settlement and for each Team child drain. */
readonly disposalTimeoutMs?: number
}
```
@@ -1222,6 +1218,8 @@ export interface Config {
minSpeechSeconds: number
/** Silence separating two speech segments. */
minSilenceSeconds: number
+ /** Recording audio restored ahead of the first VAD segment, which cold start reports late; zero disables it. */
+ vadOnsetPaddingSeconds: number
/** Maximum decoded WAV bytes accepted by the private worker. */
maxAudioBytes: number
/** Deadline for runtime preparation and cold model loading. */
@@ -1628,15 +1626,32 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
-- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
+- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
```ts config-catalog
-/** Web server listen and response-compression config. */
+/** Web server listen, TLS, and response-compression config. */
export interface Config {
- /** Listen host; the two supported values are loopback and all-interfaces. */
- host: '127.0.0.1' | '0.0.0.0'
+ /**
+ * Listen address: a concrete IPv4 or IPv6 literal of one local interface,
+ * for example the container's own Pod address from `hostname -i`. A loopback
+ * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
+ * server on this machine; any other literal serves the network that address
+ * belongs to, over plain HTTP unless `tls` is set. The unspecified address —
+ * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
+ * load: it would expose the port on every interface at once.
+ */
+ host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
+ /**
+ * Serve HTTPS with this certificate and key instead of plain HTTP. Both files
+ * are read once, before the listener binds: an unreadable or empty file,
+ * invalid PEM, or a key that does not match the certificate rejects
+ * initialization rather than falling back to HTTP. The material is never
+ * re-read, so replacing a certificate takes a reload. Omitted or null listens
+ * over plain HTTP.
+ */
+ tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -1644,6 +1659,18 @@ export interface Config {
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
compressionThresholdBytes?: number
}
+
+/**
+ * TLS material for the HTTPS listener: one certificate chain file and the
+ * private key file it pairs with. Both hold PEM text and both resolve against
+ * the process working directory.
+ */
+export interface TlsConfig {
+ /** Certificate chain file, leaf certificate first, PEM, no passphrase. */
+ certFile: string
+ /** Private key file for the chain's leaf certificate; unencrypted PEM. */
+ keyFile: string
+}
```
@@ -2362,7 +2389,7 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
-- `source`: [`packages/boot/plugin-manager/src/index.ts:44`](../packages/boot/plugin-manager/src/index.ts)
+- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2414,11 +2441,13 @@ export interface Config {
## `@deepseek-ai/dsh-ptc-runtime-node`
- `inject`: `fs` · `subprocess` · `sandbox` · `sandboxPolicy`
-- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
+- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:27`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
```ts config-catalog
/** Deployment-varying runtime bounds and launch choices. */
-export interface Config extends LaunchConfig {
+export interface Config {
+ /** Worker invocation in the subprocess world; omitted selects the local carrier. */
+ launch?: LaunchConfig
/** Default elapsed deadline, including nested tool and approval waits. */
timeoutMs?: number
/** Maximum numeric elapsed budget accepted by resolve. */
@@ -2435,12 +2464,19 @@ export interface Config extends LaunchConfig {
graceMs?: number
}
-/** Deployment-owned Node executable and optional preinstalled built bootstrap. */
-export interface LaunchConfig {
- /** Executable in the subprocess world; defaults to the current Node executable. */
- nodeExecutable?: string
- /** Absolute preinstalled built bootstrap in the execution world. */
+/** Deployment-owned worker invocation in the subprocess execution world. */
+export type LaunchConfig = {
+ /** Start a separately installed Node executable and JavaScript bootstrap. */
+ kind: 'node-script'
+ /** Executable name or path resolved by the subprocess provider. */
+ executable: string
+ /** Absolute preinstalled bootstrap; omitted maps this package's bootstrap into the execution world. */
bootstrapPath?: string
+} | {
+ /** Start the private PTC worker embedded in a packaged executable. */
+ kind: 'embedded'
+ /** Packaged executable name or path resolved by the subprocess provider. */
+ executable: string
}
```
@@ -2909,11 +2945,16 @@ export interface Config {
- `inject`: `sessionTitle` · `llm` · `sessions`
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
-- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:15`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
+- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:28`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
```ts config-catalog
-/** Required LLM policy; this plugin adds no defaults. */
-export type Config = SessionTitleLlmConfig
+/** Required execution controls plus this provider's title-length targets; no defaults. */
+export interface Config extends SessionTitleLlmConfig {
+ /** Target word count for non-CJK titles. */
+ readonly targetWords: number
+ /** Target character count for Chinese, Japanese, or Korean titles. */
+ readonly targetCjkCharacters: number
+}
```
@@ -2924,11 +2965,16 @@ export type Config = SessionTitleLlmConfig
- `inject`: `sessionTitle` · `llm` · `sessions`
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
-- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts)
+- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:28`](../packages/session/session-title-first-prompt-llm/src/index.ts)
```ts config-catalog
-/** Required LLM policy; this plugin adds no defaults. */
-export type Config = SessionTitleLlmConfig
+/** Required execution controls plus this provider's title-length targets; no defaults. */
+export interface Config extends SessionTitleLlmConfig {
+ /** Target word count for non-CJK titles. */
+ readonly targetWords: number
+ /** Target character count for Chinese, Japanese, or Korean titles. */
+ readonly targetCjkCharacters: number
+}
```
@@ -3077,25 +3123,21 @@ export interface Config {
## `@deepseek-ai/dsh-ssh`
-- `source`: [`packages/ssh/ssh/src/index.ts:17`](../packages/ssh/ssh/src/index.ts)
+- `source`: [`packages/ssh/ssh/src/index.ts:33`](../packages/ssh/ssh/src/index.ts)
```ts config-catalog
/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
export interface Config {
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
host: string
- /** Absolute remote Node executable. */
- node: string
+ /** Explicit script or self-contained executable invocation. */
+ launch: HelperLaunch
/** Absolute path to the installed, bundled helper entry. */
helper: string
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
helperHash: string
/** Absolute remote default workspace. */
workspace: string
- /** Optional preinstalled built PTC entry, paired with its expected digest. */
- bootstrapPath?: string
- /** SHA-256 of bootstrapPath; both fields must be supplied together. */
- bootstrapHash?: string
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
requestTimeoutMs?: number
/** Maximum JSON payload bytes per helper request or response. */
@@ -3105,6 +3147,21 @@ export interface Config {
/** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
leaseMs?: number
}
+
+/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
+export type HelperLaunch = {
+ /** Run the installed script with a separately installed Node executable. */
+ kind: 'node-script'
+ /** Absolute remote Node executable. */
+ node: string
+ /** Absolute remote PTC bootstrap; requires bootstrapHash. */
+ bootstrapPath?: string
+ /** Lowercase SHA-256 of bootstrapPath; requires that path. */
+ bootstrapHash?: string
+} | {
+ /** Run the helper executable with its embedded Node and PTC worker. */
+ kind: 'executable'
+}
```
@@ -4207,7 +4264,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
-- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
+- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4226,13 +4283,11 @@ export interface Config {
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
- * `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
- * see [public deployments](../README.md#public-deployments). Absent or YAML
- * `null` advertises the loopback URL.
+ * `https://app.example/ui/`, normalized to end in `/`. Configures no routing
+ * or authentication; see [public deployments](../README.md#public-deployments).
+ * Absent or YAML `null` advertises the bind-address URL.
*/
publicUrl?: string
- /** Explicit `--trusted-host` authorities from this invocation. */
- trustedHosts: string[]
}
```
@@ -4517,7 +4572,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
| `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) |
| `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) |
-| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
+| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) |
| `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) |
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
@@ -4631,6 +4686,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-session-snapshot` | — | [`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts) |
| `@deepseek-ai/dsh-session-telemetry` | — | [`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts) |
| `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) |
+| `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) |
| `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) |
| `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) |
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 6682104549..c4ab85cdda 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -94,7 +94,7 @@ export interface Config {
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`)
-- `source`: [`packages/core/agent-loop/src/index.ts:292`](../packages/core/agent-loop/src/index.ts)
+- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts)
```ts config-catalog
/** Agent-loop plugin configuration. */
@@ -141,7 +141,7 @@ export type Config = PresetDefinition
- `inject`: `loader` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
-- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:15`](../packages/preset/agent-preset-registry/src/preset.ts)
+- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts)
```ts config-catalog
/** Registry selection policy. */
@@ -222,7 +222,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
-- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts)
+- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -446,11 +446,9 @@ export interface ConnectionConfig {
recovery?: ConnectionRecoveryConfig
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
- * port-less `host` matching any port. The /api trust fence refuses any
- * request whose Host is neither loopback nor listed here, so a
- * non-loopback (`0.0.0.0`) deployment must declare the names it is reached
- * by; the Web runtime derives LAN IP literals from an active all-interface
- * bind. An entry that is not a bare, canonical authority fails plugin load.
+ * port-less `host` matching any port. The fence accepts loopback and the
+ * listener's own bind IP independently; other remote authorities require
+ * an entry here. A non-canonical or non-bare authority fails plugin load.
*/
trustedHosts?: string[]
/** Absolute browser-session lifetime in days. Default: 30. */
@@ -759,7 +757,7 @@ export interface ToolResultPruneConfig {
## `@deepseek-ai/dsh-cordis-host-runner`
- `inject`: `tools`
-- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:93`](../packages/extensions/cordis-host-runner/src/index.ts)
+- `source`: [`packages/extensions/cordis-host-runner/src/index.ts:103`](../packages/extensions/cordis-host-runner/src/index.ts)
```ts config-catalog
/** Runner configuration. */
@@ -841,7 +839,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-agent-team`
- `inject`: `agents` · `sessions` · `sessionPersistence` · `sessionProjections` · `subagents`
-- `source`: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts)
+- `source`: [`packages/experimental/agent-team/src/types.ts:153`](../packages/experimental/agent-team/src/types.ts)
```ts config-catalog
/** Team-service deployment limits. */
@@ -850,11 +848,9 @@ export interface Config {
readonly maxMembers?: number
/** Maximum non-deleted tasks retained by one Team. */
readonly maxTasks?: number
- /** Maximum queued-minus-delivered messages for one target member. */
- readonly maxPendingMessagesPerMember?: number
/** Maximum UTF-8 bytes in one complete sender-framed delivery. */
readonly maxMessageBytes?: number
- /** Maximum milliseconds allowed for Team-owned runtime disposal. */
+ /** Maximum milliseconds for shared operation settlement and for each Team child drain. */
readonly disposalTimeoutMs?: number
}
```
@@ -1224,6 +1220,8 @@ export interface Config {
minSpeechSeconds: number
/** Silence separating two speech segments. */
minSilenceSeconds: number
+ /** Recording audio restored ahead of the first VAD segment, which cold start reports late; zero disables it. */
+ vadOnsetPaddingSeconds: number
/** Maximum decoded WAV bytes accepted by the private worker. */
maxAudioBytes: number
/** Deadline for runtime preparation and cold model loading. */
@@ -1630,15 +1628,32 @@ export interface Config {
## `@deepseek-ai/dsh-host-webserver`
-- `source`: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
+- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts)
```ts config-catalog
-/** Web server listen and response-compression config. */
+/** Web server listen, TLS, and response-compression config. */
export interface Config {
- /** Listen host; the two supported values are loopback and all-interfaces. */
- host: '127.0.0.1' | '0.0.0.0'
+ /**
+ * Listen address: a concrete IPv4 or IPv6 literal of one local interface,
+ * for example the container's own Pod address from `hostname -i`. A loopback
+ * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
+ * server on this machine; any other literal serves the network that address
+ * belongs to, over plain HTTP unless `tls` is set. The unspecified address —
+ * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
+ * load: it would expose the port on every interface at once.
+ */
+ host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
+ /**
+ * Serve HTTPS with this certificate and key instead of plain HTTP. Both files
+ * are read once, before the listener binds: an unreadable or empty file,
+ * invalid PEM, or a key that does not match the certificate rejects
+ * initialization rather than falling back to HTTP. The material is never
+ * re-read, so replacing a certificate takes a reload. Omitted or null listens
+ * over plain HTTP.
+ */
+ tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -1646,6 +1661,18 @@ export interface Config {
/** Minimum known response length eligible for gzip; unknown-length streams are eligible. @default 1024 */
compressionThresholdBytes?: number
}
+
+/**
+ * TLS material for the HTTPS listener: one certificate chain file and the
+ * private key file it pairs with. Both hold PEM text and both resolve against
+ * the process working directory.
+ */
+export interface TlsConfig {
+ /** Certificate chain file, leaf certificate first, PEM, no passphrase. */
+ certFile: string
+ /** Private key file for the chain's leaf certificate; unencrypted PEM. */
+ keyFile: string
+}
```
@@ -2364,7 +2391,7 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
-- `source`: [`packages/boot/plugin-manager/src/index.ts:44`](../packages/boot/plugin-manager/src/index.ts)
+- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2416,11 +2443,13 @@ export interface Config {
## `@deepseek-ai/dsh-ptc-runtime-node`
- `inject`: `fs` · `subprocess` · `sandbox` · `sandboxPolicy`
-- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:26`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
+- `source`: [`packages/ptc-runtime/ptc-runtime-node/src/index.ts:27`](../packages/ptc-runtime/ptc-runtime-node/src/index.ts)
```ts config-catalog
/** Deployment-varying runtime bounds and launch choices. */
-export interface Config extends LaunchConfig {
+export interface Config {
+ /** Worker invocation in the subprocess world; omitted selects the local carrier. */
+ launch?: LaunchConfig
/** Default elapsed deadline, including nested tool and approval waits. */
timeoutMs?: number
/** Maximum numeric elapsed budget accepted by resolve. */
@@ -2437,12 +2466,19 @@ export interface Config extends LaunchConfig {
graceMs?: number
}
-/** Deployment-owned Node executable and optional preinstalled built bootstrap. */
-export interface LaunchConfig {
- /** Executable in the subprocess world; defaults to the current Node executable. */
- nodeExecutable?: string
- /** Absolute preinstalled built bootstrap in the execution world. */
+/** Deployment-owned worker invocation in the subprocess execution world. */
+export type LaunchConfig = {
+ /** Start a separately installed Node executable and JavaScript bootstrap. */
+ kind: 'node-script'
+ /** Executable name or path resolved by the subprocess provider. */
+ executable: string
+ /** Absolute preinstalled bootstrap; omitted maps this package's bootstrap into the execution world. */
bootstrapPath?: string
+} | {
+ /** Start the private PTC worker embedded in a packaged executable. */
+ kind: 'embedded'
+ /** Packaged executable name or path resolved by the subprocess provider. */
+ executable: string
}
```
@@ -2911,11 +2947,16 @@ export interface Config {
- `inject`: `sessionTitle` · `llm` · `sessions`
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
-- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:15`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
+- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:28`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
```ts config-catalog
-/** Required LLM policy; this plugin adds no defaults. */
-export type Config = SessionTitleLlmConfig
+/** Required execution controls plus this provider's title-length targets; no defaults. */
+export interface Config extends SessionTitleLlmConfig {
+ /** Target word count for non-CJK titles. */
+ readonly targetWords: number
+ /** Target character count for Chinese, Japanese, or Korean titles. */
+ readonly targetCjkCharacters: number
+}
```
@@ -2926,11 +2967,16 @@ export type Config = SessionTitleLlmConfig
- `inject`: `sessionTitle` · `llm` · `sessions`
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
-- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts)
+- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:28`](../packages/session/session-title-first-prompt-llm/src/index.ts)
```ts config-catalog
-/** Required LLM policy; this plugin adds no defaults. */
-export type Config = SessionTitleLlmConfig
+/** Required execution controls plus this provider's title-length targets; no defaults. */
+export interface Config extends SessionTitleLlmConfig {
+ /** Target word count for non-CJK titles. */
+ readonly targetWords: number
+ /** Target character count for Chinese, Japanese, or Korean titles. */
+ readonly targetCjkCharacters: number
+}
```
@@ -3079,25 +3125,21 @@ export interface Config {
## `@deepseek-ai/dsh-ssh`
-- `source`: [`packages/ssh/ssh/src/index.ts:17`](../packages/ssh/ssh/src/index.ts)
+- `source`: [`packages/ssh/ssh/src/index.ts:33`](../packages/ssh/ssh/src/index.ts)
```ts config-catalog
/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
export interface Config {
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
host: string
- /** Absolute remote Node executable. */
- node: string
+ /** Explicit script or self-contained executable invocation. */
+ launch: HelperLaunch
/** Absolute path to the installed, bundled helper entry. */
helper: string
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
helperHash: string
/** Absolute remote default workspace. */
workspace: string
- /** Optional preinstalled built PTC entry, paired with its expected digest. */
- bootstrapPath?: string
- /** SHA-256 of bootstrapPath; both fields must be supplied together. */
- bootstrapHash?: string
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
requestTimeoutMs?: number
/** Maximum JSON payload bytes per helper request or response. */
@@ -3107,6 +3149,21 @@ export interface Config {
/** Remote helper lease; loss of heartbeats starts remote managed cleanup. */
leaseMs?: number
}
+
+/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
+export type HelperLaunch = {
+ /** Run the installed script with a separately installed Node executable. */
+ kind: 'node-script'
+ /** Absolute remote Node executable. */
+ node: string
+ /** Absolute remote PTC bootstrap; requires bootstrapHash. */
+ bootstrapPath?: string
+ /** Lowercase SHA-256 of bootstrapPath; requires that path. */
+ bootstrapHash?: string
+} | {
+ /** Run the helper executable with its embedded Node and PTC worker. */
+ kind: 'executable'
+}
```
@@ -4209,7 +4266,7 @@ export interface WebRuntimeConfig {
## `@deepseek-ai/dsh-web-app`
- `inject`: `webServer`
-- `source`: [`packages/bundle/web-app/src/index.ts:46`](../packages/bundle/web-app/src/index.ts)
+- `source`: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */
@@ -4228,13 +4285,11 @@ export interface Config {
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
- * `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
- * see [public deployments](../README.md#public-deployments). Absent or YAML
- * `null` advertises the loopback URL.
+ * `https://app.example/ui/`, normalized to end in `/`. Configures no routing
+ * or authentication; see [public deployments](../README.md#public-deployments).
+ * Absent or YAML `null` advertises the bind-address URL.
*/
publicUrl?: string
- /** Explicit `--trusted-host` authorities from this invocation. */
- trustedHosts: string[]
}
```
@@ -4519,7 +4574,7 @@ export interface Config {
| `@deepseek-ai/dsh-fs-observation-policy` | — | [`packages/fs/fs-observation-policy/src/index.ts`](../packages/fs/fs-observation-policy/src/index.ts) |
| `@deepseek-ai/dsh-fs-ssh` | `ssh` · `sandboxPolicy` | [`packages/ssh/fs-ssh/src/index.ts`](../packages/ssh/fs-ssh/src/index.ts) |
| `@deepseek-ai/dsh-goal-round-driver` | `agents` · `goals` · `sessions` | [`packages/goal/goal-round-driver/src/index.ts`](../packages/goal/goal-round-driver/src/index.ts) |
-| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
+| `@deepseek-ai/dsh-host-directory-picker-auto` | `webServer` · `connection` · `loader` | [`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts) |
| `@deepseek-ai/dsh-host-directory-picker-native` | — | [`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts) |
| `@deepseek-ai/dsh-host-plugin-inventory` | `loader` | [`packages/host/plugin-inventory/src/index.ts`](../packages/host/plugin-inventory/src/index.ts) |
| `@deepseek-ai/dsh-llm` | — | [`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts) |
@@ -4633,6 +4688,7 @@ export interface Config {
| `@deepseek-ai/dsh-session-snapshot` | — | [`packages/test-support/session-snapshot/src/index.ts`](../packages/test-support/session-snapshot/src/index.ts) |
| `@deepseek-ai/dsh-session-telemetry` | — | [`packages/session/session-telemetry/src/index.ts`](../packages/session/session-telemetry/src/index.ts) |
| `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) |
+| `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) |
| `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) |
| `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) |
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
diff --git a/docs/cookbook/adding-a-session-format-version.i18n.yaml b/docs/cookbook/adding-a-session-format-version.i18n.yaml
index e37df25963..56f272ecf8 100644
--- a/docs/cookbook/adding-a-session-format-version.i18n.yaml
+++ b/docs/cookbook/adding-a-session-format-version.i18n.yaml
@@ -6,14 +6,14 @@
en: 71132d61e41cb40b
zh: 0b56ed8893bf8d0a
/cookbook-adding-a-session-log-format-version/summary:
- en: 76b76c19e0889ab3
- zh: 1ddc1afa5c2333f0
+ en: 3dc6121d1fcef4f8
+ zh: 178bcf02b63c4fbf
/cookbook-adding-a-session-log-format-version/table-of-contents:
en: 6977fff29f49e4a4
zh: 7ffb6f5490831dce
/cookbook-adding-a-session-log-format-version/1-choose-the-version-and-release-base:
- en: 3205409b918b891d
- zh: 98f17c19045954d5
+ en: 3e763cfbe8cd11d5
+ zh: 2d4998217cbe8030
/cookbook-adding-a-session-log-format-version/2-add-an-identity-edge:
en: 77c9c26d94fb33ca
zh: 313c54c45cfc7dbc
diff --git a/docs/cookbook/adding-a-session-format-version.md b/docs/cookbook/adding-a-session-format-version.md
index 7c6d08b993..8d960af752 100644
--- a/docs/cookbook/adding-a-session-format-version.md
+++ b/docs/cookbook/adding-a-session-format-version.md
@@ -4,7 +4,7 @@ English | [中文](adding-a-session-format-version.zh.md)
## Summary
-Use this tutorial to introduce the next structural Session log version without rewriting released data. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md).
+Use this tutorial to introduce the next Session log format version without rewriting released data. Read the [version and release-status authority](../session-format-status.md) to identify the checkout writer and the latest released format. Let N denote that verified released format and N+1 the target; substitute numeric values for these placeholders in names and metadata. Start with a working contributor checkout and read the [package checklist](adding-a-package.md), [format library](../../packages/session/session-format/README.md), and [released-format decision](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md).
## Table of Contents
@@ -20,15 +20,15 @@ Use this tutorial to introduce the next structural Session log version without r
## 1. Choose the version and release base
-Bump the format for a structural change to headers, event envelopes, core event semantics, or surface reconstruction. Backward-compatible changes can remain at the current version through new acknowledgements; follow the [versioning rule](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md) and [type-change rules](../persistence-changes/README.md#compatibility-rules). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions.
+Bump the format only when an effective discriminator cannot prevent unsafe interpretation. A structural change to headers, event envelopes, event semantics, or reconstruction requires assessment under the [reader-compatibility rule](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md), not an automatic bump. Record safe same-version evolution through the [type-change procedure](reviewing-persistence-type-changes.md). Distinguish the Session format integer from package release versions, SQLite schema versions, projection-unit versions, and protocol-wrapper versions.
-Let N be the accepted or published version in [version/status](../session-format-status.md) when planning the next breaking change. A breaking change after accepted V4 therefore needs V5 even before V4 publication is recorded; compatible additions do not require that step.
+Let N be the accepted or published version in [version/status](../session-format-status.md) when a bump is required. After accepted V4, that bump needs V5 even before V4 publication is recorded; reviewed same-version evolution does not require that step.
Use a shared `release/*` integration base for N+1. The base change adds the writer, codec, catalog wiring, identity migration, and verification. Create each independent child branch from that base and target its PR at the release branch, not another independent child’s branch. Each child adds its structural transformation, validators, consumers, and tests to the same adjacent migration package. Do not allocate extra versions just to represent review order. Merge reviewed children into the release branch through PRs, then validate the combined result before release. Honor release-branch force-push and deletion protections; do not force-sync it.
-Released codecs and accepted target-format meanings remain stable. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships, without a version bump, if its output remains compatible with N+1. A converter fix affects subsequent conversions, not existing N+1 successors. Document changes to previously accepted mappings and how existing outputs remain supported. Changing the established target representation or interpretation incompatibly requires the next version.
+Released codecs preserve accepted interpretations. A current-version codec can admit explicitly discriminated variants while retaining historical readability; do not broaden an older codec to admit a current feature. The N→N+1 converter may receive fixes or support additional historical cases after N+1 ships if its output remains compatible with N+1. A fix affects subsequent conversions, not existing N+1 successors. Document changed mappings and continued support for existing output; apply the reader-compatibility rule to any new target representation.
-Use disposable, isolated Harness homes while N+1 remains open for integration. An interim N+1 file already has the target writer version, so a later edit to N→N+1 will not migrate that file again. Re-run from unchanged historical input in a fresh test home; never repair this by rewriting a committed generation or reusing a real user's home.
+Same-version logs bypass adjacent migrations and can mix historical and newer record variants after resume. Each integer therefore identifies a family of supported representations, not one fixed schema. Owning readers must retain and distinguish those variants; the next outgoing migration must handle them. Do not add same-version migrations or rely on `restoreCurrent` alone for normalization: current-version JSONL reads using transformed validation bypass that hook. Use disposable, isolated Harness homes for interim integration formats; rerun changed adjacent conversions from unchanged historical input rather than rewriting a committed generation or using a real user's home.
Before changing the writer, use the [archive command](../persistence-changes/historical-formats/README.md#maintenance) to preserve its complete persistence schema, then add the bilingual format reference under `docs/persistence-changes/historical-formats/vN.*`. Preserve all earlier records. The format coverage check requires every integer below the new writer to have its own document; the current generated catalog covers only the new writer.
diff --git a/docs/cookbook/adding-a-session-format-version.zh.md b/docs/cookbook/adding-a-session-format-version.zh.md
index bfadeab878..5cc5b3d197 100644
--- a/docs/cookbook/adding-a-session-format-version.zh.md
+++ b/docs/cookbook/adding-a-session-format-version.zh.md
@@ -4,7 +4,7 @@
## 概述
-本教程介绍如何添加下一个结构性 Session 日志版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。
+本教程介绍如何添加下一个 Session 日志格式版本,同时不改写已发布数据。阅读[版本与发布状态真源](../session-format-status.zh.md),确定工作区写入器与最新已发布格式。令 N 表示经核实的已发布格式,N+1 表示目标版本;名称与元数据中的这些占位符须替换为数字。开始前,请准备可用的贡献者工作区,并阅读[包检查清单](adding-a-package.zh.md)、[格式库](../../packages/session/session-format/README.zh.md)和[已发布格式决策](../../.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md)。
## 目录
@@ -20,15 +20,15 @@
## 1. 选择版本与发布基线
-当 header、事件信封、核心事件语义或表面重建发生结构性变更时,提升格式版本。向后兼容的变更可以通过新的确认记录保留当前版本;遵循[版本规则](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)与[类型变更规则](../persistence-changes/README.zh.md#compatibility-rules)。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。
+只有有效判别信息无法阻止不安全解释时,才提升格式版本。头、事件封装、事件语义或重建的结构变更需要按[读取器兼容性规则](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md)评估,不会自动升版本。通过[类型变更流程](reviewing-persistence-type-changes.zh.md)记录安全的同版本演进。区分 Session 格式整数与包发布版本、SQLite schema 版本、投影单元版本及协议包装层版本。
-规划下一次破坏性变更时,N 取[版本/状态](../session-format-status.zh.md)中的已接受或已发布版本。因此,即使尚未记录 V4 发布,已接受 V4 之后的破坏性变更也需要 V5;兼容新增不需要这一步。
+确定需要升版本时,N 取[版本/状态](../session-format-status.zh.md)中的已接受或已发布版本。已接受 V4 之后,即使尚未记录 V4 发布,此次升版本也需要 V5;经评审的同版本演进不需要这一步。
为 N+1 使用共享的 `release/*` 集成基线。基线变更添加写入器、codec、catalog 接线、恒等迁移与验证。从该基线创建各个独立子分支,并将其 PR(Pull Request)的目标设为发布分支,而非另一个独立子分支。每个子分支在同一个相邻迁移包内添加自身的结构变换、校验器、消费方和测试。不要只为表示评审顺序而分配额外版本。通过 PR 将评审后的子分支合入发布分支,并在发布前验证组合结果。遵守发布分支的强制推送与删除保护;不要强制同步该分支。
-已发布 codec 和已接受的目标格式含义保持稳定。N+1 发布后,N→N+1 转换器仍可修复或增加历史场景支持;只要输出仍兼容 N+1,就不必提高版本。转换器修复只影响之后的转换,不会重新处理已有 N+1 后继文件。记录对原先已接纳映射的修改,以及已有输出如何继续得到支持。不兼容地改变既定目标表示或解释语义需要下一版本。
+已发布 codec 保留已接受的解释。当前版本 codec 可以接纳有明确判别信息的分支,同时保留历史可读性;不要为了接纳当前功能而放宽旧 codec。N+1 发布后,只要输出仍兼容 N+1,N→N+1 转换器就可以修复或增加历史场景支持。修复只影响之后的转换,不影响已有 N+1 后继文件。记录映射变化及已有输出如何继续得到支持;任何新的目标表示都须应用读取器兼容性规则。
-N+1 尚处于开放集成阶段时,应使用可丢弃、相互隔离的 Harness home。中间版本产生的 N+1 文件已标为目标写入器版本,因此后续对 N→N+1 的修改不会再次迁移该文件。请在全新测试 home 中从未变更的历史输入重新运行;绝不通过改写已提交代际或复用真实用户 home 来修复这个问题。
+同版本日志跳过相邻迁移,恢复后可以混合历史与较新的记录分支。因此,每个整数标识一组受支持表示,而非一个固定 schema。所属读取器必须保留并区分这些分支;下一条向外迁移必须处理它们。不要添加同版本迁移,也不要仅依赖 `restoreCurrent` 进行归一化:使用 transformed 校验的当前版本 JSONL 读取会跳过该 hook。中间集成格式使用可丢弃、相互隔离的 Harness home;相邻转换发生变化时,从未变更的历史输入重新运行,不得改写已提交代际或使用真实用户 home。
变更写入器之前,使用[归档命令](../persistence-changes/historical-formats/README.zh.md#maintenance)保留其完整持久化 schema,再在 `docs/persistence-changes/historical-formats/vN.*` 下添加双语格式参考。保留所有更早的记录。格式覆盖检查要求低于新写入器的每个整数版本都有独立文档;当前生成的目录只覆盖新写入器。
diff --git a/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml b/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml
index efb3b93c60..eba1d60922 100644
--- a/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml
+++ b/docs/cookbook/reviewing-persistence-type-changes.i18n.yaml
@@ -15,17 +15,17 @@
en: 5fca19ac2bb1ddac
zh: 00d78fe7e80ba4f7
/cookbook-reviewing-persistence-type-changes/optional-inspect-the-change:
- en: 3f85edb803afe870
- zh: 545c651b9e5ccb56
+ en: 14049617e3de750d
+ zh: 200456cf72bef77b
/cookbook-reviewing-persistence-type-changes/1-record-the-change:
- en: 342bd49412db070e
- zh: a8d14958e9a637d9
+ en: de16b6e1ac883317
+ zh: 21d367bb78e799d1
/cookbook-reviewing-persistence-type-changes/2-check-commit-and-push:
en: 91d39719ec40b0f7
zh: 405116ae4c91b100
/cookbook-reviewing-persistence-type-changes/update-an-unaccepted-record:
- en: 4fd3f48761f7909f
- zh: 69af35c41656fa17
+ en: e3872db4ec9aad7d
+ zh: 18de4f6ebcb4b31c
/cookbook-reviewing-persistence-type-changes/dev-note:
en: 5de1d6304e00c58f
zh: 8d8225361cfa29e5
diff --git a/docs/cookbook/reviewing-persistence-type-changes.md b/docs/cookbook/reviewing-persistence-type-changes.md
index 15b6c9fab9..56620e5a8b 100644
--- a/docs/cookbook/reviewing-persistence-type-changes.md
+++ b/docs/cookbook/reviewing-persistence-type-changes.md
@@ -31,7 +31,7 @@ pnpm --silent run verify-persistence-changes --json
Use `--silent` when consuming JSON: pnpm otherwise appends lifecycle failure text to stdout. Failed commands still exit with code 1.
-Read the reported root, path, change kind, and version requirement. A referenced type can affect several event digests; inspect every affected root. Until the history covers the new schemas, verification fails. A stale generated inventory also fails verification; the recording command refreshes it. If `changes` is empty after reordering fields or union alternatives, run `pnpm run gen-persistence-catalog` and repeat the check. An unchanged digest needs no new acknowledgement even when copied declarations or source locations produce a catalog diff.
+Read the reported root, path, change kind, and compatibility-review requirement. A referenced type can affect several event digests; inspect every affected root. Until the history covers the new schemas, verification fails. A stale generated inventory also fails verification; the recording command refreshes it. If `changes` is empty after reordering fields or union alternatives, run `pnpm run gen-persistence-catalog` and repeat the check. An unchanged digest needs no new acknowledgement even when copied declarations or source locations produce a catalog diff.
To review a PR independently of its acknowledgement history, save the base and head inventories as local JSON files and run:
@@ -39,6 +39,8 @@ To review a PR independently of its acknowledgement history, save the base and h
pnpm --silent run persistence-review --before .artifacts/base.schema.json --after docs/persistence-schema.json
```
+A recorded decision does not hide the transition from this comparison. `--check` only compares current source with acknowledged history; after recording, it cannot replace a base/head review or establish that a human reviewed the change.
+
Record the commits supplying those files with the report. Add `--json` for structured output. This read-only comparison groups shared changes with their affected roots and uses actual literal `kind`/`form` values instead of union positions. Ambiguous alternatives remain separate additions and removals. Its compatibility section copies every root's authoritative classifier result; the structural explanation does not replace acknowledgement checks. Current catalog labels and declaration names are descriptive metadata; structural anchors and fingerprints identify types.
The current machine inventory stores complete graphs in `roots`. A `types` entry contains `digest`, `names`, and `sources`, plus an explicit `schema` when roots cannot reconstruct that graph exactly. Readers resolve omitted graphs from root subgraphs by digest and validate explicit graphs directly, preserving every type and its metadata. Historical full entries remain readable. `formatVersion` identifies normalization rules; storage compaction does not change root fingerprints or require an acknowledgement.
@@ -46,7 +48,7 @@ The current machine inventory stores complete graphs in `roots`. A `types` entry
## 1. Record the change
-Check the [accepted baseline](../session-format-status.md#finalization-record) first. Preserve its locked records. Record backward-compatible evolution in a new same-version acknowledgement; implement a higher writer version before recording a breaking change.
+Check the [accepted baseline](../session-format-status.md#finalization-record) first and preserve its locked records. Assess both reader directions under the [compatibility rules](../persistence-changes/README.md#compatibility-rules). A structural difference can remain same-version with evidence of safe interpretation or refusal; it does not automatically require a higher writer version.
Write a local JSON file containing `en` and `zh`, each with `summary`, `compatibility`, and `verification` strings. The following input describes an exercised required-to-optional hook audit field change. Replace the explanation and test evidence with facts about your change; the CLI does not establish these claims.
@@ -73,9 +75,9 @@ Use a date and descriptive slug in place of this example id:
pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
-The command validates the history and paired prose, infers the minimum version decision, and checks any required header increase before writing. It generates the record pair, complete after schemas, both catalogs, the machine inventory, and pairing records. Review the explanations and returned `changes`, `roots`, and `files` before committing. Omitting `--prose` creates unfinished drafts that verification rejects until their explanations are completed.
+The command validates the history and paired prose, infers established same-version decisions, and checks explicit compatibility decisions before writing. It generates the record pair, complete after schemas, both catalogs, the machine inventory, and pairing records. Review the explanations and returned `changes`, `roots`, and `files` before committing. Omitting `--prose` creates unfinished drafts that verification rejects until their explanations are completed.
-Inference follows the [fixed compatibility rules](../persistence-changes/README.md#compatibility-rules); it never changes source or relaxes them. If a bump is required, first follow [adding a Session format version](adding-a-session-format-version.md). The record must include its own increasing `SessionHeader.version` transition; an unrelated historical bump cannot authorize it. Routine changes never create another baseline.
+For a change marked `requiresCompatibilityReview`, explain both reader directions in the acknowledgement’s Compatibility section and record executed checks in Verification. Supply `--decision same-version` when that evidence supports keeping the version; recording or updating a flagged change with an unchanged header version and no explicit decision fails with `decision-required`. Structural differences alone do not infer a bump. An actual header version increase can infer `version-bump`. If an effective discriminator cannot prevent unsafe interpretation, follow [adding a Session format version](adding-a-session-format-version.md) and select `--decision version-bump`. That record must include its own increasing `SessionHeader.version` transition; an unrelated historical bump cannot authorize it. Routine changes never create another baseline.
## 2. Check, commit, and push
@@ -99,14 +101,14 @@ If source changes after recording, review the compatibility explanation and refr
pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
-The command refreshes the machine declaration, schemas, catalogs, and pairing. Without `--prose`, it preserves the existing explanation. Update refuses the initial baseline, records that another record depends on, and finalized checkpoint records. Outside finalized checkpoints, the tree does not infer review acceptance: preserve accepted history and create a successor instead.
+The command refreshes the machine declaration, schemas, catalogs, and pairing. Without `--prose`, it preserves the existing explanation. Reassess that explanation against the complete transition and supply `--decision same-version` for any flagged same-version update. Update refuses the initial baseline, records that another record depends on, and finalized checkpoint records. Outside finalized checkpoints, the tree does not infer review acceptance: preserve accepted history and create a successor instead.
When integration creates competing terminal records, update the unaccepted record against the remaining history, then reassess the resulting diff. An unrelated root's acknowledgement does not need refreshing. The [mechanism decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) explains why complete snapshots and per-root predecessors are retained.
-An explicit `--decision` remains a checked assertion. For an existing property's value-type change, the following deliberately wrong assertion fails before writing:
+For an existing property value-type change requiring review, first revise the prose file’s Compatibility and Verification sections to explain the actual new transition and its executed checks. Do not reuse the earlier required-to-optional explanation for a different change. Then update the record with that prose and the explicit same-version choice:
```sh
-pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --json
+pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json
```
diff --git a/docs/cookbook/reviewing-persistence-type-changes.zh.md b/docs/cookbook/reviewing-persistence-type-changes.zh.md
index ed82decae9..51d3f1b6c2 100644
--- a/docs/cookbook/reviewing-persistence-type-changes.zh.md
+++ b/docs/cookbook/reviewing-persistence-type-changes.zh.md
@@ -31,7 +31,7 @@ pnpm --silent run verify-persistence-changes --json
消费 JSON 时使用 `--silent`:否则 pnpm 会把生命周期失败文本追加到标准输出。失败命令仍以退出码 1 结束。
-阅读报告中的根、路径、变更种类和版本要求。被引用类型可能影响多个事件摘要;检查每个受影响的根。在历史覆盖新 schema 之前,验证会失败。陈旧生成清单也会导致验证失败;记录命令会刷新它。若重排字段或联合类型分支后 `changes` 为空,运行 `pnpm run gen-persistence-catalog` 并重新检查。即使复制的声明或源码位置产生目录 diff,未变的摘要也无需新增确认记录。
+阅读报告中的根、路径、变更种类和兼容性评审要求。被引用类型可能影响多个事件摘要;检查每个受影响的根。在历史覆盖新 schema 之前,验证会失败。陈旧生成清单也会导致验证失败;记录命令会刷新它。若重排字段或联合类型分支后 `changes` 为空,运行 `pnpm run gen-persistence-catalog` 并重新检查。即使复制的声明或源码位置产生目录 diff,未变的摘要也无需新增确认记录。
要独立于确认历史评审 PR,先将 base 和 head 的目录保存为本地 JSON 文件,再运行:
@@ -39,6 +39,8 @@ pnpm --silent run verify-persistence-changes --json
pnpm --silent run persistence-review --before .artifacts/base.schema.json --after docs/persistence-schema.json
```
+记录决策不会使转换从此比较中消失。`--check` 仅比较当前源码与已确认历史;记录完成后,它不能替代 base/head 评审,也不能证明人类已评审该变更。
+
在报告旁记录这些文件对应的 commit。添加 `--json` 可获取结构化输出。此只读比较将共享变更与受影响的根类型归组,使用实际字面量 `kind`/`form` 值代替联合类型位置。无法唯一匹配的候选项保留为独立的新增与删除。兼容性部分复制每个根类型的权威分类结果;结构说明不替代确认检查。当前目录标签和声明名称是描述元数据;结构锚点和指纹标识类型。
当前机器清单在 `roots` 中保存完整图。每个 `types` 条目包含 `digest`、`names` 和 `sources`;若根无法精确重建该图,条目还会保存显式 `schema`。读取器按摘要从根的子图恢复省略的图,并直接验证显式图,保留每个类型及其元数据。历史完整条目仍然可读。`formatVersion` 标识规范化规则;存储压缩不改变根指纹,也不需要确认记录。
@@ -46,7 +48,7 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
## 1. 记录变更
-先检查[已接受基线](../session-format-status.zh.md#finalization-record),保留其锁定记录。向后兼容的演进使用新的同版本确认记录;记录破坏性变更之前,先实现更高的写入器版本。
+先检查[已接受基线](../session-format-status.zh.md#finalization-record),保留其锁定记录。按[兼容性规则](../persistence-changes/README.zh.md#compatibility-rules)评估两个读取方向。若有安全解释或拒绝的证据,结构差异可以保留同一版本,不会自动要求更高的写入器版本。
编写包含 `en` 和 `zh` 的本地 JSON 文件,两者分别包含 `summary`、`compatibility` 和 `verification` 字符串。以下输入描述一个经过验证的钩子审计字段从必选改为可选的变更。用你所做变更的事实替换说明和测试证据;CLI(命令行界面)不会证明这些声明。
@@ -73,9 +75,9 @@ pnpm --silent run persistence-review --before .artifacts/base.schema.json --afte
pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
-命令在写入前验证历史和双语说明、推断最低版本决策,并检查所需的头部版本递增。它生成记录对、完整的变更后 schema、两份目录、机器清单和配对记录。提交前审阅说明及返回的 `changes`、`roots` 和 `files`。省略 `--prose` 会创建未完成草稿,验证将拒绝它们,直到说明补齐。
+命令在写入前验证历史与双语说明、推断已明确允许的同版本决策,并检查显式兼容性决策。它生成记录对、完整的 after schema、两个目录、机器清单与配对记录。提交前检查说明及返回的 `changes`、`roots`、`files`。省略 `--prose` 会生成未完成草稿;说明填写完成前,验证会拒绝它们。
-推断遵循[固定兼容性规则](../persistence-changes/README.zh.md#compatibility-rules),不会更改源码或放宽规则。需要升版本时,先遵循[添加会话格式版本](adding-a-session-format-version.zh.md)。记录必须包含其自身的 `SessionHeader.version` 递增转换;无关的历史升版本不能授权它。日常变更不创建另一条基线。
+对于标为 `requiresCompatibilityReview` 的变更,在确认记录的兼容性部分说明两个方向的读取器行为,并在验证部分记录已执行的检查。证据支持保持版本时,提供 `--decision same-version`;记录或更新被标记的变更时,若头版本未变且缺少显式决策,会以 `decision-required` 失败。仅凭结构差异不会推断升版本。实际头版本递增可以推断出 `version-bump`。若有效判别信息无法阻止不安全解释,遵循[添加会话格式版本](adding-a-session-format-version.zh.md),并选择 `--decision version-bump`。该记录必须包含自身的 `SessionHeader.version` 递增转换;无关的历史升版本不能授权它。日常变更不创建另一条基线。
## 2. 检查、提交并推送
@@ -99,14 +101,14 @@ pnpm run doc-sync
pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --prose .artifacts/persistence-change.prose.json --json
```
-命令刷新机器声明、schema、目录和配对。没有 `--prose` 时,它保留已有说明。更新会拒绝初始基线、其他记录所依赖的记录,以及已被定稿检查点锁定的记录。定稿检查点之外,目录不会推断审阅接受状态:保留已接受历史,并创建后继。
+命令刷新机器声明、schema、目录和配对。没有 `--prose` 时,它保留已有说明。根据完整转换重新评估说明,并为任何被标记的同版本更新提供 `--decision same-version`。更新会拒绝初始基线、其他记录所依赖的记录,以及已被定稿检查点锁定的记录。定稿检查点之外,目录不会推断审阅接受状态:保留已接受历史,并创建后继。
集成产生竞争末端记录时,根据剩余历史更新尚未接受的记录,再重新评估最终差异。无关根的确认无需刷新。[机制决策](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)解释为何保留完整快照和逐根前驱。
-显式 `--decision` 仍是受检查的断言。若已有属性的值类型发生变化,下面这个故意错误的断言会在写入前失败:
+若已有属性的值类型变化需要评审,先修改说明文件中的兼容性与验证部分,解释实际的新转换及已执行检查。不要为不同变更复用先前必选改可选的说明。然后使用该说明与显式同版本选择更新记录:
```sh
-pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --json
+pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json
```
diff --git a/docs/deepseek-llm-api-wire-extensions.i18n.yaml b/docs/deepseek-llm-api-wire-extensions.i18n.yaml
index ca1ec47310..79b48d6dc5 100644
--- a/docs/deepseek-llm-api-wire-extensions.i18n.yaml
+++ b/docs/deepseek-llm-api-wire-extensions.i18n.yaml
@@ -12,8 +12,8 @@
en: 740e92e903ecb594
zh: f934d3c9e5c56bfe
/official-deepseek-llm-api-wire-extensions/body-extension-transaction:
- en: f0b1443c364a431c
- zh: 16d16ddf5c451e8e
+ en: 48001cfd1281a23b
+ zh: d410530ae8aa2823
/official-deepseek-llm-api-wire-extensions/dsh-plugin-packages:
en: f89059050613f3e8
zh: da5cf2a4eeb81b75
diff --git a/docs/deepseek-llm-api-wire-extensions.md b/docs/deepseek-llm-api-wire-extensions.md
index 216725d71d..1a30f7c463 100644
--- a/docs/deepseek-llm-api-wire-extensions.md
+++ b/docs/deepseek-llm-api-wire-extensions.md
@@ -34,9 +34,9 @@ Credential failure happens before anonymous-user-id resolution, so an unauthoriz
The adapter serializes the complete base body, including the exact `messages`, before it asks registered providers to prepare fields. A provider receives that immutable body, the request cancellation signal, and optional `sessionId` and auxiliary-call `purpose`. Returning `undefined` omits that provider's field for the request.
-Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. Preparation or collision failure prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body.
+Prepared JSON values are detached from provider-owned state, merged as top-level siblings of the base fields, and serialized in the same HTTP body. A contributor whose preparation fails is omitted from that request, with only its first failure logged, while the other fields are still sent; a field that collides with a base field prevents the request. If the merged body fails to serialize, the adapter sends the base body without any extension field, skips the acceptance transaction so contributors resend their state on a later request, and logs the omitted field names. A composition without the registry sends the unextended base body.
-After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure fails the model request even though the endpoint returned 2xx. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension.
+After the configured endpoint returns HTTP 2xx, the adapter runs the prepared `accept()` transaction before reading the SSE response body. Transport failures and non-2xx responses do not accept any contribution. An acceptance failure is logged and does not fail the model request; the contributor resends its unaccepted state on a later request. Acceptance records endpoint-level HTTP success; it does not assert that an SSE stream completed or that the endpoint persisted an extension.
## `dsh_plugin_packages`
diff --git a/docs/deepseek-llm-api-wire-extensions.zh.md b/docs/deepseek-llm-api-wire-extensions.zh.md
index 33c8908ae5..dcccecc6a7 100644
--- a/docs/deepseek-llm-api-wire-extensions.zh.md
+++ b/docs/deepseek-llm-api-wire-extensions.zh.md
@@ -34,9 +34,9 @@
适配器先序列化包括确切 `messages` 在内的完整基础正文,再让已注册提供方准备字段。提供方会收到该不可变正文、请求取消信号,以及可选的 `sessionId` 和辅助调用 `purpose`。提供方返回 `undefined` 时,本次请求会省略其字段。
-系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。准备失败或冲突会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。
+系统将已准备的 JSON 值与提供方持有的状态分离,再将其作为基础字段的顶层同级成员合并,并序列化到同一个 HTTP 正文中。某个贡献方准备失败时,系统在该次请求中省略其字段,且只记录首次失败,其余字段照常发送;与基础字段冲突的字段会阻止请求。合并后的正文无法序列化时,适配器发送不含任何扩展字段的基础正文,跳过接受事务,让贡献方在之后的请求中重发自身状态,并记录被省略的字段名。组合未挂载注册表时,适配器发送未经扩展的基础正文。
-已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。即使端点返回 2xx,接受失败仍会使模型请求失败。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。
+已配置端点返回 HTTP 2xx 后,适配器会在读取 SSE 正文之前运行已准备的 `accept()` 事务。传输失败和非 2xx 响应不会接受任何贡献。接受失败会记录告警,但不会使模型请求失败;贡献方会在之后的请求中重发未被接受的状态。接受仅记录端点级 HTTP 成功,不表示 SSE 流已完整结束,也不表示端点已持久化扩展。
## `dsh_plugin_packages`
diff --git a/docs/defensive-patterns.i18n.yaml b/docs/defensive-patterns.i18n.yaml
index d9a68ffae5..61e5aec2f2 100644
--- a/docs/defensive-patterns.i18n.yaml
+++ b/docs/defensive-patterns.i18n.yaml
@@ -18,8 +18,8 @@
en: 5c906be504b88a65
zh: 8f428f59a87bb1a8
/defensive-patterns/contain-callback-exceptions-in-the-dispatcher:
- en: ae1788e76190db2c
- zh: a55ab7473bb0c8dd
+ en: 3275390293995f44
+ zh: 4d143e99a2a7c8cb
/defensive-patterns/never-hand-untrusted-output-the-ambient-environment-or-predictable-paths:
en: 09ad9d6433026468
zh: cc0ba1a52859bb08
diff --git a/docs/defensive-patterns.md b/docs/defensive-patterns.md
index 9db5823546..25ec826913 100644
--- a/docs/defensive-patterns.md
+++ b/docs/defensive-patterns.md
@@ -22,7 +22,7 @@ A teardown that issues kills/aborts but returns before the work stops leaves orp
## Contain callback exceptions in the dispatcher
-A user-supplied listener that throws must not reject the promise it runs inside or starve the listeners after it. Wrap the dispatch loop in try/catch and log; one bad subscriber never breaks core lifecycle.
+Catch and report each listener's exception without rejecting the dispatch promise or starving later listeners. The listener's owner must handle state it mutates under the [error-handling rules](../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop); catching alone does not restore state.
## Never hand untrusted output the ambient environment or predictable paths
diff --git a/docs/defensive-patterns.zh.md b/docs/defensive-patterns.zh.md
index 11b2a34a40..c21a87cb28 100644
--- a/docs/defensive-patterns.zh.md
+++ b/docs/defensive-patterns.zh.md
@@ -24,7 +24,7 @@
## 在分发器中隔离回调异常
-用户提供的监听器如果抛出异常,不得导致它所在的 promise 被 reject,也不得饿死排在它后面的监听器。请用 try/catch 包裹分发循环并记录日志;一个行为不当的订阅者绝不能破坏核心生命周期。
+逐个捕获并报告监听器的异常,不让分发 promise 被 reject,也不让后续监听器失去执行机会。监听器的所有者必须按[错误处理规范](../.agents/skills/dsh-error-handling/SKILL.md#catch-recover-or-stop)处置其修改的状态;仅捕获异常不会恢复状态。
## 绝不将环境变量或可预测路径暴露给不可信输出
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 5adc51ba90..9691933dda 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -22,12 +22,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:56`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -56,9 +56,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
-| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:270`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:277`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:271`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 0133716910..cb8ea84aac 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -10,7 +10,7 @@
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
-| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
+| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
@@ -24,12 +24,12 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:56`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -58,9 +58,9 @@
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
-| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:270`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
-| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:277`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:258`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:264`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
+| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:271`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -82,7 +82,7 @@
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 63a00c948d..12a6608993 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -398,6 +398,7 @@ flowchart TD
pkg_fs_ssh["fs-ssh"]
pkg_sandbox_ssh["sandbox-ssh"]
pkg_ssh["ssh"]
+ pkg_ssh_helper_runtime["ssh-helper-runtime"]
pkg_subprocess_ssh["subprocess-ssh"]
end
subgraph group_storage["packages/storage"]
@@ -458,11 +459,6 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
- pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
- pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
- pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
- pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
- pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_anonymous_user_id --> pkg_brand
pkg_anonymous_user_id --> pkg_home_paths
pkg_lsp --> pkg_brand
@@ -499,6 +495,12 @@ flowchart TD
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
pkg_skill_badge --> pkg_skill
+ pkg_host_directory_picker_auto --> pkg_client_connection
+ pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
+ pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
+ pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
+ pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
+ pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_frontend_static --> pkg_client_connection
pkg_host_frontend_static --> pkg_host_webserver
pkg_persona --> pkg_system_prompt
@@ -1006,6 +1008,7 @@ flowchart TD
pkg_ssh --> pkg_session_projection
pkg_ssh --> pkg_subprocess
pkg_ssh --> pkg_subprocess_local
+ pkg_ssh --> pkg_util_values
pkg_llm_replay --> pkg_compaction
pkg_llm_replay --> pkg_deepseek_llm_api_extensions
pkg_llm_replay --> pkg_llm
@@ -1159,7 +1162,6 @@ flowchart TD
pkg_acp --> pkg_session_persistence
pkg_acp --> pkg_token_meter
pkg_acp --> pkg_user_approval
- pkg_plugin_manager --> pkg_agent_preset_registry
pkg_plugin_manager --> pkg_brand
pkg_plugin_manager --> pkg_hmr
pkg_plugin_manager --> pkg_host_plugin_inventory
@@ -1546,6 +1548,7 @@ flowchart TD
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — |
| [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — |
+| [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — |
| [`storage`](../packages/storage/storage) | `storage` | — |
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
@@ -1563,7 +1566,6 @@ flowchart TD
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
-| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) |
@@ -1585,6 +1587,7 @@ flowchart TD
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
+| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1704,7 +1707,7 @@ flowchart TD
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
-| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
+| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
@@ -1729,7 +1732,7 @@ flowchart TD
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 9b3a1e2036..b9a744b112 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -400,6 +400,7 @@ flowchart TD
pkg_fs_ssh["fs-ssh"]
pkg_sandbox_ssh["sandbox-ssh"]
pkg_ssh["ssh"]
+ pkg_ssh_helper_runtime["ssh-helper-runtime"]
pkg_subprocess_ssh["subprocess-ssh"]
end
subgraph group_storage["packages/storage"]
@@ -460,11 +461,6 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_home_paths
pkg_credentials_local --> pkg_launch_environment
- pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
- pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
- pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
- pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
- pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_anonymous_user_id --> pkg_brand
pkg_anonymous_user_id --> pkg_home_paths
pkg_lsp --> pkg_brand
@@ -501,6 +497,12 @@ flowchart TD
pkg_experimental_webworker_runtime --> pkg_client_modules
pkg_experimental_webworker_runtime --> pkg_host_webserver
pkg_skill_badge --> pkg_skill
+ pkg_host_directory_picker_auto --> pkg_client_connection
+ pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_browse
+ pkg_host_directory_picker_auto --> pkg_client_ui_directory_picker_native
+ pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
+ pkg_host_directory_picker_auto --> pkg_host_directory_picker_native
+ pkg_host_directory_picker_auto --> pkg_host_webserver
pkg_host_frontend_static --> pkg_client_connection
pkg_host_frontend_static --> pkg_host_webserver
pkg_persona --> pkg_system_prompt
@@ -1008,6 +1010,7 @@ flowchart TD
pkg_ssh --> pkg_session_projection
pkg_ssh --> pkg_subprocess
pkg_ssh --> pkg_subprocess_local
+ pkg_ssh --> pkg_util_values
pkg_llm_replay --> pkg_compaction
pkg_llm_replay --> pkg_deepseek_llm_api_extensions
pkg_llm_replay --> pkg_llm
@@ -1161,7 +1164,6 @@ flowchart TD
pkg_acp --> pkg_session_persistence
pkg_acp --> pkg_token_meter
pkg_acp --> pkg_user_approval
- pkg_plugin_manager --> pkg_agent_preset_registry
pkg_plugin_manager --> pkg_brand
pkg_plugin_manager --> pkg_hmr
pkg_plugin_manager --> pkg_host_plugin_inventory
@@ -1548,6 +1550,7 @@ flowchart TD
| [`session-format-v1-to-v2`](../packages/session/session-format-v1-to-v2) | `session` | — |
| [`session-format-v2-to-v3`](../packages/session/session-format-v2-to-v3) | `session` | — |
| [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — |
+| [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — |
| [`storage`](../packages/storage/storage) | `storage` | — |
| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — |
| [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — |
@@ -1565,7 +1568,6 @@ flowchart TD
| [`computer-use`](../packages/computer-use/computer-use) | `computer-use` | [`brand`](../packages/util/brand) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) |
-| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) |
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) |
@@ -1587,6 +1589,7 @@ flowchart TD
| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) |
| [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) |
+| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) |
| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) |
| [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1706,7 +1709,7 @@ flowchart TD
| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) |
| [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
-| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
+| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
@@ -1731,7 +1734,7 @@ flowchart TD
| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
-| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
+| [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml
index 4e1c160841..dab112d89b 100644
--- a/docs/persistence-catalog.i18n.yaml
+++ b/docs/persistence-catalog.i18n.yaml
@@ -177,8 +177,8 @@
en: 6c6a27578dff5828
zh: 9a5add2bc0df2d0a
/session-persistence-event-catalog/events/session/session-title-llm-request-log-only:
- en: 5eb108772a8498c9
- zh: 1fbc45e377084bae
+ en: 50d9e29d3d2bfe52
+ zh: 3ff1d959cf7776b9
/session-persistence-event-catalog/events/session-log-deepseek:
en: 7b827672c80aecdd
zh: 7b827672c80aecdd
@@ -216,17 +216,17 @@
en: a640800a38b3c874
zh: a640800a38b3c874
/session-persistence-event-catalog/events/team/team-member-log-only:
- en: 7f6c8a45dc35091f
- zh: 47060fd1344a5ea3
+ en: a2fe5b8582ef5f46
+ zh: 42bfffcde3f3d886
/session-persistence-event-catalog/events/team/team-message-delivered-log-only:
- en: 951c0d2655ede111
- zh: 05d5c77fc656b918
+ en: 4a716b14e33eab18
+ zh: dcde26e4dc40b62e
/session-persistence-event-catalog/events/team/team-message-queued-log-only:
- en: 02ac91734cd032da
- zh: 91fae1059caa59b4
+ en: 05bf873244099da5
+ zh: 8a0b62912087f4aa
/session-persistence-event-catalog/events/team/team-task-log-only:
- en: ee2efdbe3f7b8978
- zh: a3829e506d9e81d1
+ en: 5ffeb64225feda2a
+ zh: d58129e189a726fb
/session-persistence-event-catalog/events/todo:
en: 308d4bb4b6b96e35
zh: 308d4bb4b6b96e35
@@ -1158,8 +1158,8 @@
en: 4ff17eae0b30056d
zh: 6642afbaa87da3ab
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata:
- en: 390c1daad2342ce8
- zh: 07ce68854dce8ea4
+ en: d7491d51e36cd1b6
+ zh: 2fa7d1c33bace7fc
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity:
en: 9531468264499498
zh: 9da23c0ad2776563
@@ -1197,23 +1197,23 @@
en: a3eabd0e9fc565dd
zh: fa763aefdbc1bdb8
/session-persistence-event-catalog/resolved-persistence-types/teammemberphase:
- en: 5457d6872347310b
- zh: e0d5946902c3e933
+ en: 2bf8f06fe328ab33
+ zh: ded602f18e0deadf
/session-persistence-event-catalog/resolved-persistence-types/teammembersnapshot:
- en: f6ee72fe168139ff
- zh: d28586070ab7b118
+ en: 8a37845006830548
+ zh: b8e7caf6189b92df
/session-persistence-event-catalog/resolved-persistence-types/teammessagesnapshot:
- en: 31fb7e4f07b49012
- zh: 15fd933a217dadee
+ en: 9b94faac43ba068f
+ zh: a83582be6a6a3c1a
/session-persistence-event-catalog/resolved-persistence-types/teammessagesource:
- en: 11bec13073c76f30
- zh: e90deecb47471c74
+ en: 269f0a4fcbea1c0c
+ zh: 5f2c50c03c951995
/session-persistence-event-catalog/resolved-persistence-types/teamtasksnapshot:
- en: 336a17d99a56859f
- zh: 51c252b4f4add812
+ en: cfa8230ef5d661b2
+ zh: cb40ce86d6778f09
/session-persistence-event-catalog/resolved-persistence-types/teamtaskstatus:
- en: 498986242f41c79a
- zh: 4d219bd7d9a3ac7c
+ en: 9bff57038f472770
+ zh: 4eec0e793ae16d53
/session-persistence-event-catalog/resolved-persistence-types/textblock:
en: c5fcd2cdd37ea387
zh: 9c678fbdea0bf25c
@@ -1299,8 +1299,8 @@
en: 386989934d1bb62e
zh: 69b8bc182a5acbd3
/session-persistence-event-catalog/resolved-persistence-types/string:
- en: 21a0bfa3d2e4d775
- zh: e1d212b7cbc37c35
+ en: 90d3fd7202322ca4
+ zh: 88cce532aed9f6d4
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1782,8 +1782,8 @@
en: 79db83dd2c430faf
zh: 173f568241f5ce86
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
- en: aacb77f1cead58ba
- zh: e6ce9dac4bdd07b7
+ en: cb5d3fbab4debac6
+ zh: 72add55254356149
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
@@ -1818,17 +1818,17 @@
en: afedf4a31a67eb14
zh: 98d3bc882f2b61c3
/session-persistence-event-catalog/resolved-persistence-types/member-teamid-version:
- en: a0a3339a63f2bc4f
- zh: fd6512ef11c36bde
+ en: 82b0989dad6bbfc0
+ zh: 0789b63f1d59b2ed
/session-persistence-event-catalog/resolved-persistence-types/message-step-turn:
en: 7cc30865737643f2
zh: 512c6c81ed10a9b6
/session-persistence-event-catalog/resolved-persistence-types/message-teamid-version:
- en: 7960b689af537cf6
- zh: e5b044052e009a98
+ en: f778769acb264dd5
+ zh: eba9b81d04e9f57b
/session-persistence-event-catalog/resolved-persistence-types/messageid-targetid-teamid-version:
- en: 22665480d164fdc7
- zh: aa2c96be4a4fe468
+ en: 91a948ede28ebcb5
+ zh: 8a2b0ac6bb68a09e
/session-persistence-event-catalog/resolved-persistence-types/mode-source:
en: 3028b222670ce5ad
zh: cae4c9437e560d61
@@ -1860,8 +1860,8 @@
en: 5360af614fe753e3
zh: 96427c489cc71de6
/session-persistence-event-catalog/resolved-persistence-types/task-teamid-version:
- en: bc6a4d5b483d4845
- zh: 839703c23f628d6b
+ en: a2fcd46e54b14120
+ zh: d9a6e0e50bbff9ad
/session-persistence-event-catalog/resolved-persistence-types/todos:
en: 06e6c1837e2b5d50
zh: 37cebfe3e41294ec
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index e20df04e5d..6d4c694309 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -843,7 +843,7 @@ Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/s
Types: [SessionTitleLlmRequestEventData](subsystems/session-title.md)
-Source: [`packages/session/session-title-llm/src/index.ts:54`](../packages/session/session-title-llm/src/index.ts)
+Source: [`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts)
### `session-log-deepseek/*`
@@ -978,14 +978,14 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/
Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md)
-Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts)
#### `team/message/delivered` — log-only
```ts persistence-catalog
-/** Durable acknowledgement that the target Session recorded the message. */
+/** Historical acknowledgement that the target Session recorded a queued message. */
'team/message/delivered': {
version: 2
teamId: TeamId
@@ -996,20 +996,20 @@ Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md)
-Source: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
#### `team/message/queued` — log-only
```ts persistence-catalog
-/** Durable mailbox enqueue, stored before delivery is attempted. */
+/** Historical mailbox enqueue; new sends use the target Agent inbox. */
'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot }
```
Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md)
-Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
@@ -1022,7 +1022,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md)
-Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
### `todo/*`
@@ -4503,7 +4503,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/
SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19`
-Sources: [`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts)
+Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4740,7 +4740,7 @@ Sources: [`packages/llm/llm/src/message.ts:35`](../packages/llm/llm/src/message.
SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd`
-Sources: [`packages/experimental/agent-team/src/types.ts:44`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:45`](../packages/experimental/agent-team/src/types.ts)
One of:
@@ -4758,7 +4758,7 @@ One of:
SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25`
-Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:48`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4780,7 +4780,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi
SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e`
-Sources: [`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:129`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4800,7 +4800,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:128`](../packages/exper
SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0`
-Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:138`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4820,7 +4820,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/exper
SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357`
-Sources: [`packages/experimental/agent-team/src/types.ts:74`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:75`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4843,7 +4843,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:74`](../packages/experi
SHA-256: `b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943`
-Sources: [`packages/experimental/agent-team/src/types.ts:71`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:72`](../packages/experimental/agent-team/src/types.ts)
One of:
@@ -5382,7 +5382,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
-Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
+Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7341,7 +7341,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
-Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts)
+Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7492,7 +7492,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web
SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1`
-Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7520,7 +7520,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src
SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
-Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7534,7 +7534,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/exper
SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1`
-Sources: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7680,7 +7680,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](.
SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c`
-Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
+Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md
index 4e8c425b73..92024676bd 100644
--- a/docs/persistence-catalog.zh.md
+++ b/docs/persistence-catalog.zh.md
@@ -845,7 +845,7 @@ export type SessionEvent = {
类型:[SessionTitleLlmRequestEventData](subsystems/session-title.zh.md)
-来源:[`packages/session/session-title-llm/src/index.ts:54`](../packages/session/session-title-llm/src/index.ts)
+来源:[`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts)
### `session-log-deepseek/*`
@@ -980,14 +980,14 @@ export type SessionEvent = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md)
-来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts)
#### `team/message/delivered` — log-only
```ts persistence-catalog
-/** Durable acknowledgement that the target Session recorded the message. */
+/** Historical acknowledgement that the target Session recorded a queued message. */
'team/message/delivered': {
version: 2
teamId: TeamId
@@ -998,20 +998,20 @@ export type SessionEvent = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md)
-来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
#### `team/message/queued` — log-only
```ts persistence-catalog
-/** Durable mailbox enqueue, stored before delivery is attempted. */
+/** Historical mailbox enqueue; new sends use the target Agent inbox. */
'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot }
```
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md)
-来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
@@ -1024,7 +1024,7 @@ export type SessionEvent = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md)
-来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
### `todo/*`
@@ -4505,7 +4505,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4`
SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19`
-来源:[`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts)
+来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4742,7 +4742,7 @@ SHA-256: `65a8400e451e667e5b6e34df72ceb81fca8c12330330fa72083ad40279b189ad`
SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd`
-来源:[`packages/experimental/agent-team/src/types.ts:44`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:45`](../packages/experimental/agent-team/src/types.ts)
以下类型之一:
@@ -4760,7 +4760,7 @@ SHA-256: `dfce5db4aa42b7ebe0bb79eca92553587287e18c06b9eba85cbfb9d332e26acd`
SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25`
-来源:[`packages/experimental/agent-team/src/types.ts:47`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:48`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4782,7 +4782,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25`
SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e`
-来源:[`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:129`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4802,7 +4802,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e`
SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0`
-来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:138`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4822,7 +4822,7 @@ SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0`
SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357`
-来源:[`packages/experimental/agent-team/src/types.ts:74`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:75`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4845,7 +4845,7 @@ SHA-256: `51edec100659da3a9bd9c3f6ba1180f5e64ec9a1585e5057080df45c180bd357`
SHA-256: `b2d2a01e51341a3bfec809198952c8c830a7899f4cb6ecd3c1dc0b5d9a2af943`
-来源:[`packages/experimental/agent-team/src/types.ts:71`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:72`](../packages/experimental/agent-team/src/types.ts)
以下类型之一:
@@ -5384,7 +5384,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
-来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
+来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7343,7 +7343,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
-来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts)
+来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7494,7 +7494,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731`
SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1`
-来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:230`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7522,7 +7522,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5`
SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
-来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7536,7 +7536,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1`
-来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7682,7 +7682,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028`
SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c`
-来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
diff --git a/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml b/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml
index 22adb806b0..5bf6e8cc50 100644
--- a/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml
+++ b/docs/persistence-changes/2026-09-16-session-format-v4.i18n.yaml
@@ -18,11 +18,11 @@
en: 6907136f28e48c90
zh: 02f8298485f3d8e7
/2026-09-16-session-format-v4/compatibility:
- en: 62d76f6b66d04dc4
- zh: 736b1b7cc2f6084f
+ en: 0fc8ce7241c4ecbe
+ zh: d3f2c2812ecaca38
/2026-09-16-session-format-v4/verification:
- en: f917c71292c98b17
- zh: 974c65afde0adb22
+ en: 94b3c1b1d07bc87d
+ zh: af7ec37007c62ecb
/2026-09-16-session-format-v4/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
diff --git a/docs/persistence-changes/2026-09-16-session-format-v4.md b/docs/persistence-changes/2026-09-16-session-format-v4.md
index 187acab093..370970ccb7 100644
--- a/docs/persistence-changes/2026-09-16-session-format-v4.md
+++ b/docs/persistence-changes/2026-09-16-session-format-v4.md
@@ -93,7 +93,7 @@ The [native V4 validation decision](../../.agents/notes/implemented/architecture
The V3-to-V4 migration lifts released user-role tool results into tool-role messages with a required toolCallId and optional isError. Tool-result wrappers leave the content-block union. The migration preserves every admitted source event and inherited cut, and appends missing parent subagent/catalog records from retained direct-child logs in the same persistence root. Historical body restoration requires an explicit child-evidence set, including an empty set when no children are available to backfill. Missing, multiple, or unknown-version child descriptors skip that child’s backfill; conflicting identities, timestamps, or modes refuse migration without publication. Existing catalog facts remain. Historical read opens prepare the result in memory. Write opens revalidate child membership and revisions before publishing the current successor beside unchanged predecessor files. Delivery-generation checks keep historical acknowledgements from becoming active V4 watermarks. V3 readers refuse the newer generation. The finalized transition adds forked to turn/end.reason; exact-cut forks append child-owned error results and closers after the inherited marker. V4 admits checked not-started fork results with deterministic branch-specific IDs and wording; released V0–V3 validators and recorded predecessor generations remain unchanged.
-The `request/header` schema also records the retired `system` key as forbidden. This declaration captures the existing native-reader refusal without changing stored data or prompt reconstruction; later permitting a value requires a version bump instead of being classified as an ordinary optional-field addition.
+The `request/header` schema also records the retired `system` key as forbidden. This declaration captures the existing native-reader refusal without changing stored data or prompt reconstruction; later permitting a value requires compatibility review for a same-version change, or an explicit version bump, instead of being classified as an ordinary optional-field addition.
Producer-owned sources replace released plugin wrappers through the [V3-to-V4 migration](../../packages/session/session-format-v3-to-v4/README.md#v3-to-v4-specification). The frozen rename table and collision rules preserve source fields and event coordinates; unknown producer attribution retains every own JSON property. Native read and write opens validate source fields before exposing the Session. Existing V4 files do not rerun the incoming migration edge.
@@ -108,7 +108,7 @@ The PTC producer writes `source.kind: 'ptc-mode'`. The V3-to-V4 edge retains `to
The focused Session, agent-loop, Session Controller, V4, chat-view, and compaction suites passed 1,523 tests across 63 files after integration of exact-cut forks. V4 fork tests retain original IDs and text across encoding, decoding, and restoration, reject malformed results, and validate nested inherited cuts. The tool-role migration tests and SDK snapshot refresh also passed in the originating change; the built Python runtime sdk-snapshot scenario passed, and focused pi-ai and auto-review coverage passed 351 tests with 100% coverage of the three affected modules.
-The generated request-header reservation regression and existing retired-syntax and Session surface tests pass 65 tests across three files. The generated field retains optional `never`; permitting an optional string produces a version-bump diagnostic while the native reader still refuses the retired key.
+The generated request-header reservation regression and existing retired-syntax and Session surface tests passed 65 tests across three files. The generated field retains optional `never`; the current classifier marks permitting an optional string as requiring compatibility review, while the native reader still refuses the retired key.
The producer-source and request-input checks pass 447 tests across 12 files, including provider equality, request-only type rejection, user-attribution retention, source migration, and native source admission. Comparing the generated inventories with the tool-role parent reports four changed roots and 447 unchanged type fingerprints.
diff --git a/docs/persistence-changes/2026-09-16-session-format-v4.zh.md b/docs/persistence-changes/2026-09-16-session-format-v4.zh.md
index 16a1793797..f9c25f9b32 100644
--- a/docs/persistence-changes/2026-09-16-session-format-v4.zh.md
+++ b/docs/persistence-changes/2026-09-16-session-format-v4.zh.md
@@ -93,7 +93,7 @@ changes:
V3-to-V4 迁移将已发布的 user 角色工具结果提升为 tool 角色消息,包含必需的 toolCallId 和可选的 isError。工具结果包装不再属于内容块联合。迁移保留每个已接纳源事件和继承切分点,并根据同一持久化根目录中保留的直属子 Session 日志追加缺失的父级 subagent/catalog 记录。历史正文恢复要求显式提供子日志证据集合;没有可供补全的子日志时也须传入空集合。子日志的 descriptor 缺失、多条或版本未知时,跳过该子项的补全;身份、时间戳或模式冲突会拒绝迁移且不发布后继。已有 catalog 事实保持不变。历史读取打开在内存中准备结果。写入打开在重新校验子项成员与修订后,将当前后继发布到未修改的前代文件旁。Delivery generation 校验防止历史确认成为有效的 V4 水位。V3 读取方拒绝更新的 generation。已定稿迁移向 turn/end.reason 添加 forked。精确切点的 fork 在继承标记之后追加子会话自有的错误结果和结束事件。V4 接纳经过校验、使用确定性分支 ID 和文案的未启动 fork 结果;已发布的 V0–V3 校验器和已记录的前驱代际保持不变。
-`request/header` schema 还将退役的 `system` 键记录为禁止字段。这项声明记录已有的原生读取拒绝规则,不改变存储数据或提示词重建;以后允许该字段携带值时,必须提升格式版本,而不能归类为普通可选字段添加。
+`request/header` schema 还将退役的 `system` 键记录为禁止字段。这项声明记录已有的原生读取拒绝规则,不改变存储数据或提示词重建;以后允许该字段携带值时,同版本变更必须经过兼容性评审,或显式提升格式版本,而不能归类为普通可选字段添加。
生产者拥有的 source 通过 [V3→V4 迁移](../../packages/session/session-format-v3-to-v4/README.zh.md#v3-to-v4-specification)替代已发布的 plugin wrapper。冻结的重命名表和冲突规则保留 source 字段与事件坐标;未知生产者归属保留每个自有 JSON 属性。原生读取和写入打开在公开 Session 之前校验 source 字段。已有 V4 文件不会重新运行迁入边。
@@ -108,7 +108,7 @@ PTC 生产者写入 `source.kind: 'ptc-mode'`。V3→V4 迁入边保留 `tools-c
精确切点 fork 集成后,聚焦的 Session、agent-loop、Session Controller、V4、chat-view 和 compaction 测试共 63 个文件、1,523 个测试通过。V4 fork 测试确认编码、解码和恢复保留原始 ID 与文本,拒绝格式错误的结果,并验证嵌套继承切点。工具角色迁移测试和 SDK 快照刷新也在原始变更中通过;构建后的 Python runtime sdk-snapshot 场景通过,定向 pi-ai 与 auto-review 覆盖率检查通过 351 个测试,三个受影响模块覆盖率均为 100%。
-生成的请求头保留字段回归测试以及已有的退役语法和 Session surface 测试共通过三个文件中的 65 项测试。生成字段保留可选的 `never`;允许可选字符串会产生必须提升版本的诊断,而原生读取方仍拒绝该退役键。
+生成的请求头保留字段回归测试以及已有的退役语法和 Session surface 测试曾通过三个文件中的 65 项测试。生成字段保留可选的 `never`;当前分类器将允许可选字符串标记为需要兼容性评审,而原生读取方仍拒绝该退役键。
Producer-source 与 request-input 检查共通过 12 个文件中的 447 项测试,覆盖 provider 等价性、仅供请求使用的输入的类型拒绝、user 归属保留、source 迁移及原生 source 准入。与 tool-role 父层的生成目录相比,有四个 root 发生变化,447 个类型指纹保持不变。
diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml b/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml
index 1ae5d09e4f..080248eabe 100644
--- a/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml
+++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.i18n.yaml
@@ -21,8 +21,8 @@
en: 39494aa3af994e6f
zh: 07c2880503668176
/2026-10-05-working-directory-attribution/verification:
- en: 5963942c0d87e518
- zh: 5dbb6ac0fca1f279
+ en: 9b79379292881d10
+ zh: 54d670c09ce1bfc7
/2026-10-05-working-directory-attribution/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.md b/docs/persistence-changes/2026-10-05-working-directory-attribution.md
index 80996eee5c..6496413c37 100644
--- a/docs/persistence-changes/2026-10-05-working-directory-attribution.md
+++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.md
@@ -52,7 +52,7 @@ Existing records remain valid. The additive working-directory source kind is att
## Verification
-pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent-in-process-driver/tests/schedule-tools.spec.ts: 4 files and 203 tests passed.
+pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent/tests/schedule-tools.spec.ts: 5 files and 207 tests passed.
## Dev Note
diff --git a/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md b/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md
index e7ed00e08f..56157cc7d2 100644
--- a/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md
+++ b/docs/persistence-changes/2026-10-05-working-directory-attribution.zh.md
@@ -52,7 +52,7 @@ changes:
## 验证
-pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent-in-process-driver/tests/schedule-tools.spec.ts:4 个文件、203 个测试通过。
+pnpm exec vitest run packages/session/working-directory/tests packages/shell/tool-bash/tests/tools.spec.ts packages/shell/tool-pwsh/tests/tools.spec.ts packages/subagent/subagent/tests/schedule-tools.spec.ts:5 个文件、207 个测试通过。
## 开发备注
diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml
index 30537db444..aa185ca392 100644
--- a/docs/persistence-changes/README.i18n.yaml
+++ b/docs/persistence-changes/README.i18n.yaml
@@ -18,11 +18,11 @@
en: 6cd0f79a9455bdf2
zh: a5978b7834c0802b
/persistence-type-change-records/compatibility-rules:
- en: 62ce78d5618572b6
- zh: 023021d431f8a8e6
+ en: 218b28a5096b5e7d
+ zh: 5b987c852e204893
/persistence-type-change-records/history-and-limitations:
- en: 10a426d36c41548b
- zh: ef03b693d9d9be26
+ en: 705a215ce0dd5d82
+ zh: a3b3e46231dae6ce
/persistence-type-change-records/dev-note:
en: 5de1d6304e00c58f
zh: 8d8225361cfa29e5
diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md
index 4f6cf773a9..79e1401414 100644
--- a/docs/persistence-changes/README.md
+++ b/docs/persistence-changes/README.md
@@ -46,25 +46,27 @@ The [record template](../../.agents/skills/dsh-doc/templates/persistence-change.
## Compatibility rules
-Every detected structural change requires an acknowledgement. Record creation and update infer the minimum decision from these fixed rules; an explicit `--decision` is a checked assertion. The rules apply to the complete change, so an allowed change cannot hide a simultaneous breaking change.
+Every detected structural change requires an acknowledgement. Keep the Session version when older readers preserve the required meaning of accepted data, safely ignore optional features while retaining opaque records, or refuse unsupported required data before execution, mutation, or recovery truncation. New readers must preserve historical meaning and readability. Bump only when unsafe interpretation cannot be prevented by an effective discriminator. Unknown fields or values count only when existing readers actually handle them safely; type differences alone cannot establish that behavior. See the [reader-compatibility decision](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md).
-| Detected change | Minimum decision |
+| Detected change | Recording requirement |
|---|---|
| Add an optional event-body property, including its complete subtree | `same-version` |
| Make a required event-body property optional | `same-version` |
| Add an ordinary event type | `same-version` |
| Add a higher numeric `data.version` to an ordinary event while retaining every old payload alternative under the existing compatibility rules | `same-version` |
| Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` |
-| Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` |
-| Change the Session header or event envelope | `version-bump` |
+| Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | Compatibility review for `same-version`, or explicit `version-bump` |
+| Change the Session header or event envelope | Compatibility review for `same-version`; a header version change requires `version-bump` |
-A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition.
+The classifier identifies established same-version cases and changes requiring compatibility review; it cannot decide semantic safety. The latter require an explicit `--decision same-version` when recording or updating without a version increase. The acknowledgement’s Compatibility section explains actual reader behavior in both directions, and Verification records the executed checks. Review the complete transition, including simultaneous changes. An explicit `version-bump` instead requires its own increasing `SessionHeader.version` transition; a same-version decision cannot waive a header version change. Finalized records remain immutable, so a later record cannot reuse the accepted 3→4 transition.
-An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided every existing alternative remains readable under the compatibility rules above. Optional property additions and required-to-optional changes may accompany the new version; each retained alternative is compared separately. The reader must retain support for the old payloads; adding variants at an existing version, dropping old versions, and changing the Session header or envelope remain breaking. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition.
+The CLI requires that explicit choice before generating a flagged same-version record. CI reads the recorded `decision: same-version` and accepts it when the ordinary history, schema, and header-version constraints hold, whether the record was generated or edited manually. Verification cannot establish how the record was authored or whether a human reviewed it. Reviewers compare base and head inventories with [`persistence-review`](../cookbook/reviewing-persistence-type-changes.md#generate); `--check` validates current-tree consistency and cannot reveal a transition already covered by an acknowledgement.
-The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group.
+An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided every existing alternative remains readable under the established same-version rules above. Optional property additions and required-to-optional changes may accompany the new version; each retained alternative is compared separately. Other payload changes require compatibility review. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition.
-A same-version explanation states how old records remain readable and how older readers handle new records. Optional additions explain why older readers can ignore them without changing replay; a new payload version records the older-reader rejection. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md).
+The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated structural changes require compatibility review. Multiple context-form alternatives with the same wire kind form one group.
+
+Every same-version explanation states how old records remain readable and how older readers handle new records, including established cases that need no explicit CLI decision. A feature absent from a log must retain the old behavior; dispensable features must be safe to ignore; required features need an effective refusal signal. Unsupported-feature refusal must not become corruption recovery or tail truncation. For required-to-optional changes, explain how readers handle an absent value. Reviewers assess behavior and evidence; the checker validates the declaration and type classification. A version-bump record follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md).
When a reader rejects a JSON property by name, declare the property as optional `never` and mark it with argument-free `@persistenceReserved`. The extractor retains the forbidden field, so allowing a JSON value later requires an existing-field type change. Required or JSON-valued properties cannot carry this marker. Unmarked optional `never` and `undefined` properties retain their existing omission behavior.
@@ -75,7 +77,7 @@ One baseline records the complete initial inventory. Later records use their pre
Accepted records describe historical transitions; preserve their machine declarations and schema snapshots when adding a successor. An unaccepted terminal record can be refreshed explicitly; the command rejects baselines, records with dependants, and checkpoint-locked records before producing artifacts. Verification checks retained checkpoint hashes; it does not prove that the checkpoints and their authority record were never edited together. No Git ref, remote service, or released checkout supplies the baseline.
-Digests describe declared persistence types, not runtime validation or behavior. Ordinary comments, source locations, alias names, and harmless declaration reordering do not affect them. Compatibility annotations are recorded policy data and do affect digests. Unannotated version-1 snapshots retain their original normalization, fingerprints, and strict classification; policy-bearing graphs use a separate fingerprint domain. Object fields, union alternatives, intersection operands, and index signatures can be reordered when their resolved types stay the same; tuple positions and numeric enum values remain significant. Catalog text and source locations may still change, so regenerate stale artifacts without adding an acknowledgement for an unchanged digest. Opaque types such as `unknown` expose no hidden structure to compare. Behavior-only changes and structures hidden inside opaque values are outside this mechanism's scope. The [decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) records these trade-offs.
+Digests describe declared persistence types, not runtime validation or behavior. Ordinary comments, source locations, alias names, and harmless declaration reordering do not affect them. Compatibility annotations are recorded policy data and do affect digests. Unannotated version-1 snapshots retain their original normalization and fingerprints; policy-bearing graphs use a separate fingerprint domain. Object fields, union alternatives, intersection operands, and index signatures can be reordered when their resolved types stay the same; tuple positions and numeric enum values remain significant. Catalog text and source locations may still change, so regenerate stale artifacts without adding an acknowledgement for an unchanged digest. Opaque types such as `unknown` expose no hidden structure to compare. Behavior-only changes and structures hidden inside opaque values are outside this mechanism's scope. The [decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) records these trade-offs.
## Dev Note
diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md
index 5754ff07c1..c7efb7f6cb 100644
--- a/docs/persistence-changes/README.zh.md
+++ b/docs/persistence-changes/README.zh.md
@@ -46,25 +46,27 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
## 兼容性规则
-每个检测到的结构变更都需要确认。创建和更新记录依据这些固定规则推断最低决策;显式 `--decision` 是受检查的断言。规则应用于整个变更,因此允许的变更不能掩盖同时发生的破坏性变更。
+每个检测到的结构变更都需要确认。当旧读取器保留所接受数据的必要含义、安全忽略可选功能并保留不透明记录,或在执行、修改及恢复截断前拒绝不受支持的必要数据时,可以保留 Session 版本。新读取器必须保留历史含义和可读性。只有有效判别信息无法阻止不安全解释时,才提升版本。未知字段或值只有在已有读取器实际安全处理它们时才有效;类型差异本身无法证明这种行为。参见[读取器兼容性决策](../../.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.zh.md)。
-| 检测到的变更 | 最低决策要求 |
+| 检测到的变更 | 记录要求 |
|---|---|
| 添加可选事件体属性,包括其完整子树 | `same-version` |
| 将必选事件体属性改为可选 | `same-version` |
| 添加普通事件类型 | `same-version` |
| 为普通事件增加更高的数字 `data.version`,并按现有兼容性规则保留所有旧载荷分支 | `same-version` |
| 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` |
-| 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` |
-| 更改会话头或事件封装 | `version-bump` |
+| 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `same-version` 需兼容性评审,或显式 `version-bump` |
+| 更改会话头或事件封装 | `same-version` 需兼容性评审;头版本变化要求 `version-bump` |
-定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。
+分类器识别已明确允许的同版本情形及需要兼容性评审的变更,不能判定语义安全性。后者在不升版本的记录或更新操作中要求显式提供 `--decision same-version`。确认记录的兼容性部分说明两个方向上的实际读取器行为,验证部分记录已执行的检查。评审覆盖整个转换,包括同时发生的其他变更。显式 `version-bump` 则要求自身包含 `SessionHeader.version` 递增转换;同版本决策不能豁免头版本变化。定稿记录保持不可变,因此后续记录不能复用已接受的 3→4 转换。
-普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支按上述兼容性规则保持可读。可选属性新增和必选改可选可以与新版本一同引入;每个保留分支均独立比较。读取器必须保留旧载荷支持;在已有版本中增加分支、移除旧版本,以及修改 Session header 或事件封装仍属于破坏性变更。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md) 记录了此类演进。
+CLI 在生成被标记的同版本记录前要求该显式选择。CI 读取所记录的 `decision: same-version`;只要通常的历史、schema 与头版本约束成立,无论记录由工具生成还是手工编辑,都会接受。验证无法确定记录的编写方式,也无法证明人类已评审。评审者使用 [`persistence-review`](../cookbook/reviewing-persistence-type-changes.zh.md#generate) 比较 base 与 head 清单;`--check` 验证当前目录一致性,无法显示已被确认记录覆盖的转换。
-提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。
+普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支按上述明确允许的同版本规则保持可读。可选属性新增和必选改可选可以与新版本一同引入;每个保留分支均独立比较。其他载荷变更需要兼容性评审。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md)记录了此类演进。
-同版本说明须解释旧记录如何保持可读,以及旧读取器如何处理新记录。可选新增须说明旧读取器为何可以忽略它而不改变回放;新增载荷版本须记录旧读取器的拒绝行为。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。
+提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关结构变更需要兼容性评审。同一 wire kind 的多个上下文形式分支归为一组。
+
+每项同版本说明都须解释旧记录如何保持可读,以及旧读取器如何处理新记录,包括无需显式 CLI 决策的明确允许情形。日志未使用某功能时必须保留旧行为;可舍弃功能必须能安全忽略;必要功能需要有效的拒绝信号。不受支持功能的拒绝不能变成损坏恢复或尾部截断。对于必选改可选的变更,说明读取器如何处理缺失值。审阅者判断行为和证据;检查器校验声明及类型分类。升版本记录遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。
当读取器按属性名拒绝 JSON 字段时,将该属性声明为可选 `never`,并添加无参数的 `@persistenceReserved` 标记。提取器保留这一禁止字段,因此后续允许 JSON 值属于已有字段类型变更。必选属性或允许 JSON 值的属性不能携带该标记。未标记的可选 `never` 和 `undefined` 属性保留现有省略行为。
@@ -75,7 +77,7 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
已接受记录描述历史转换;添加后继时须保留其机器声明和 schema 快照。尚未接受的末端记录可以显式刷新;命令在生成产物前拒绝基线、被依赖的记录和被检查点锁定的记录。验证检查所保留检查点的哈希,但不证明检查点及其真源记录从未被一起修改。基线不依赖 Git 引用、远端服务或已发布版本的检出目录。
-摘要描述声明的持久化类型,不描述运行时验证或行为。普通注释、源码位置、别名名称和无语义变化的声明重排不影响摘要。兼容性标记是记录的策略数据,会影响摘要。不带标记的版本 1 快照保留原有规范化、指纹和严格分类;包含策略的图使用独立的指纹域。对象字段、联合类型分支、交叉类型操作数和索引签名可以在解析类型不变时重排;元组位置和数字枚举值仍然影响摘要。目录文本和源码位置仍可能变化,因此应重新生成陈旧产物,无需为未变的摘要添加确认记录。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。
+摘要描述声明的持久化类型,不描述运行时验证或行为。普通注释、源码位置、别名名称和无语义变化的声明重排不影响摘要。兼容性标记是记录的策略数据,会影响摘要。不带标记的版本 1 快照保留原有规范化和指纹;包含策略的图使用独立的指纹域。对象字段、联合类型分支、交叉类型操作数和索引签名可以在解析类型不变时重排;元组位置和数字枚举值仍然影响摘要。目录文本和源码位置仍可能变化,因此应重新生成陈旧产物,无需为未变的摘要添加确认记录。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。
## 开发备注
diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml
index 0ac31ac4ee..202343b8f7 100644
--- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-0-1-rc-2/structural-changes:
- en: bdf6c95697ae5816
- zh: 0332dec60181ab4a
+ en: 7cd96972b43b0cb0
+ zh: 7e61c1098f402657
/persistence-release-dsh-v0-0-1-rc-2/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md
index f9deeccf24..621d74281c 100644
--- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md
+++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.md
@@ -191,58 +191,58 @@ changes:
-Detected 45 changed roots and 48 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 45 changed roots and 48 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/done.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/run.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/prune.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/summary.ignorable` | `optional-property-added` | `version-bump` |
-| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` |
-| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` |
-| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` |
-| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/context.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/header.ignorable` | `optional-property-added` | `version-bump` |
-| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:schedule/change` | `root-added` | `same-version` |
-| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` |
-| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/agent-end` | `root-added` | `same-version` |
-| `event:tool-workflow/agent-start` | `root-added` | `same-version` |
-| `event:tool-workflow/run-end` | `root-added` | `same-version` |
-| `event:tool-workflow/run-start` | `root-added` | `same-version` |
-| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
-| `event:user/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/done.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/run.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/prune.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/summary.ignorable` | `optional-property-added` | `review-required` |
+| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` |
+| `event:goal/change.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` |
+| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` |
+| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/context.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/header.ignorable` | `optional-property-added` | `review-required` |
+| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:schedule/change` | `root-added` | `not-required` |
+| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` |
+| `event:todo/write.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/agent-end` | `root-added` | `not-required` |
+| `event:tool-workflow/agent-start` | `root-added` | `not-required` |
+| `event:tool-workflow/run-end` | `root-added` | `not-required` |
+| `event:tool-workflow/run-start` | `root-added` | `not-required` |
+| `event:tool/call.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
+| `event:user/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md
index 32962d394e..b0e2b58248 100644
--- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.2.zh.md
@@ -191,58 +191,58 @@ changes:
-检测到 45 个根类型变化、48 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 45 个根类型变化、48 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/done.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/run.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/prune.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compact/summary.ignorable` | `optional-property-added` | `version-bump` |
-| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` |
-| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` |
-| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` |
-| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/context.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/header.ignorable` | `optional-property-added` | `version-bump` |
-| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:schedule/change` | `root-added` | `same-version` |
-| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` |
-| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/agent-end` | `root-added` | `same-version` |
-| `event:tool-workflow/agent-start` | `root-added` | `same-version` |
-| `event:tool-workflow/run-end` | `root-added` | `same-version` |
-| `event:tool-workflow/run-start` | `root-added` | `same-version` |
-| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
-| `event:user/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/done.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/run.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/prune.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:compact/summary.ignorable` | `optional-property-added` | `review-required` |
+| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` |
+| `event:goal/change.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` |
+| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` |
+| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/context.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/header.ignorable` | `optional-property-added` | `review-required` |
+| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:schedule/change` | `root-added` | `not-required` |
+| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` |
+| `event:todo/write.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/agent-end` | `root-added` | `not-required` |
+| `event:tool-workflow/agent-start` | `root-added` | `not-required` |
+| `event:tool-workflow/run-end` | `root-added` | `not-required` |
+| `event:tool-workflow/run-start` | `root-added` | `not-required` |
+| `event:tool/call.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
+| `event:user/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml
index 9a336ac41d..6e491600da 100644
--- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-0-1-rc-3/structural-changes:
- en: b4ded7b41999584d
- zh: 49c3f355a5559cbd
+ en: 0fd2841ea23b8cd6
+ zh: 1138a25ac478eaa8
/persistence-release-dsh-v0-0-1-rc-3/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md
index 7201966f5e..fcede4e7bd 100644
--- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md
+++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.md
@@ -92,22 +92,22 @@ changes:
-Detected 12 changed roots and 12 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 12 changed roots and 12 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `version-bump` |
-| `event:compact/end` | `root-removed` | `version-bump` |
-| `event:compact/prune` | `root-removed` | `version-bump` |
-| `event:compact/start` | `root-removed` | `version-bump` |
-| `event:compact/summary` | `root-removed` | `version-bump` |
-| `event:compaction/end` | `root-added` | `same-version` |
-| `event:compaction/prune` | `root-added` | `same-version` |
-| `event:compaction/start` | `root-added` | `same-version` |
-| `event:compaction/summary` | `root-added` | `same-version` |
-| `event:hook/invoked.data.dialect` | `type-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `version-bump` |
-| `event:user/message.data.source.kind` | `type-changed` | `version-bump` |
+| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `review-required` |
+| `event:compact/end` | `root-removed` | `review-required` |
+| `event:compact/prune` | `root-removed` | `review-required` |
+| `event:compact/start` | `root-removed` | `review-required` |
+| `event:compact/summary` | `root-removed` | `review-required` |
+| `event:compaction/end` | `root-added` | `not-required` |
+| `event:compaction/prune` | `root-added` | `not-required` |
+| `event:compaction/start` | `root-added` | `not-required` |
+| `event:compaction/summary` | `root-added` | `not-required` |
+| `event:hook/invoked.data.dialect` | `type-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `review-required` |
+| `event:user/message.data.source.kind` | `type-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md
index a59d39a86f..3ece24837e 100644
--- a/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.0.1-rc.3.zh.md
@@ -92,22 +92,22 @@ changes:
-检测到 12 个根类型变化、12 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 12 个根类型变化、12 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `version-bump` |
-| `event:compact/end` | `root-removed` | `version-bump` |
-| `event:compact/prune` | `root-removed` | `version-bump` |
-| `event:compact/start` | `root-removed` | `version-bump` |
-| `event:compact/summary` | `root-removed` | `version-bump` |
-| `event:compaction/end` | `root-added` | `same-version` |
-| `event:compaction/prune` | `root-added` | `same-version` |
-| `event:compaction/start` | `root-added` | `same-version` |
-| `event:compaction/summary` | `root-added` | `same-version` |
-| `event:hook/invoked.data.dialect` | `type-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `version-bump` |
-| `event:user/message.data.source.kind` | `type-changed` | `version-bump` |
+| `event:agent/inbox/spliced.data.inserted[].source.kind` | `type-changed` | `review-required` |
+| `event:compact/end` | `root-removed` | `review-required` |
+| `event:compact/prune` | `root-removed` | `review-required` |
+| `event:compact/start` | `root-removed` | `review-required` |
+| `event:compact/summary` | `root-removed` | `review-required` |
+| `event:compaction/end` | `root-added` | `not-required` |
+| `event:compaction/prune` | `root-added` | `not-required` |
+| `event:compaction/start` | `root-added` | `not-required` |
+| `event:compaction/summary` | `root-added` | `not-required` |
+| `event:hook/invoked.data.dialect` | `type-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source.kind` | `type-changed` | `review-required` |
+| `event:user/message.data.source.kind` | `type-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml
index 0efe3d557b..ca3647aa67 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-0-rc-7/structural-changes:
- en: 5178d26722926711
- zh: bfe39111dd34ff48
+ en: 1a08248deb5e841c
+ zh: b5dd3c2ac161a100
/persistence-release-dsh-v0-1-0-rc-7/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md
index 6427da5c46..4c30b5408f 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.md
@@ -59,11 +59,11 @@ changes:
-Detected 1 changed root and 1 structural difference. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 1 changed root and 1 structural difference. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `version-bump` |
+| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md
index 91ae3f1061..379e11e6ff 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.7.zh.md
@@ -59,11 +59,11 @@ changes:
-检测到 1 个根类型变化、1 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 1 个根类型变化、1 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `version-bump` |
+| `event:assistant/chunk.data.chunk.replayState` | `type-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml
index d2012c1958..f55fe23514 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-0-rc-8/structural-changes:
- en: 6c7808df4fdcbf70
- zh: 8b6d83a8adec7f51
+ en: 1909dc1b62eef347
+ zh: 010e53d396fc675c
/persistence-release-dsh-v0-1-0-rc-8/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md
index c52f050b82..83170d8295 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.md
@@ -80,18 +80,18 @@ changes:
-Detected 8 changed roots and 8 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 8 changed roots and 8 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:assistant/message.data.interrupted` | `optional-property-added` | `same-version` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:team/member` | `root-added` | `same-version` |
-| `event:team/message/delivered` | `root-added` | `same-version` |
-| `event:team/message/queued` | `root-added` | `same-version` |
-| `event:team/task` | `root-added` | `same-version` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:assistant/message.data.interrupted` | `optional-property-added` | `not-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:team/member` | `root-added` | `not-required` |
+| `event:team/message/delivered` | `root-added` | `not-required` |
+| `event:team/message/queued` | `root-added` | `not-required` |
+| `event:team/task` | `root-added` | `not-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md
index b4fd283ede..a18e26f5fd 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.0-rc.8.zh.md
@@ -80,18 +80,18 @@ changes:
-检测到 8 个根类型变化、8 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 8 个根类型变化、8 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:assistant/message.data.interrupted` | `optional-property-added` | `same-version` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:team/member` | `root-added` | `same-version` |
-| `event:team/message/delivered` | `root-added` | `same-version` |
-| `event:team/message/queued` | `root-added` | `same-version` |
-| `event:team/task` | `root-added` | `same-version` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:assistant/message.data.interrupted` | `optional-property-added` | `not-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:team/member` | `root-added` | `not-required` |
+| `event:team/message/delivered` | `root-added` | `not-required` |
+| `event:team/message/queued` | `root-added` | `not-required` |
+| `event:team/task` | `root-added` | `not-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml
index c5721338e9..0a07a76011 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-1-rc-1/structural-changes:
- en: 0be3fa84192c242d
- zh: 59dc45dd8e7f7594
+ en: 69516d329a54a014
+ zh: 16a3c770a9ba3217
/persistence-release-dsh-v0-1-1-rc-1/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md
index 6b598f1c61..23febf967f 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.md
@@ -59,11 +59,11 @@ changes:
-Detected 1 changed root and 1 structural difference. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 1 changed root and 1 structural difference. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `event:permission/preset.data.origin` | `optional-property-added` | `same-version` |
+| `event:permission/preset.data.origin` | `optional-property-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md
index eca10c509e..2220cf203a 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.1.zh.md
@@ -59,11 +59,11 @@ changes:
-检测到 1 个根类型变化、1 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 1 个根类型变化、1 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `event:permission/preset.data.origin` | `optional-property-added` | `same-version` |
+| `event:permission/preset.data.origin` | `optional-property-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml
index f0259c93e1..54355badb7 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-1-rc-2/structural-changes:
- en: c03cbb0f4bfb5d83
- zh: c23900507d4111aa
+ en: fedf3524c654f8b3
+ zh: 4df22b00a3981e60
/persistence-release-dsh-v0-1-1-rc-2/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md
index 41cc4f0a39..694393a876 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.md
@@ -86,21 +86,21 @@ changes:
-Detected 10 changed roots and 11 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 10 changed roots and 11 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:permission/preset.data.origin` | `property-removed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
+| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:permission/preset.data.origin` | `property-removed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md
index 5ab22efd5d..7fa7ba008f 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.1-rc.2.zh.md
@@ -86,21 +86,21 @@ changes:
-检测到 10 个根类型变化、11 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 10 个根类型变化、11 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:permission/preset.data.origin` | `property-removed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
-| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `same-version` |
+| `event:agent/inbox/spliced.data.inserted[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:assistant/chunk.data.chunk.block.attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:assistant/message.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:compaction/summary.data.rawOutput[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:compaction/summary.data.summary[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:permission/preset.data.origin` | `property-removed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:team/message/queued.data.message.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:tool/code-dispatch.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:tool/result.data.message.content[0].content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
+| `event:user/message.data.content[].attachment.originalDimensions` | `optional-property-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml
index 8d25d441e9..b5891761cd 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-2-alpha-1/structural-changes:
- en: f8e5907da99d32b9
- zh: dcd8dbc9660a20b6
+ en: ebe16d1afafb4cbc
+ zh: d80430fa60b01c29
/persistence-release-dsh-v0-1-2-alpha-1/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md
index 3ed00447e4..5dfbc59f5f 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.md
@@ -212,71 +212,71 @@ changes:
-Detected 52 changed roots and 61 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 52 changed roots and 61 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `event:agent-preset/selected.ignorable` | `property-removed` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:agent/inbox/spliced.ignorable` | `property-removed` | `version-bump` |
-| `event:approval/asked.ignorable` | `property-removed` | `version-bump` |
-| `event:approval/decided.ignorable` | `property-removed` | `version-bump` |
-| `event:approval/policy.ignorable` | `property-removed` | `version-bump` |
-| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `same-version` |
-| `event:assistant/chunk.ignorable` | `property-removed` | `version-bump` |
-| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `same-version` |
-| `event:assistant/message.ignorable` | `property-removed` | `version-bump` |
-| `event:command/done.ignorable` | `property-removed` | `version-bump` |
-| `event:command/run.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/end.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/prune.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/start.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `same-version` |
-| `event:compaction/summary.ignorable` | `property-removed` | `version-bump` |
-| `event:feedback/record.ignorable` | `property-removed` | `version-bump` |
-| `event:goal/change.ignorable` | `property-removed` | `version-bump` |
-| `event:hook/invoked.ignorable` | `property-removed` | `version-bump` |
-| `event:hook/result.ignorable` | `property-removed` | `version-bump` |
-| `event:llm/retry.ignorable` | `property-removed` | `version-bump` |
-| `event:llm/retry-started.ignorable` | `property-removed` | `version-bump` |
-| `event:model/selection` | `root-added` | `same-version` |
-| `event:permission/preset.ignorable` | `property-removed` | `version-bump` |
-| `event:plan/mode.ignorable` | `property-removed` | `version-bump` |
-| `event:request/context.ignorable` | `property-removed` | `version-bump` |
-| `event:request/header.data.reason` | `union-variants-changed` | `version-bump` |
-| `event:request/header.data.startsSeries` | `optional-property-added` | `same-version` |
-| `event:request/header.ignorable` | `property-removed` | `version-bump` |
-| `event:sandbox/mode.ignorable` | `property-removed` | `version-bump` |
-| `event:schedule/change.ignorable` | `property-removed` | `version-bump` |
-| `event:session-log-deepseek/delivery-accepted` | `root-added` | `same-version` |
-| `event:session/end-seed.ignorable` | `property-removed` | `version-bump` |
-| `event:session/title.ignorable` | `property-removed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.ignorable` | `property-removed` | `version-bump` |
-| `event:step/end.ignorable` | `property-removed` | `version-bump` |
-| `event:step/start.ignorable` | `property-removed` | `version-bump` |
-| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `same-version` |
-| `event:subagent/descriptor.ignorable` | `property-removed` | `version-bump` |
-| `event:subagent/model-selection-policy` | `root-added` | `same-version` |
-| `event:team/member.ignorable` | `property-removed` | `version-bump` |
-| `event:team/message/delivered.ignorable` | `property-removed` | `version-bump` |
-| `event:team/message/queued.ignorable` | `property-removed` | `version-bump` |
-| `event:team/task.ignorable` | `property-removed` | `version-bump` |
-| `event:todo/write.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/run-end.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/run-start.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/call.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/code-dispatch.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/result.ignorable` | `property-removed` | `version-bump` |
-| `event:turn/end.ignorable` | `property-removed` | `version-bump` |
-| `event:turn/start.ignorable` | `property-removed` | `version-bump` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
-| `event:user/message.ignorable` | `property-removed` | `version-bump` |
-| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `event:agent-preset/selected.ignorable` | `property-removed` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:agent/inbox/spliced.ignorable` | `property-removed` | `review-required` |
+| `event:approval/asked.ignorable` | `property-removed` | `review-required` |
+| `event:approval/decided.ignorable` | `property-removed` | `review-required` |
+| `event:approval/policy.ignorable` | `property-removed` | `review-required` |
+| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `not-required` |
+| `event:assistant/chunk.ignorable` | `property-removed` | `review-required` |
+| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `not-required` |
+| `event:assistant/message.ignorable` | `property-removed` | `review-required` |
+| `event:command/done.ignorable` | `property-removed` | `review-required` |
+| `event:command/run.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/end.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/prune.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/start.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `not-required` |
+| `event:compaction/summary.ignorable` | `property-removed` | `review-required` |
+| `event:feedback/record.ignorable` | `property-removed` | `review-required` |
+| `event:goal/change.ignorable` | `property-removed` | `review-required` |
+| `event:hook/invoked.ignorable` | `property-removed` | `review-required` |
+| `event:hook/result.ignorable` | `property-removed` | `review-required` |
+| `event:llm/retry.ignorable` | `property-removed` | `review-required` |
+| `event:llm/retry-started.ignorable` | `property-removed` | `review-required` |
+| `event:model/selection` | `root-added` | `not-required` |
+| `event:permission/preset.ignorable` | `property-removed` | `review-required` |
+| `event:plan/mode.ignorable` | `property-removed` | `review-required` |
+| `event:request/context.ignorable` | `property-removed` | `review-required` |
+| `event:request/header.data.reason` | `union-variants-changed` | `review-required` |
+| `event:request/header.data.startsSeries` | `optional-property-added` | `not-required` |
+| `event:request/header.ignorable` | `property-removed` | `review-required` |
+| `event:sandbox/mode.ignorable` | `property-removed` | `review-required` |
+| `event:schedule/change.ignorable` | `property-removed` | `review-required` |
+| `event:session-log-deepseek/delivery-accepted` | `root-added` | `not-required` |
+| `event:session/end-seed.ignorable` | `property-removed` | `review-required` |
+| `event:session/title.ignorable` | `property-removed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.ignorable` | `property-removed` | `review-required` |
+| `event:step/end.ignorable` | `property-removed` | `review-required` |
+| `event:step/start.ignorable` | `property-removed` | `review-required` |
+| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `not-required` |
+| `event:subagent/descriptor.ignorable` | `property-removed` | `review-required` |
+| `event:subagent/model-selection-policy` | `root-added` | `not-required` |
+| `event:team/member.ignorable` | `property-removed` | `review-required` |
+| `event:team/message/delivered.ignorable` | `property-removed` | `review-required` |
+| `event:team/message/queued.ignorable` | `property-removed` | `review-required` |
+| `event:team/task.ignorable` | `property-removed` | `review-required` |
+| `event:todo/write.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/run-end.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/run-start.ignorable` | `property-removed` | `review-required` |
+| `event:tool/call.ignorable` | `property-removed` | `review-required` |
+| `event:tool/code-dispatch.ignorable` | `property-removed` | `review-required` |
+| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `review-required` |
+| `event:tool/result.ignorable` | `property-removed` | `review-required` |
+| `event:turn/end.ignorable` | `property-removed` | `review-required` |
+| `event:turn/start.ignorable` | `property-removed` | `review-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
+| `event:user/message.ignorable` | `property-removed` | `review-required` |
+| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md
index 09a6bf3df1..1c5abc026b 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.1.zh.md
@@ -212,71 +212,71 @@ changes:
-检测到 52 个根类型变化、61 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 52 个根类型变化、61 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `event:agent-preset/selected.ignorable` | `property-removed` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:agent/inbox/spliced.ignorable` | `property-removed` | `version-bump` |
-| `event:approval/asked.ignorable` | `property-removed` | `version-bump` |
-| `event:approval/decided.ignorable` | `property-removed` | `version-bump` |
-| `event:approval/policy.ignorable` | `property-removed` | `version-bump` |
-| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `same-version` |
-| `event:assistant/chunk.ignorable` | `property-removed` | `version-bump` |
-| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `same-version` |
-| `event:assistant/message.ignorable` | `property-removed` | `version-bump` |
-| `event:command/done.ignorable` | `property-removed` | `version-bump` |
-| `event:command/run.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/end.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/prune.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/start.ignorable` | `property-removed` | `version-bump` |
-| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `same-version` |
-| `event:compaction/summary.ignorable` | `property-removed` | `version-bump` |
-| `event:feedback/record.ignorable` | `property-removed` | `version-bump` |
-| `event:goal/change.ignorable` | `property-removed` | `version-bump` |
-| `event:hook/invoked.ignorable` | `property-removed` | `version-bump` |
-| `event:hook/result.ignorable` | `property-removed` | `version-bump` |
-| `event:llm/retry.ignorable` | `property-removed` | `version-bump` |
-| `event:llm/retry-started.ignorable` | `property-removed` | `version-bump` |
-| `event:model/selection` | `root-added` | `same-version` |
-| `event:permission/preset.ignorable` | `property-removed` | `version-bump` |
-| `event:plan/mode.ignorable` | `property-removed` | `version-bump` |
-| `event:request/context.ignorable` | `property-removed` | `version-bump` |
-| `event:request/header.data.reason` | `union-variants-changed` | `version-bump` |
-| `event:request/header.data.startsSeries` | `optional-property-added` | `same-version` |
-| `event:request/header.ignorable` | `property-removed` | `version-bump` |
-| `event:sandbox/mode.ignorable` | `property-removed` | `version-bump` |
-| `event:schedule/change.ignorable` | `property-removed` | `version-bump` |
-| `event:session-log-deepseek/delivery-accepted` | `root-added` | `same-version` |
-| `event:session/end-seed.ignorable` | `property-removed` | `version-bump` |
-| `event:session/title.ignorable` | `property-removed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.ignorable` | `property-removed` | `version-bump` |
-| `event:step/end.ignorable` | `property-removed` | `version-bump` |
-| `event:step/start.ignorable` | `property-removed` | `version-bump` |
-| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `same-version` |
-| `event:subagent/descriptor.ignorable` | `property-removed` | `version-bump` |
-| `event:subagent/model-selection-policy` | `root-added` | `same-version` |
-| `event:team/member.ignorable` | `property-removed` | `version-bump` |
-| `event:team/message/delivered.ignorable` | `property-removed` | `version-bump` |
-| `event:team/message/queued.ignorable` | `property-removed` | `version-bump` |
-| `event:team/task.ignorable` | `property-removed` | `version-bump` |
-| `event:todo/write.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/run-end.ignorable` | `property-removed` | `version-bump` |
-| `event:tool-workflow/run-start.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/call.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/code-dispatch.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `version-bump` |
-| `event:tool/result.ignorable` | `property-removed` | `version-bump` |
-| `event:turn/end.ignorable` | `property-removed` | `version-bump` |
-| `event:turn/start.ignorable` | `property-removed` | `version-bump` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
-| `event:user/message.ignorable` | `property-removed` | `version-bump` |
-| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `event:agent-preset/selected.ignorable` | `property-removed` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:agent/inbox/spliced.ignorable` | `property-removed` | `review-required` |
+| `event:approval/asked.ignorable` | `property-removed` | `review-required` |
+| `event:approval/decided.ignorable` | `property-removed` | `review-required` |
+| `event:approval/policy.ignorable` | `property-removed` | `review-required` |
+| `event:assistant/chunk.data.chunk.usage.totalTokens` | `optional-property-added` | `not-required` |
+| `event:assistant/chunk.ignorable` | `property-removed` | `review-required` |
+| `event:assistant/message.data.usage.totalTokens` | `optional-property-added` | `not-required` |
+| `event:assistant/message.ignorable` | `property-removed` | `review-required` |
+| `event:command/done.ignorable` | `property-removed` | `review-required` |
+| `event:command/run.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/end.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/prune.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/start.ignorable` | `property-removed` | `review-required` |
+| `event:compaction/summary.data.usage.totalTokens` | `optional-property-added` | `not-required` |
+| `event:compaction/summary.ignorable` | `property-removed` | `review-required` |
+| `event:feedback/record.ignorable` | `property-removed` | `review-required` |
+| `event:goal/change.ignorable` | `property-removed` | `review-required` |
+| `event:hook/invoked.ignorable` | `property-removed` | `review-required` |
+| `event:hook/result.ignorable` | `property-removed` | `review-required` |
+| `event:llm/retry.ignorable` | `property-removed` | `review-required` |
+| `event:llm/retry-started.ignorable` | `property-removed` | `review-required` |
+| `event:model/selection` | `root-added` | `not-required` |
+| `event:permission/preset.ignorable` | `property-removed` | `review-required` |
+| `event:plan/mode.ignorable` | `property-removed` | `review-required` |
+| `event:request/context.ignorable` | `property-removed` | `review-required` |
+| `event:request/header.data.reason` | `union-variants-changed` | `review-required` |
+| `event:request/header.data.startsSeries` | `optional-property-added` | `not-required` |
+| `event:request/header.ignorable` | `property-removed` | `review-required` |
+| `event:sandbox/mode.ignorable` | `property-removed` | `review-required` |
+| `event:schedule/change.ignorable` | `property-removed` | `review-required` |
+| `event:session-log-deepseek/delivery-accepted` | `root-added` | `not-required` |
+| `event:session/end-seed.ignorable` | `property-removed` | `review-required` |
+| `event:session/title.ignorable` | `property-removed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.ignorable` | `property-removed` | `review-required` |
+| `event:step/end.ignorable` | `property-removed` | `review-required` |
+| `event:step/start.ignorable` | `property-removed` | `review-required` |
+| `event:subagent/descriptor.data.agentReasoningEffort` | `optional-property-added` | `not-required` |
+| `event:subagent/descriptor.ignorable` | `property-removed` | `review-required` |
+| `event:subagent/model-selection-policy` | `root-added` | `not-required` |
+| `event:team/member.ignorable` | `property-removed` | `review-required` |
+| `event:team/message/delivered.ignorable` | `property-removed` | `review-required` |
+| `event:team/message/queued.ignorable` | `property-removed` | `review-required` |
+| `event:team/task.ignorable` | `property-removed` | `review-required` |
+| `event:todo/write.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/agent-end.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/agent-start.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/run-end.ignorable` | `property-removed` | `review-required` |
+| `event:tool-workflow/run-start.ignorable` | `property-removed` | `review-required` |
+| `event:tool/call.ignorable` | `property-removed` | `review-required` |
+| `event:tool/code-dispatch.ignorable` | `property-removed` | `review-required` |
+| `event:tool/code-dispatch-start.ignorable` | `property-removed` | `review-required` |
+| `event:tool/result.ignorable` | `property-removed` | `review-required` |
+| `event:turn/end.ignorable` | `property-removed` | `review-required` |
+| `event:turn/start.ignorable` | `property-removed` | `review-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
+| `event:user/message.ignorable` | `property-removed` | `review-required` |
+| `event:web/deepseek-search-llm-request.ignorable` | `property-removed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml
index 7bc9636a7a..da6fdae068 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-2-alpha-2/structural-changes:
- en: 28cb9fbbfbc761af
- zh: f3345b2dc10920b1
+ en: aeca9db54d4a5ea4
+ zh: fa2e354c039b8381
/persistence-release-dsh-v0-1-2-alpha-2/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md
index 8d4830f4b4..cfd6eb4225 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.md
@@ -212,62 +212,62 @@ changes:
-Detected 52 changed roots and 52 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 52 changed roots and 52 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` |
-| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/done.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/run.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/prune.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/summary.ignorable` | `optional-property-added` | `version-bump` |
-| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` |
-| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` |
-| `event:model/selection.ignorable` | `optional-property-added` | `version-bump` |
-| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` |
-| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/context.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/header.ignorable` | `optional-property-added` | `version-bump` |
-| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:schedule/change.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` |
-| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/member.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/message/delivered.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/message/queued.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/task.ignorable` | `optional-property-added` | `version-bump` |
-| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:user/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` |
+| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/done.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/run.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/prune.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/summary.ignorable` | `optional-property-added` | `review-required` |
+| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` |
+| `event:goal/change.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` |
+| `event:model/selection.ignorable` | `optional-property-added` | `review-required` |
+| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` |
+| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/context.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/header.ignorable` | `optional-property-added` | `review-required` |
+| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:schedule/change.ignorable` | `optional-property-added` | `review-required` |
+| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` |
+| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/member.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/message/delivered.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/message/queued.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/task.ignorable` | `optional-property-added` | `review-required` |
+| `event:todo/write.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/call.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:user/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md
index b68ea09841..4d6b39343c 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.2.zh.md
@@ -212,62 +212,62 @@ changes:
-检测到 52 个根类型变化、52 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 52 个根类型变化、52 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `event:agent-preset/selected.ignorable` | `optional-property-added` | `version-bump` |
-| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/asked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/decided.ignorable` | `optional-property-added` | `version-bump` |
-| `event:approval/policy.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/chunk.ignorable` | `optional-property-added` | `version-bump` |
-| `event:assistant/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/done.ignorable` | `optional-property-added` | `version-bump` |
-| `event:command/run.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/prune.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:compaction/summary.ignorable` | `optional-property-added` | `version-bump` |
-| `event:feedback/record.ignorable` | `optional-property-added` | `version-bump` |
-| `event:goal/change.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/invoked.ignorable` | `optional-property-added` | `version-bump` |
-| `event:hook/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry.ignorable` | `optional-property-added` | `version-bump` |
-| `event:llm/retry-started.ignorable` | `optional-property-added` | `version-bump` |
-| `event:model/selection.ignorable` | `optional-property-added` | `version-bump` |
-| `event:permission/preset.ignorable` | `optional-property-added` | `version-bump` |
-| `event:plan/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/context.ignorable` | `optional-property-added` | `version-bump` |
-| `event:request/header.ignorable` | `optional-property-added` | `version-bump` |
-| `event:sandbox/mode.ignorable` | `optional-property-added` | `version-bump` |
-| `event:schedule/change.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/end-seed.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title.ignorable` | `optional-property-added` | `version-bump` |
-| `event:session/title-llm-request.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:step/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:subagent/descriptor.ignorable` | `optional-property-added` | `version-bump` |
-| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/member.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/message/delivered.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/message/queued.ignorable` | `optional-property-added` | `version-bump` |
-| `event:team/task.ignorable` | `optional-property-added` | `version-bump` |
-| `event:todo/write.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/call.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:tool/result.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/end.ignorable` | `optional-property-added` | `version-bump` |
-| `event:turn/start.ignorable` | `optional-property-added` | `version-bump` |
-| `event:user/message.ignorable` | `optional-property-added` | `version-bump` |
-| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `event:agent-preset/selected.ignorable` | `optional-property-added` | `review-required` |
+| `event:agent/inbox/spliced.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/asked.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/decided.ignorable` | `optional-property-added` | `review-required` |
+| `event:approval/policy.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/chunk.ignorable` | `optional-property-added` | `review-required` |
+| `event:assistant/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/done.ignorable` | `optional-property-added` | `review-required` |
+| `event:command/run.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/prune.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:compaction/summary.ignorable` | `optional-property-added` | `review-required` |
+| `event:feedback/record.ignorable` | `optional-property-added` | `review-required` |
+| `event:goal/change.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/invoked.ignorable` | `optional-property-added` | `review-required` |
+| `event:hook/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry.ignorable` | `optional-property-added` | `review-required` |
+| `event:llm/retry-started.ignorable` | `optional-property-added` | `review-required` |
+| `event:model/selection.ignorable` | `optional-property-added` | `review-required` |
+| `event:permission/preset.ignorable` | `optional-property-added` | `review-required` |
+| `event:plan/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/context.ignorable` | `optional-property-added` | `review-required` |
+| `event:request/header.ignorable` | `optional-property-added` | `review-required` |
+| `event:sandbox/mode.ignorable` | `optional-property-added` | `review-required` |
+| `event:schedule/change.ignorable` | `optional-property-added` | `review-required` |
+| `event:session-log-deepseek/delivery-accepted.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/end-seed.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title.ignorable` | `optional-property-added` | `review-required` |
+| `event:session/title-llm-request.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:step/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:subagent/descriptor.ignorable` | `optional-property-added` | `review-required` |
+| `event:subagent/model-selection-policy.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/member.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/message/delivered.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/message/queued.ignorable` | `optional-property-added` | `review-required` |
+| `event:team/task.ignorable` | `optional-property-added` | `review-required` |
+| `event:todo/write.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/agent-end.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/agent-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/run-end.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool-workflow/run-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/call.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/code-dispatch-start.ignorable` | `optional-property-added` | `review-required` |
+| `event:tool/result.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/end.ignorable` | `optional-property-added` | `review-required` |
+| `event:turn/start.ignorable` | `optional-property-added` | `review-required` |
+| `event:user/message.ignorable` | `optional-property-added` | `review-required` |
+| `event:web/deepseek-search-llm-request.ignorable` | `optional-property-added` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml
index cea2d1c259..c1f9bddfbf 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-2-alpha-4/structural-changes:
- en: 19dc565f0446afc5
- zh: 16309465db21bd8b
+ en: eec8e831ff143edc
+ zh: 253dcaaf41ecac6f
/persistence-release-dsh-v0-1-2-alpha-4/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md
index 9a6be5072c..d83a5e1bba 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.md
@@ -68,15 +68,15 @@ changes:
-Detected 4 changed roots and 5 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 4 changed roots and 5 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `SessionHeader.seedLength` | `property-removed` | `version-bump` |
-| `SessionHeader.isSeeded` | `required-property-added` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
+| `SessionHeader.seedLength` | `property-removed` | `review-required` |
+| `SessionHeader.isSeeded` | `required-property-added` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md
index a279aebe89..aedf30a137 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.2-alpha.4.zh.md
@@ -68,15 +68,15 @@ changes:
-检测到 4 个根类型变化、5 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 4 个根类型变化、5 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `SessionHeader.seedLength` | `property-removed` | `version-bump` |
-| `SessionHeader.isSeeded` | `required-property-added` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `version-bump` |
-| `event:user/message.data.source` | `union-variants-changed` | `version-bump` |
+| `SessionHeader.seedLength` | `property-removed` | `review-required` |
+| `SessionHeader.isSeeded` | `required-property-added` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source` | `union-variants-changed` | `review-required` |
+| `event:user/message.data.source` | `union-variants-changed` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml
index 31145c4ef4..2e37ebb2c3 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-3-alpha-1/structural-changes:
- en: 492c315af4d250aa
- zh: 17a45e34aeb94791
+ en: 746c20adf88325e1
+ zh: e607c8b6e3dc8a3a
/persistence-release-dsh-v0-1-3-alpha-1/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md
index 571718ea48..ef21066705 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.md
@@ -107,35 +107,35 @@ changes:
-Detected 17 changed roots and 25 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 17 changed roots and 25 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `JsonlHeaderLine.seedLength` | `property-removed` | `version-bump` |
-| `JsonlHeaderLine.isSeeded` | `required-property-added` | `version-bump` |
-| `SessionHeader.version` | `type-changed` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` |
-| `event:assistant/attempt` | `root-added` | `same-version` |
-| `event:assistant/chunk` | `root-removed` | `version-bump` |
-| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `version-bump` |
-| `event:assistant/message.data.stream` | `required-property-added` | `version-bump` |
-| `event:compaction/summary.data` | `union-variants-changed` | `version-bump` |
-| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `same-version` |
-| `event:session/end-seed.data.inherited` | `optional-property-added` | `same-version` |
-| `event:session/end-seed.data` | `index-signature-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` |
-| `event:team/member.data.version` | `type-changed` | `version-bump` |
-| `event:team/message/delivered.data.version` | `type-changed` | `version-bump` |
-| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `version-bump` |
-| `event:team/message/queued.data.message.delivery` | `property-removed` | `version-bump` |
-| `event:team/message/queued.data.version` | `type-changed` | `version-bump` |
-| `event:team/task.data.version` | `type-changed` | `version-bump` |
-| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `version-bump` |
-| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `version-bump` |
-| `event:user/message.data.content[]` | `union-variants-changed` | `version-bump` |
-| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` |
+| `JsonlHeaderLine.seedLength` | `property-removed` | `review-required` |
+| `JsonlHeaderLine.isSeeded` | `required-property-added` | `review-required` |
+| `SessionHeader.version` | `type-changed` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` |
+| `event:assistant/attempt` | `root-added` | `not-required` |
+| `event:assistant/chunk` | `root-removed` | `review-required` |
+| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `review-required` |
+| `event:assistant/message.data.stream` | `required-property-added` | `review-required` |
+| `event:compaction/summary.data` | `union-variants-changed` | `review-required` |
+| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `not-required` |
+| `event:session/end-seed.data.inherited` | `optional-property-added` | `not-required` |
+| `event:session/end-seed.data` | `index-signature-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` |
+| `event:team/member.data.version` | `type-changed` | `review-required` |
+| `event:team/message/delivered.data.version` | `type-changed` | `review-required` |
+| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `review-required` |
+| `event:team/message/queued.data.message.delivery` | `property-removed` | `review-required` |
+| `event:team/message/queued.data.version` | `type-changed` | `review-required` |
+| `event:team/task.data.version` | `type-changed` | `review-required` |
+| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `review-required` |
+| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `review-required` |
+| `event:user/message.data.content[]` | `union-variants-changed` | `review-required` |
+| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md
index 980106910f..af0b35779e 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.1.zh.md
@@ -107,35 +107,35 @@ changes:
-检测到 17 个根类型变化、25 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 17 个根类型变化、25 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `JsonlHeaderLine.seedLength` | `property-removed` | `version-bump` |
-| `JsonlHeaderLine.isSeeded` | `required-property-added` | `version-bump` |
-| `SessionHeader.version` | `type-changed` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `version-bump` |
-| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` |
-| `event:assistant/attempt` | `root-added` | `same-version` |
-| `event:assistant/chunk` | `root-removed` | `version-bump` |
-| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `version-bump` |
-| `event:assistant/message.data.stream` | `required-property-added` | `version-bump` |
-| `event:compaction/summary.data` | `union-variants-changed` | `version-bump` |
-| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `same-version` |
-| `event:session/end-seed.data.inherited` | `optional-property-added` | `same-version` |
-| `event:session/end-seed.data` | `index-signature-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `version-bump` |
-| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` |
-| `event:team/member.data.version` | `type-changed` | `version-bump` |
-| `event:team/message/delivered.data.version` | `type-changed` | `version-bump` |
-| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `version-bump` |
-| `event:team/message/queued.data.message.delivery` | `property-removed` | `version-bump` |
-| `event:team/message/queued.data.version` | `type-changed` | `version-bump` |
-| `event:team/task.data.version` | `type-changed` | `version-bump` |
-| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `version-bump` |
-| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `version-bump` |
-| `event:user/message.data.content[]` | `union-variants-changed` | `version-bump` |
-| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `same-version` |
+| `JsonlHeaderLine.seedLength` | `property-removed` | `review-required` |
+| `JsonlHeaderLine.isSeeded` | `required-property-added` | `review-required` |
+| `SessionHeader.version` | `type-changed` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].content[]` | `union-variants-changed` | `review-required` |
+| `event:agent/inbox/spliced.data.inserted[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` |
+| `event:assistant/attempt` | `root-added` | `not-required` |
+| `event:assistant/chunk` | `root-removed` | `review-required` |
+| `event:assistant/message.data.message.content[]` | `union-variants-changed` | `review-required` |
+| `event:assistant/message.data.stream` | `required-property-added` | `review-required` |
+| `event:compaction/summary.data` | `union-variants-changed` | `review-required` |
+| `event:session-log-deepseek/delivery-accepted.data.sessionFormatVersion` | `optional-property-added` | `not-required` |
+| `event:session/end-seed.data.inherited` | `optional-property-added` | `not-required` |
+| `event:session/end-seed.data` | `index-signature-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].content[]` | `union-variants-changed` | `review-required` |
+| `event:session/title-llm-request.data.messages[].source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` |
+| `event:team/member.data.version` | `type-changed` | `review-required` |
+| `event:team/message/delivered.data.version` | `type-changed` | `review-required` |
+| `event:team/message/queued.data.message.content[]` | `union-variants-changed` | `review-required` |
+| `event:team/message/queued.data.message.delivery` | `property-removed` | `review-required` |
+| `event:team/message/queued.data.version` | `type-changed` | `review-required` |
+| `event:team/task.data.version` | `type-changed` | `review-required` |
+| `event:tool/code-dispatch.data.content[]` | `union-variants-changed` | `review-required` |
+| `event:tool/result.data.message.content[0].content[]` | `union-variants-changed` | `review-required` |
+| `event:user/message.data.content[]` | `union-variants-changed` | `review-required` |
+| `event:user/message.data.source.references[].capturedFormatVersion` | `optional-property-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml
index 934ea2566a..f3f248ee3b 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-3-alpha-2/structural-changes:
- en: be5d82540eb344db
- zh: 8edd0ca270019992
+ en: 4b866a071a7d653e
+ zh: a2ffe1a9ae361e2c
/persistence-release-dsh-v0-1-3-alpha-2/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md
index d6b6a658c3..99407651a3 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.md
@@ -62,12 +62,12 @@ changes:
-Detected 2 changed roots and 2 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 2 changed roots and 2 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `event:feedback/message-delete` | `root-added` | `same-version` |
-| `event:feedback/message-put` | `root-added` | `same-version` |
+| `event:feedback/message-delete` | `root-added` | `not-required` |
+| `event:feedback/message-put` | `root-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md
index 9f6d962138..913f9cfccf 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.3-alpha.2.zh.md
@@ -62,12 +62,12 @@ changes:
-检测到 2 个根类型变化、2 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 2 个根类型变化、2 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `event:feedback/message-delete` | `root-added` | `same-version` |
-| `event:feedback/message-put` | `root-added` | `same-version` |
+| `event:feedback/message-delete` | `root-added` | `not-required` |
+| `event:feedback/message-put` | `root-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml
index 5db80e034e..4896e684f0 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-5-alpha-1/structural-changes:
- en: f65ce6c033426a9d
- zh: 278e167343e54fef
+ en: b5f68991a076ac50
+ zh: 4593e46ada9541e9
/persistence-release-dsh-v0-1-5-alpha-1/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md
index 3dd251e6ca..d7c9dcb531 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.md
@@ -92,35 +92,35 @@ changes:
-Detected 12 changed roots and 25 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 12 changed roots and 25 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `SessionHeader.version` | `type-changed` | `version-bump` |
-| `event:assistant/message.sourceEventSeqs` | `property-removed` | `version-bump` |
-| `event:assistant/message.surfaceOp` | `property-made-required` | `version-bump` |
-| `event:assistant/message.surfaceOp.end` | `property-removed` | `version-bump` |
-| `event:assistant/message.surfaceOp.start` | `property-removed` | `version-bump` |
-| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` |
-| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` |
-| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `same-version` |
-| `event:request/header.data.header.system` | `property-removed` | `version-bump` |
-| `event:system/message` | `root-added` | `version-bump` |
-| `event:tool/code-dispatch` | `root-removed` | `version-bump` |
-| `event:tool/code-dispatch-start` | `root-removed` | `version-bump` |
-| `event:tool/ptc-dispatch` | `root-added` | `same-version` |
-| `event:tool/ptc-dispatch-start` | `root-added` | `same-version` |
-| `event:tool/result.surfaceOp` | `property-made-required` | `version-bump` |
-| `event:tool/result.surfaceOp.end` | `property-removed` | `version-bump` |
-| `event:tool/result.surfaceOp.start` | `property-removed` | `version-bump` |
-| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `version-bump` |
-| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `version-bump` |
-| `event:user/message.surfaceOp` | `property-made-required` | `version-bump` |
-| `event:user/message.surfaceOp.end` | `property-removed` | `version-bump` |
-| `event:user/message.surfaceOp.start` | `property-removed` | `version-bump` |
-| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` |
-| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `SessionHeader.version` | `type-changed` | `review-required` |
+| `event:assistant/message.sourceEventSeqs` | `property-removed` | `review-required` |
+| `event:assistant/message.surfaceOp` | `property-made-required` | `review-required` |
+| `event:assistant/message.surfaceOp.end` | `property-removed` | `review-required` |
+| `event:assistant/message.surfaceOp.start` | `property-removed` | `review-required` |
+| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `review-required` |
+| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `review-required` |
+| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `not-required` |
+| `event:request/header.data.header.system` | `property-removed` | `review-required` |
+| `event:system/message` | `root-added` | `review-required` |
+| `event:tool/code-dispatch` | `root-removed` | `review-required` |
+| `event:tool/code-dispatch-start` | `root-removed` | `review-required` |
+| `event:tool/ptc-dispatch` | `root-added` | `not-required` |
+| `event:tool/ptc-dispatch-start` | `root-added` | `not-required` |
+| `event:tool/result.surfaceOp` | `property-made-required` | `review-required` |
+| `event:tool/result.surfaceOp.end` | `property-removed` | `review-required` |
+| `event:tool/result.surfaceOp.start` | `property-removed` | `review-required` |
+| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `review-required` |
+| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `review-required` |
+| `event:user/message.surfaceOp` | `property-made-required` | `review-required` |
+| `event:user/message.surfaceOp.end` | `property-removed` | `review-required` |
+| `event:user/message.surfaceOp.start` | `property-removed` | `review-required` |
+| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `review-required` |
+| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md
index fb2e241250..1e5af66f0a 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.1.zh.md
@@ -92,35 +92,35 @@ changes:
-检测到 12 个根类型变化、25 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 12 个根类型变化、25 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `SessionEventEnvelope` | `union-variants-changed` | `version-bump` |
-| `SessionHeader.version` | `type-changed` | `version-bump` |
-| `event:assistant/message.sourceEventSeqs` | `property-removed` | `version-bump` |
-| `event:assistant/message.surfaceOp` | `property-made-required` | `version-bump` |
-| `event:assistant/message.surfaceOp.end` | `property-removed` | `version-bump` |
-| `event:assistant/message.surfaceOp.start` | `property-removed` | `version-bump` |
-| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` |
-| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` |
-| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `same-version` |
-| `event:request/header.data.header.system` | `property-removed` | `version-bump` |
-| `event:system/message` | `root-added` | `version-bump` |
-| `event:tool/code-dispatch` | `root-removed` | `version-bump` |
-| `event:tool/code-dispatch-start` | `root-removed` | `version-bump` |
-| `event:tool/ptc-dispatch` | `root-added` | `same-version` |
-| `event:tool/ptc-dispatch-start` | `root-added` | `same-version` |
-| `event:tool/result.surfaceOp` | `property-made-required` | `version-bump` |
-| `event:tool/result.surfaceOp.end` | `property-removed` | `version-bump` |
-| `event:tool/result.surfaceOp.start` | `property-removed` | `version-bump` |
-| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `version-bump` |
-| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `version-bump` |
-| `event:user/message.surfaceOp` | `property-made-required` | `version-bump` |
-| `event:user/message.surfaceOp.end` | `property-removed` | `version-bump` |
-| `event:user/message.surfaceOp.start` | `property-removed` | `version-bump` |
-| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `version-bump` |
-| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `version-bump` |
+| `SessionEventEnvelope` | `union-variants-changed` | `review-required` |
+| `SessionHeader.version` | `type-changed` | `review-required` |
+| `event:assistant/message.sourceEventSeqs` | `property-removed` | `review-required` |
+| `event:assistant/message.surfaceOp` | `property-made-required` | `review-required` |
+| `event:assistant/message.surfaceOp.end` | `property-removed` | `review-required` |
+| `event:assistant/message.surfaceOp.start` | `property-removed` | `review-required` |
+| `event:assistant/message.surfaceOp.endSeq` | `required-property-added` | `review-required` |
+| `event:assistant/message.surfaceOp.startSeq` | `required-property-added` | `review-required` |
+| `event:request/context.data.systemPromptUpdate` | `optional-property-added` | `not-required` |
+| `event:request/header.data.header.system` | `property-removed` | `review-required` |
+| `event:system/message` | `root-added` | `review-required` |
+| `event:tool/code-dispatch` | `root-removed` | `review-required` |
+| `event:tool/code-dispatch-start` | `root-removed` | `review-required` |
+| `event:tool/ptc-dispatch` | `root-added` | `not-required` |
+| `event:tool/ptc-dispatch-start` | `root-added` | `not-required` |
+| `event:tool/result.surfaceOp` | `property-made-required` | `review-required` |
+| `event:tool/result.surfaceOp.end` | `property-removed` | `review-required` |
+| `event:tool/result.surfaceOp.start` | `property-removed` | `review-required` |
+| `event:tool/result.surfaceOp.endSeq` | `required-property-added` | `review-required` |
+| `event:tool/result.surfaceOp.startSeq` | `required-property-added` | `review-required` |
+| `event:user/message.surfaceOp` | `property-made-required` | `review-required` |
+| `event:user/message.surfaceOp.end` | `property-removed` | `review-required` |
+| `event:user/message.surfaceOp.start` | `property-removed` | `review-required` |
+| `event:user/message.surfaceOp.endSeq` | `required-property-added` | `review-required` |
+| `event:user/message.surfaceOp.startSeq` | `required-property-added` | `review-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml
index 6aceb0a20f..03e9c9e64d 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml
+++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.i18n.yaml
@@ -21,8 +21,8 @@
en: af9cea43334ee692
zh: c6c8bd0d6a17718a
/persistence-release-dsh-v0-1-5-alpha-2/structural-changes:
- en: a142ebb2293bce41
- zh: 488e9e2321625d51
+ en: 5a12854a5c81d51e
+ zh: dc924c0138b2e57a
/persistence-release-dsh-v0-1-5-alpha-2/verification:
en: 8ccca317964523b1
zh: 2c8b3e70a3320778
diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md
index 76353e3ab3..b6db727160 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.md
@@ -68,15 +68,15 @@ changes:
-Detected 4 changed roots and 5 structural differences. The minimum below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
+Detected 4 changed roots and 5 structural differences. The review requirement below is calculated using current rules for comparison only; it does not assert historical compliance, migration correctness, or runtime compatibility.
-| Path | Change | Current minimum |
+| Path | Change | Review requirement |
|---|---|---|
-| `event:deliverables/presented` | `root-added` | `same-version` |
-| `event:feedback/message-put.data.item.category` | `optional-property-added` | `same-version` |
-| `event:feedback/record.data.text` | `property-made-optional` | `same-version` |
-| `event:feedback/record.data.category` | `optional-property-added` | `same-version` |
-| `event:subagent/catalog` | `root-added` | `same-version` |
+| `event:deliverables/presented` | `root-added` | `not-required` |
+| `event:feedback/message-put.data.item.category` | `optional-property-added` | `not-required` |
+| `event:feedback/record.data.text` | `property-made-optional` | `not-required` |
+| `event:feedback/record.data.category` | `optional-property-added` | `not-required` |
+| `event:subagent/catalog` | `root-added` | `not-required` |
diff --git a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md
index 676a09c1e6..37e01ce888 100644
--- a/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md
+++ b/docs/persistence-changes/releases/dsh-v0.1.5-alpha.2.zh.md
@@ -68,15 +68,15 @@ changes:
-检测到 4 个根类型变化、5 项结构差异。下表的最低要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
+检测到 4 个根类型变化、5 项结构差异。下表的审查要求按当前规则计算,只用于比较;不表示旧版本曾遵守这些规则,也不证明迁移或运行时兼容性。
-| 路径 | 变化 | 当前最低要求 |
+| 路径 | 变化 | 审查要求 |
|---|---|---|
-| `event:deliverables/presented` | `root-added` | `same-version` |
-| `event:feedback/message-put.data.item.category` | `optional-property-added` | `same-version` |
-| `event:feedback/record.data.text` | `property-made-optional` | `same-version` |
-| `event:feedback/record.data.category` | `optional-property-added` | `same-version` |
-| `event:subagent/catalog` | `root-added` | `same-version` |
+| `event:deliverables/presented` | `root-added` | `not-required` |
+| `event:feedback/message-put.data.item.category` | `optional-property-added` | `not-required` |
+| `event:feedback/record.data.text` | `property-made-optional` | `not-required` |
+| `event:feedback/record.data.category` | `optional-property-added` | `not-required` |
+| `event:subagent/catalog` | `root-added` | `not-required` |
diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json
index e6b73c0fcd..e9046242ff 100644
--- a/docs/persistence-schema.json
+++ b/docs/persistence-schema.json
@@ -22052,7 +22052,7 @@
"packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"
],
"sources": [
- "packages/session/session-title-llm/src/index.ts:34"
+ "packages/session/session-title-llm/src/index.ts:37"
]
},
{
@@ -22152,7 +22152,7 @@
"digest": "0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1",
"names": [],
"sources": [
- "packages/experimental/agent-team/src/types.ts:238"
+ "packages/experimental/agent-team/src/types.ts:236"
]
},
{
@@ -22532,7 +22532,7 @@
"packages/experimental/agent-team/src/types.ts#TeamMessageSource"
],
"sources": [
- "packages/experimental/agent-team/src/types.ts:137"
+ "packages/experimental/agent-team/src/types.ts:138"
]
},
{
@@ -22707,7 +22707,7 @@
"packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot"
],
"sources": [
- "packages/experimental/agent-team/src/types.ts:128"
+ "packages/experimental/agent-team/src/types.ts:129"
]
},
{
@@ -23137,13 +23137,13 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
- "packages/api/session-controller/src/types.ts:398",
+ "packages/api/session-controller/src/types.ts:403",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:20",
- "packages/experimental/agent-team/src/types.ts:20",
- "packages/experimental/agent-team/src/types.ts:32",
- "packages/experimental/agent-team/src/types.ts:8",
+ "packages/experimental/agent-team/src/types.ts:21",
+ "packages/experimental/agent-team/src/types.ts:33",
+ "packages/experimental/agent-team/src/types.ts:9",
"packages/experimental/webhook/src/brand.ts:12",
"packages/experimental/webhook/src/brand.ts:6",
"packages/experimental/webhook/src/brand.ts:9",
@@ -23234,7 +23234,7 @@
"packages/experimental/agent-team/src/types.ts#TeamTaskSnapshot"
],
"sources": [
- "packages/experimental/agent-team/src/types.ts:74"
+ "packages/experimental/agent-team/src/types.ts:75"
]
},
{
@@ -23507,7 +23507,7 @@
"packages/experimental/agent-team/src/types.ts#TeamMemberSnapshot"
],
"sources": [
- "packages/experimental/agent-team/src/types.ts:47"
+ "packages/experimental/agent-team/src/types.ts:48"
]
},
{
@@ -23582,7 +23582,7 @@
"digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7",
"names": [],
"sources": [
- "packages/api/session-controller/src/types.ts:403"
+ "packages/api/session-controller/src/types.ts:408"
]
},
{
@@ -24417,7 +24417,7 @@
"digest": "a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c",
"names": [],
"sources": [
- "packages/experimental/agent-team/src/types.ts:234"
+ "packages/experimental/agent-team/src/types.ts:232"
]
},
{
@@ -24659,7 +24659,7 @@
"digest": "b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1",
"names": [],
"sources": [
- "packages/experimental/agent-team/src/types.ts:232"
+ "packages/experimental/agent-team/src/types.ts:230"
]
},
{
@@ -24688,7 +24688,7 @@
"packages/experimental/agent-team/src/types.ts#TeamTaskStatus"
],
"sources": [
- "packages/experimental/agent-team/src/types.ts:71"
+ "packages/experimental/agent-team/src/types.ts:72"
]
},
{
@@ -25227,7 +25227,7 @@
"packages/experimental/agent-team/src/types.ts#TeamMemberPhase"
],
"sources": [
- "packages/experimental/agent-team/src/types.ts:44"
+ "packages/experimental/agent-team/src/types.ts:45"
]
},
{
@@ -25359,7 +25359,7 @@
"digest": "ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403",
"names": [],
"sources": [
- "packages/experimental/agent-team/src/types.ts:236"
+ "packages/experimental/agent-team/src/types.ts:234"
]
},
{
diff --git a/docs/session-format-status.i18n.yaml b/docs/session-format-status.i18n.yaml
index ef3b64d39c..0f82e86719 100644
--- a/docs/session-format-status.i18n.yaml
+++ b/docs/session-format-status.i18n.yaml
@@ -15,14 +15,14 @@
en: 02d18ba8a66cfb10
zh: 54b753bbcb8f8a9e
/session-format-version-and-release-status/finalization-record:
- en: 15b6134dcb3a09d5
- zh: 4260c82e79baa021
+ en: c84a45a0489804c9
+ zh: 4f4474a678c90ffe
/session-format-version-and-release-status/release-record:
en: 83ce79a241b630d7
zh: 53f3c461ef731f67
/session-format-version-and-release-status/updating-the-record:
- en: 7eaa730a2d163bd6
- zh: 53ed7618537db2f5
+ en: 0d6e8f65604b1394
+ zh: 0c8dd27ad4d37649
/session-format-version-and-release-status/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
diff --git a/docs/session-format-status.md b/docs/session-format-status.md
index 78b6701095..9cfc59b643 100644
--- a/docs/session-format-status.md
+++ b/docs/session-format-status.md
@@ -32,7 +32,7 @@ The [format references](persistence-changes/historical-formats/README.md) docume
latestFinalizedVersion: 4
```
-V4 has an accepted compatibility baseline in the [checkpoint](persistence-changes/finalized/v4.json). Backward-compatible schema changes may remain V4 through new acknowledgement records. Breaking changes require a higher writer version and their own header transition; they cannot reuse the accepted 3→4 transition. Accepted machine records and after schemas remain immutable. [Checkpoint rules](persistence-changes/README.md#compatibility-rules) define the comparison.
+V4 has an accepted compatibility baseline in the [checkpoint](persistence-changes/finalized/v4.json). Schema changes may remain V4 when the [reader-compatibility rules](persistence-changes/README.md#compatibility-rules) are satisfied and the required review is recorded. A version-bump decision requires a higher writer version and its own header transition; it cannot reuse the accepted 3→4 transition. Accepted machine records and after schemas remain immutable.
Finalization does not freeze every future V4 addition and does not assert publication. The release record below retains the independently verified published version. Ordinary comments, aliases, source locations, and implementation fixes preserving the accepted meaning do not change this baseline.
@@ -51,7 +51,7 @@ Evidence: published product tag `dsh-v0.2.0-rc.2`; tagged writer: `packages/core
## Updating the record
-When a structural writer change is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and the evidence tag and tagged writer path in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk.
+When a Session format bump is implemented, update the code constant and adjacent catalog together; do not advance this release record before publication. When a product release first publishes a higher Session format, confirm publication and its tagged writer, then advance this record and the evidence tag and tagged writer path in the same bilingual update. Later product releases carrying the same format do not require changing the record. Never lower it on the development trunk.
The [documentation-standard test](../scripts/doc-standard.spec.ts) checks record structure, bilingual equality, evidence-tag and writer-path consistency, and that the documented release does not exceed the checkout writer. This keyless check does not query GitHub or prove that the record is up to date; publication verification remains part of the release update.
diff --git a/docs/session-format-status.zh.md b/docs/session-format-status.zh.md
index 141917a63c..0317995c67 100644
--- a/docs/session-format-status.zh.md
+++ b/docs/session-format-status.zh.md
@@ -32,7 +32,7 @@
latestFinalizedVersion: 4
```
-V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized/v4.json)中。向后兼容的 schema 变更可以通过新的确认记录保留 V4。破坏性变更要求更高的写入器版本及自身的头版本转换,不能复用已接受的 3→4 转换。已接受的机器记录与变更后 schema 保持不可变。[检查点规则](persistence-changes/README.zh.md#compatibility-rules)规定比较方法。
+V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized/v4.json)中。满足[读取器兼容性规则](persistence-changes/README.zh.md#compatibility-rules)并记录所需评审时,schema 变更可以保留 V4。升版本决策要求更高的写入器版本及自身的头版本转换,不能复用已接受的 3→4 转换。已接受的机器记录与变更后 schema 保持不可变。
定稿不冻结 V4 之后的每项新增,也不表示已发布。下方发布记录保留独立验证的已发布版本。普通注释、别名、源码位置,以及保留已接受含义的实现修复,不改变该基线。
@@ -51,7 +51,7 @@ evidenceTag: dsh-v0.2.0-rc.2
## 更新记录
-实现结构性写入器变更时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与证据标签及该标签的写入器路径。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。
+实现 Session 格式升版本时,一起更新代码常量与相邻迁移目录;不要在产品发布前推进此发布记录。当产品首次发布更高的 Session 格式时,确认发布事实及对应标签的写入器,然后在同一次双语更新中推进本记录与证据标签及该标签的写入器路径。后续携带相同格式的产品发布无需改变此记录。开发主干上的记录绝不降低。
[文档标准测试](../scripts/doc-standard.spec.ts)检查记录结构、双语一致性、证据标签及写入器路径一致性,以及文档中的已发布版本不高于工作区写入器。这个无密钥检查不会查询 GitHub,也不能证明记录是最新的;核实发布事实仍属于发布更新的一部分。
diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml
index 76288201ad..ec9d159bbb 100644
--- a/docs/subsystems/README.i18n.yaml
+++ b/docs/subsystems/README.i18n.yaml
@@ -3,5 +3,5 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
/subsystems:
- en: 34b6bc8c15b8009b
- zh: 7a07e5c32b3feca1
+ en: 1d4818dbb07391a7
+ zh: f1e27ce66c181295
diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md
index 0960b21527..c2a47fb70b 100644
--- a/docs/subsystems/README.md
+++ b/docs/subsystems/README.md
@@ -50,7 +50,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
| [voice-input.md](voice-input.md) | experimental named recognizers, transient audio and revision-guarded draft insertion |
| [translation.md](translation.md) | experimental anonymous text translation and explicit provider requests |
-| [agent-team.md](agent-team.md) | Agent Teams: implicit Lead identity, named continuable teammates, durable peer mailbox, and shared task DAG |
+| [agent-team.md](agent-team.md) | Agent Teams: implicit Lead identity, named continuable teammates, direct peer inbox delivery, and shared task DAG |
| [claude-code-mods.md](claude-code-mods.md) | Claude Code mods through the experimental bridge: every event, `$` member, chain rule, drawing feature, and example mod that differs from Claude Code |
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md
index e5f33a3060..a1380713bc 100644
--- a/docs/subsystems/README.zh.md
+++ b/docs/subsystems/README.zh.md
@@ -50,7 +50,7 @@
| [subagent.md](subagent.zh.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 |
| [voice-input.md](voice-input.zh.md) | 实验性具名语音识别、临时音频和受版本保护的草稿插入 |
| [translation.md](translation.zh.md) | 实验性免登录文本翻译与显式 Provider 请求 |
-| [agent-team.md](agent-team.zh.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、持久 peer mailbox 与共享任务 DAG |
+| [agent-team.md](agent-team.zh.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、直接 peer inbox 投递 与共享任务 DAG |
| [claude-code-mods.md](claude-code-mods.zh.md) | 经实验性桥接运行的 Claude Code 模组:与 Claude Code 不同的每个事件、`$` 成员、链规则、绘制特性与示例模组 |
| [web.md](web.zh.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` |
| [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` |
diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml
index edb0d57235..a4c599562f 100644
--- a/docs/subsystems/agent-team.i18n.yaml
+++ b/docs/subsystems/agent-team.i18n.yaml
@@ -6,11 +6,11 @@
en: f571e223220bb616
zh: dd7e37fdf359eeae
/agent-teams/identity-and-roster:
- en: c6e6d2add95df5e8
- zh: 36c0b25c3f4eb80b
-/agent-teams/durable-mailbox:
- en: 75fb392a6ccfbdf9
- zh: f80678b84bd1ea47
+ en: df72d9409b89714d
+ zh: 982934dd18864ca2
+/agent-teams/messages-and-historical-mailbox:
+ en: 2aed7dd369f47747
+ zh: 4f080e50c584ad37
/agent-teams/shared-task-dag:
en: 4515005d07e4d2a3
zh: 26811b31f69009d6
@@ -18,5 +18,5 @@
en: 6af99eff8036597a
zh: f172f994e01f12c1
/agent-teams/replay:
- en: 4adfd7bad5c0e302
- zh: 2d71ba065f44f2a1
+ en: fe32f71c6d799ebc
+ zh: aa2b6ccddba8cfd5
diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md
index 6cb503d130..c5c6a0f775 100644
--- a/docs/subsystems/agent-team.md
+++ b/docs/subsystems/agent-team.md
@@ -6,7 +6,7 @@ Types shared by the experimental implicit-root Team domain, model tools, and hos
## Identity and roster
-`TeamId` is the root `SessionId` under a distinct [brand](core.md#branded-ids). `TeamTaskId` is Team-local and monotonically allocated as `task-`; `TeamMessageId` is globally random. A teammate's Session id remains its persistent identity, while `name` is an immutable model/UI label.
+`TeamId` is the root `SessionId` under a distinct [brand](core.md#branded-ids). `TeamTaskId` is allocated as `task-`. New sends return the target inbox `MessageId`; `TeamMessageId` is retained only for historical mailbox records. Teammate names are immutable labels.
```ts type-equiv
/** Whole durable value written on every teammate lifecycle change. */
@@ -23,12 +23,12 @@ interface TeamMemberSnapshot {
Every member starts in `provisioning` and reaches exactly one terminal roster phase, `active` or `failed`. Roster `running`/`inactive` status is derived separately and never rewrites this record.
-## Durable mailbox
+## Messages and historical mailbox
-The Lead Session first stores the complete queued message. A target receipt is acknowledged only after its pending inbox item or recorded user message is durable, leaving queued-minus-delivered as the recovery mailbox.
+New sends return inbox acceptance or reject. The target stores the existing `agent-message` source with the real sender Session id and a sender-name content prefix. Team has no new-message outbox or resend deduplication. The [package contract](../../packages/experimental/agent-team/README.md#understand-the-implementation) defines persistence. The following mailbox types remain readable so historical Lead logs still replay; Team does not deliver their pending records.
```ts type-equiv
-/** One peer message retained until its target Session records it. */
+/** One peer message recorded by the historical Team mailbox. */
interface TeamMessageSnapshot {
readonly id: TeamMessageId
readonly senderId: SessionId
@@ -40,10 +40,10 @@ interface TeamMessageSnapshot {
Every message attempts Steer delivery. A running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Scheduling is not stored in the durable record because callers cannot select another mode.
-The target Session keeps message identity and sender attribution on both the pending inbox item and the eventual user message. Folding that source across inbox and history is the target-side de-duplication key; the model-visible framing repeats the id and sender.
+Historical `team-message` sources retain the old message id and sender attribution; the client uses them for the message title and icon.
```ts type-equiv
-/** Source retained by the target Session for durable mailbox de-duplication. */
+/** Source recorded by a target Session for one historical Team mailbox delivery. */
interface TeamMessageSource {
readonly kind: 'team-message'
readonly teamId: TeamId
@@ -122,7 +122,7 @@ interface TeamProjection {
## Replay
-The `agentTeam` Session projection replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior.
+The `agentTeam` Session projection replays one root Session into the roster, task board, and historical queued-minus-delivered mailbox. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail is not exposed to callers. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior.
@@ -162,10 +162,10 @@ listMembers(agent: Agent): TeamMemberView[]
async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise
/**
- * Queue one durable peer message, then attempt immediate delivery.
+ * Steer one peer message into the target inbox or reject the attempt.
* @param caller - exact live sending Team member.
- * @param request - target name, content, and pre-queue cancellation.
- * @returns durable message identity and immediate-delivery observation.
+ * @param request - target name, content, and cancellation before acceptance.
+ * @returns accepted inbox identity; acceptance follows normal Agent persistence and does not await model processing.
*/
async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise
diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md
index 083b9fafb6..2e3c0b040e 100644
--- a/docs/subsystems/agent-team.zh.md
+++ b/docs/subsystems/agent-team.zh.md
@@ -6,7 +6,7 @@
## 身份与 roster
-`TeamId` 是具有独立[品牌](core.zh.md#branded-ids)的 Root `SessionId`。`TeamTaskId` 在 Team 内按 `task-` 单调分配;`TeamMessageId` 是全局随机值。teammate 的 Session id 始终是持久身份,而 `name` 是不可变的模型/UI 标签。
+`TeamId` 是使用独立[品牌类型](core.zh.md#branded-ids)的 root `SessionId`。`TeamTaskId` 按 `task-` 分配。新发送返回目标 inbox 的 `MessageId`;`TeamMessageId` 仅供历史 mailbox 记录保留。teammate 名称是不可变标签。
```ts type-equiv
/** Whole durable value written on every teammate lifecycle change. */
@@ -23,12 +23,12 @@ interface TeamMemberSnapshot {
每个 member 都从 `provisioning` 开始,并且只到达一个终态 roster phase:`active` 或 `failed`。roster 的 `running`/`inactive` 状态单独派生,绝不会重写该记录。
-## 持久 mailbox
+## 消息与历史 mailbox
-Lead Session 首先存储完整 queued message。只有 target 的 pending inbox 条目或已记录用户消息完成持久化,才会写入独立 acknowledgement event,queued-minus-delivered 因而构成恢复 mailbox。
+新发送返回 inbox 接收结果或拒绝。目标保存现有 `agent-message` source,包含真实发送者 Session id 和发送者名称正文前缀。Team 不为新消息提供 outbox 或重发去重。[包约定](../../packages/experimental/agent-team/README.zh.md#understand-the-implementation)定义持久化。以下 mailbox 类型继续可读,以便历史 Lead 日志仍能回放;Team 不投递其中的 pending 记录。
```ts type-equiv
-/** One peer message retained until its target Session records it. */
+/** One peer message recorded by the historical Team mailbox. */
interface TeamMessageSnapshot {
readonly id: TeamMessageId
readonly senderId: SessionId
@@ -40,10 +40,10 @@ interface TeamMessageSnapshot {
每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。
-target Session 会在 pending inbox 条目和最终用户消息上保留消息身份与发送者归因。跨 inbox 与历史折叠该 source 构成 target 侧去重键;模型可见的 framing 会重复 id 和发送者。
+历史 `team-message` source 保留旧消息 id 与发送者归属;客户端用它们展示消息标题和图标。
```ts type-equiv
-/** Source retained by the target Session for durable mailbox de-duplication. */
+/** Source recorded by a target Session for one historical Team mailbox delivery. */
interface TeamMessageSource {
readonly kind: 'team-message'
readonly teamId: TeamId
@@ -122,7 +122,7 @@ interface TeamProjection {
## 回放
-`agentTeam` Session 投影把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。
+`agentTeam` Session 投影把一个 Root Session 回放成roster、任务板与历史 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件不对调用方公开。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。
@@ -162,10 +162,10 @@ listMembers(agent: Agent): TeamMemberView[]
async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise
/**
- * Queue one durable peer message, then attempt immediate delivery.
+ * Steer one peer message into the target inbox or reject the attempt.
* @param caller - exact live sending Team member.
- * @param request - target name, content, and pre-queue cancellation.
- * @returns durable message identity and immediate-delivery observation.
+ * @param request - target name, content, and cancellation before acceptance.
+ * @returns accepted inbox identity; acceptance follows normal Agent persistence and does not await model processing.
*/
async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise
diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml
index 52d584bd1f..5d10d9774d 100644
--- a/docs/subsystems/boot.i18n.yaml
+++ b/docs/subsystems/boot.i18n.yaml
@@ -6,5 +6,5 @@
en: 962b4398c9052959
zh: 14471c340a9f6e60
/profile-management/management-records:
- en: a10d3f358e9b7c8e
- zh: 28b0f44589a5020d
+ en: a1412da0219c2a09
+ zh: b6869fd548f4c693
diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md
index be79a823a3..049049957a 100644
--- a/docs/subsystems/boot.md
+++ b/docs/subsystems/boot.md
@@ -6,8 +6,6 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p
## Management records
-`ProfilePatch` adds an optional literal `preset` row id to native Include patch fields; `id` then targets a child in that preset. [App-boot](../../packages/boot/app-boot/README.md#profiles) owns compilation, ordering, validation, and path resolution.
-
`PluginEntryId` identifies one Loader entry; callers obtain it from `listPlugins` rather than constructing a patch id.
`PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`.
@@ -32,7 +30,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.configEditor` — `ConfigEditor`
-Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.
+Persist complete raw configs and apply them through the normal Loader path.
```ts cordis-catalog
/** Addressable profile rows; nested Includes have independent configuration ownership.
@@ -116,7 +114,7 @@ Manage profile files and apply their declared reload lifecycle.
* @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional
* display metadata, activation selections, whether the installation offers the bundle, and removal availability.
*/
-@Remote async listBundles(): Promise
+@Remote listBundles(): Promise
/** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
* @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now.
diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md
index 93024b965a..b2b800f3c9 100644
--- a/docs/subsystems/boot.zh.md
+++ b/docs/subsystems/boot.zh.md
@@ -6,8 +6,6 @@
## 管理记录
-`ProfilePatch` 在原生 Include patch 字段上增加可选的字面量 `preset` 条目 id;此时 `id` 指向该 preset 内的子条目。[App-boot](../../packages/boot/app-boot/README.zh.md#profiles) 定义编译、顺序、校验与路径解析规则。
-
`PluginEntryId` 标识一个 Loader 条目;调用方从 `listPlugins` 获取,不自行拼接 patch id。
`PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。
@@ -32,7 +30,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.configEditor` — `ConfigEditor`
-Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader.
+Persist complete raw configs and apply them through the normal Loader path.
```ts cordis-catalog
/** Addressable profile rows; nested Includes have independent configuration ownership.
@@ -116,7 +114,7 @@ Manage profile files and apply their declared reload lifecycle.
* @returns Package versions, manifest descriptions, the installable spec of profile dependencies, rows, optional
* display metadata, activation selections, whether the installation offers the bundle, and removal availability.
*/
-@Remote async listBundles(): Promise
+@Remote listBundles(): Promise
/** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
* @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now.
diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md
index 665df11563..c6c02c6c42 100644
--- a/docs/subsystems/core.md
+++ b/docs/subsystems/core.md
@@ -497,12 +497,12 @@ async register(definition: PresetDefinition): Promise<() => Promise>
/** Inspect retained revisions, or the exact revision an Agent joined.
* @param ctx - optional Agent context; omission includes all retained revisions.
- * @returns detached definition and module row identities, resolution bases, and isolation diagnostics; no match returns an empty list.
+ * @returns detached module references and isolation diagnostics; no match returns an empty list.
*/
inspectCompositions(ctx?: Context): AgentPresetInspection[]
/** Read every declared preset, including activation failures.
- * @returns Display metadata, declaring Loader row identities, and loading diagnostics.
+ * @returns Display metadata and loading diagnostics.
*/
async list(): Promise
diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md
index 44ee6d3605..582a817f1f 100644
--- a/docs/subsystems/core.zh.md
+++ b/docs/subsystems/core.zh.md
@@ -507,12 +507,12 @@ async register(definition: PresetDefinition): Promise<() => Promise>
/** Inspect retained revisions, or the exact revision an Agent joined.
* @param ctx - optional Agent context; omission includes all retained revisions.
- * @returns detached definition and module row identities, resolution bases, and isolation diagnostics; no match returns an empty list.
+ * @returns detached module references and isolation diagnostics; no match returns an empty list.
*/
inspectCompositions(ctx?: Context): AgentPresetInspection[]
/** Read every declared preset, including activation failures.
- * @returns Display metadata, declaring Loader row identities, and loading diagnostics.
+ * @returns Display metadata and loading diagnostics.
*/
async list(): Promise
diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml
index 8491f61b6d..c5426ddc70 100644
--- a/docs/subsystems/llm-streaming.i18n.yaml
+++ b/docs/subsystems/llm-streaming.i18n.yaml
@@ -42,8 +42,8 @@
en: f2e6932e211f179b
zh: 75112586a6f3f4aa
/llm-streaming/official-deepseek-request-extensions:
- en: b19f291f094157ed
- zh: 682f13a833e8bb52
+ en: 42bc50394b070e96
+ zh: 007caa743ec3c790
/llm-streaming/service-and-provider-contracts:
en: 8dc79d4cc82824c2
zh: 3ebabd3a117ad717
diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md
index f87c1e9d45..4936ba4ae8 100644
--- a/docs/subsystems/llm-streaming.md
+++ b/docs/subsystems/llm-streaming.md
@@ -796,7 +796,7 @@ interface LlmCallConfigAdapterDefaults {
## Official DeepSeek request extensions
-`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. Preparation, collision, and acceptance failures use `REQUEST_EXTENSION` and fail the model request. A merged body that fails to serialize is sent without extension fields; acceptance is skipped and the provider plugin logs the omitted field names.
+`ctx.deepseekLlmApiExtensions` is the provider-specific registry for additive top-level fields on `deepseek-official` requests. Contributor plugins use `register(field, provider)` to claim one field; the adapter calls `prepare(request)` after serializing its base body and merges the returned fields before HTTP. The prepared `accept()` transaction runs after 2xx, so a contributor can commit delivery state without treating a transport or provider rejection as acceptance. A contributor whose preparation throws is logged and omitted from that request, and an acceptance failure is logged without failing the request; a base-field collision uses `REQUEST_EXTENSION` and fails the model request. A merged body that fails to serialize is sent without extension fields; acceptance is skipped and the provider plugin logs the omitted field names.
The [wire reference](../deepseek-llm-api-wire-extensions.md) defines the exact request headers, extension transaction, field versions, and receiver obligations. The shipped composition registers [`dsh_session_log`](../../packages/session/session-log-deepseek/README.md) as a lossless incremental canonical-log suffix and [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.md) as the complete active Loader-backed package set. These fields remain outside model messages and are absent from the pi-ai adapter path.
@@ -931,8 +931,9 @@ register( field: K, provider: DeepSe
/**
* Prepare every currently registered field from one immutable base request.
- * Preparation failures reject before HTTP dispatch. Field values are cloned and frozen;
- * providers retain no mutable alias to the outgoing request.
+ * A provider whose preparation throws, or whose value cannot be cloned, is omitted
+ * from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned
+ * and frozen; providers retain no mutable alias to the outgoing request.
* @param request - exact serialized request facts before extension fields.
* @returns detached fields and their idempotent joint acceptance transaction.
*/
diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md
index 2f3c90e096..47d7c0df31 100644
--- a/docs/subsystems/llm-streaming.zh.md
+++ b/docs/subsystems/llm-streaming.zh.md
@@ -802,7 +802,7 @@ interface LlmCallConfigAdapterDefaults {
## DeepSeek 官方请求扩展
-`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。准备、冲突与接受失败会使用 `REQUEST_EXTENSION`,并使模型请求失败。合并后的正文无法序列化时,请求不带扩展字段发出,跳过接受,并由提供方插件记录被省略的字段名。
+`ctx.deepseekLlmApiExtensions` 是用于向 `deepseek-official` 请求添加顶层字段的提供方特定注册表。贡献插件通过 `register(field, provider)` 认领一个字段;适配器在序列化基础正文后调用 `prepare(request)`,并在 HTTP 前合并返回字段。已准备的 `accept()` 事务会在 2xx 后运行,因此贡献方可以提交交付状态,而不会把传输失败或提供方拒绝当作接受。贡献方准备时抛错会被记录并在该次请求中省略,接受失败只记录告警而不使请求失败;与基础字段冲突时使用 `REQUEST_EXTENSION` 并使模型请求失败。合并后的正文无法序列化时,请求不带扩展字段发出,跳过接受,并由提供方插件记录被省略的字段名。
[协议参考](../deepseek-llm-api-wire-extensions.zh.md)定义确切的请求标头、扩展事务、字段版本和接收方义务。随附组合会将 [`dsh_session_log`](../../packages/session/session-log-deepseek/README.zh.md) 注册为无损增量权威日志后缀,并将 [`dsh_plugin_packages`](../../packages/llm/plugin-package-inventory-deepseek/README.zh.md) 注册为完整存活 Loader 包集合。这些字段仍位于模型消息之外,也不会进入 pi-ai 适配器路径。
@@ -937,8 +937,9 @@ register( field: K, provider: DeepSe
/**
* Prepare every currently registered field from one immutable base request.
- * Preparation failures reject before HTTP dispatch. Field values are cloned and frozen;
- * providers retain no mutable alias to the outgoing request.
+ * A provider whose preparation throws, or whose value cannot be cloned, is omitted
+ * from this request; the first such failure per field is logged. Only cancellation rejects. Field values are cloned
+ * and frozen; providers retain no mutable alias to the outgoing request.
* @param request - exact serialized request facts before extension fields.
* @returns detached fields and their idempotent joint acceptance transaction.
*/
diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md
index 7c6b47deb1..3c0c2586a7 100644
--- a/docs/subsystems/persistence.md
+++ b/docs/subsystems/persistence.md
@@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'>
interface SessionPersistenceSnapshot {
/** Detached metadata for one stored session. */
readonly header: SessionHeader
+ /** Header-only format classification; absent without versioned artifact metadata. */
+ readonly formatStatus?: 'current' | 'migration-required'
/** Opaque change token; see {@link SessionPersistence.stat}. */
readonly revision: SessionPersistenceRevision
/** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */
diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md
index 2883db7e22..dba0685409 100644
--- a/docs/subsystems/persistence.zh.md
+++ b/docs/subsystems/persistence.zh.md
@@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'>
interface SessionPersistenceSnapshot {
/** Detached metadata for one stored session. */
readonly header: SessionHeader
+ /** Header-only format classification; absent without versioned artifact metadata. */
+ readonly formatStatus?: 'current' | 'migration-required'
/** Opaque change token; see {@link SessionPersistence.stat}. */
readonly revision: SessionPersistenceRevision
/** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */
diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml
index 6ec7b89a19..e5ce0f0902 100644
--- a/docs/subsystems/plan.i18n.yaml
+++ b/docs/subsystems/plan.i18n.yaml
@@ -15,8 +15,8 @@
en: ee04468005fae8e1
zh: 1347d554102951f2
/plan-mode/the-exit-tool-and-the-plan-command:
- en: ecf7fe520658b10f
- zh: 9d6d2c4729e9a47e
+ en: b5bbc440552d7ade
+ zh: 17fb1cfed1bc48ef
/plan-mode/the-service:
en: 07c3eec12e0dc75c
zh: 183d73f6672e2c5a
diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md
index 1c29a87851..beba872f61 100644
--- a/docs/subsystems/plan.md
+++ b/docs/subsystems/plan.md
@@ -30,7 +30,7 @@ A missing, blank, or non-string `section` and any unknown key fail at plugin loa
## The exit tool and the `/plan` command
-[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again; a missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode.
+[`exit_plan_mode`](../tool-catalog.md#deepseek-aidsh-plan-mode) stays registered while plan mode is inactive, so entering or leaving plan mode changes only the prompt section, never the request tool catalog; execution outside plan mode fails. In plan mode it requires a complete markdown plan starting with a `#` heading and presents it for review through the [user-questions seam](user-questions.md). Approval returns `{ approved: true }` and records a silent (non-narrated) pending exit that is appended at the next accepted in-turn pre-step. Plan guidance therefore remains active for the rest of the assistant's current tool batch, and the tool result itself reports the transition. Keep-planning is a failed call carrying the user's feedback, so the model revises and presents again. A dismissed review returns `{ approved: false }` and concludes the turn, so plan mode stays active until the user's next message. A missing interaction channel and a service reload during review also fail the call rather than silently leaving plan mode.
When [`ctx.commands`](commands.md) is composed, the plugin registers `/plan [off|message]`: bare `/plan` selects plan mode, any other non-empty message selects it and then submits the text through `agent.steer()` so it becomes the next step's ordinary logged user message under plan guidance, and the exact argument `off` selects inactive, which also cancels a pending entry before it is appended and becomes visible to a request.
diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md
index db2a7e1c2b..63463f0105 100644
--- a/docs/subsystems/plan.zh.md
+++ b/docs/subsystems/plan.zh.md
@@ -30,7 +30,7 @@ interface PlanModeConfig {
## 退出工具与 `/plan` 命令
-[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交;评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。
+[`exit_plan_mode`](../tool-catalog.zh.md#deepseek-aidsh-plan-mode) 在计划模式未激活时仍保持注册,因此进入或离开计划模式只改变提示词段落,绝不改变请求的工具目录;在计划模式之外执行会失败。在计划模式中,它要求一份以 `#` 标题开头的完整 markdown 计划,并通过[用户交互 seam](user-questions.zh.md) 呈交评审。批准返回 `{ approved: true }`,并记录一个静默(不叙述)的待生效退出,由下一个被接受的轮内 pre-step 追加。因此,计划指引在 assistant 当前这批工具调用的剩余部分继续生效,而工具结果本身会报告这次转换。「继续规划」则是一次携带用户反馈的失败调用,模型据此修订并再次呈交。放弃评审返回 `{ approved: false }` 并结束当前轮次,计划模式保持激活,直到用户发出下一条消息。评审期间交互通道缺失或服务重载同样使调用失败,而不是静默离开计划模式。
当 [`ctx.commands`](commands.zh.md) 被组合时,插件注册 `/plan [off|message]`:单独的 `/plan` 选择计划模式;任何其他非空消息先选择计划模式,再通过 `agent.steer()` 提交该文本,使其在计划指引下成为下一步骤的普通已记录用户消息;确切参数 `off` 选择未激活,这还会在待生效条目被追加并对请求可见之前将其取消。
diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md
index 1cf3dff325..1b07f1c698 100644
--- a/docs/subsystems/session-query.md
+++ b/docs/subsystems/session-query.md
@@ -24,6 +24,8 @@ interface SessionRecord {
live: boolean
/** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */
persisted: boolean
+ /** Current for live Sessions; otherwise the persistence format status when reported. */
+ formatStatus?: 'current' | 'migration-required'
}
```
diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md
index 2b8504dfb9..95b9b570af 100644
--- a/docs/subsystems/session-query.zh.md
+++ b/docs/subsystems/session-query.zh.md
@@ -24,6 +24,8 @@ interface SessionRecord {
live: boolean
/** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */
persisted: boolean
+ /** Current for live Sessions; otherwise the persistence format status when reported. */
+ formatStatus?: 'current' | 'migration-required'
}
```
diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml
index a701f0ad9d..72d5fb61aa 100644
--- a/docs/subsystems/session-title.i18n.yaml
+++ b/docs/subsystems/session-title.i18n.yaml
@@ -3,14 +3,14 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-title.md
/session-titles:
- en: a0ad8d5f000e5258
- zh: 9527bc25711342de
+ en: 40ede027d4f62b35
+ zh: 0efd7cb0710a0ed9
/session-titles/durable-title-state:
en: c6be2d86647daa6f
zh: fb259d9fb4738f39
/session-titles/auxiliary-request-record:
- en: c36916905dad76f9
- zh: c82ff6c3d62e53e5
+ en: e5d5045ddfa7e771
+ zh: 8c989ef59853cce4
/session-titles/provider-input-and-output:
- en: 886e036e442ae06a
- zh: 49f95782b8aa9bf3
+ en: 28ee84e62db773f0
+ zh: 93beca6fbcd02c4e
diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md
index 699b0148a6..f953d3fcf2 100644
--- a/docs/subsystems/session-title.md
+++ b/docs/subsystems/session-title.md
@@ -2,7 +2,7 @@
English | [中文](session-title.zh.md)
-Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session/session-title). The shared LLM helper owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations.
+Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session/session-title). The shared execution module owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations.
Sources: [`packages/session/session-title/src/index.ts`](../../packages/session/session-title/src/index.ts), [`packages/session/session-title-llm/src/index.ts`](../../packages/session/session-title-llm/src/index.ts)
@@ -64,7 +64,7 @@ interface SessionTitleSnapshot extends SessionTitleEventData {
## Auxiliary request record
-The shared LLM helper records each validated, dispatchable title request before calling the model. The payload reproduces the model-visible system and message input, routing, output limit, resolved reasoning effort, provider ownership, and source-message attribution even when generation later fails.
+The shared execution module records each validated, dispatchable title request before calling the model. The payload reproduces the model-visible system and message input, routing, output limit, resolved reasoning effort, provider ownership, and source-message attribution even when generation later fails.
```ts type-equiv
/** Exact model-visible request recorded before one auxiliary title dispatch. */
@@ -88,7 +88,7 @@ interface SessionTitleLlmRequestEventData {
## Provider input and output
-The service snapshots eligible messages through one revision. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind.
+The service snapshots eligible messages through one revision and, immediately before each provider call, the latest accepted title including the fallback it just ensured. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind.
```ts type-equiv
/** One eligible human text message exposed to title providers. */
@@ -114,6 +114,12 @@ interface SessionTitleProviderRequest {
readonly messages: readonly SessionTitleUserMessage[]
/** Exact current logged main-request route, when one has been recorded. */
readonly route?: SessionTitleModelIdentity
+ /**
+ * Latest accepted title captured at invocation, including an accepted
+ * fallback whose event may follow the last eligible message. Absent before
+ * any title is accepted. The provider decides whether and how to use it.
+ */
+ readonly currentTitle?: SessionTitleSnapshot
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
readonly signal: AbortSignal
}
@@ -140,7 +146,7 @@ interface SessionTitleProvider {
readonly automatic: SessionTitleAutomaticMode
/**
* Produce one title revision.
- * @param request - message snapshot, current route, session, and cancellation.
+ * @param request - message snapshot, current title, current route, session, and cancellation.
* @returns proposed title plus exact input seqs and the optional provider/model route used to generate it.
*/
generate(request: SessionTitleProviderRequest): Promise
@@ -193,7 +199,8 @@ async refresh(session: Session, signal?: AbortSignal): Promise
@@ -193,7 +199,8 @@ async refresh(session: Session, signal?: AbortSignal): Promise
+@Remote('fork') async fork(request: SessionForkRequest): Promise
/**
* Admit one prompt after explicitly resuming its Session.
@@ -974,10 +974,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable
/**
- * Read all registered projections without activating an Agent.
+ * Read exact projections, returning cached hints only when migration is required.
* @param request - Session whose current values are required.
- * @param signal - cancellation for the Session observation.
- * @returns complete baseline, or null when the Session does not exist.
+ * @param signal - cancellation for the Session read.
+ * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent.
*/
@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index 8085cb8067..d214c4b471 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -926,10 +926,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
- * @param request - source Session and optional exact inclusive event boundary.
+ * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice.
* @returns the new Session identity.
*/
-@Remote('fork') fork(request: SessionForkRequest): Promise
+@Remote('fork') async fork(request: SessionForkRequest): Promise
/**
* Admit one prompt after explicitly resuming its Session.
@@ -978,10 +978,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable
/**
- * Read all registered projections without activating an Agent.
+ * Read exact projections, returning cached hints only when migration is required.
* @param request - Session whose current values are required.
- * @param signal - cancellation for the Session observation.
- * @returns complete baseline, or null when the Session does not exist.
+ * @param signal - cancellation for the Session read.
+ * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent.
*/
@Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise
diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml
index 67850a5a7d..aec206af8c 100644
--- a/docs/subsystems/slots.i18n.yaml
+++ b/docs/subsystems/slots.i18n.yaml
@@ -18,8 +18,8 @@
en: 2fcfc55fbac80f72
zh: ea3016f394c56b07
/web-client-slots/developer-provided-injection:
- en: 7081335b356bf46b
- zh: 631e60d29daf7196
+ en: 4fb5222bb2b064e7
+ zh: b961f792052b4ce5
/web-client-slots/current-hierarchy:
en: 8a4cc4b8608ce0f6
zh: 21281dcf047503b2
diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md
index 8a9448a4f8..c0fa352a4f 100644
--- a/docs/subsystems/slots.md
+++ b/docs/subsystems/slots.md
@@ -106,8 +106,6 @@ A reserved `hooks` object in that return value accepts bare `getSnapshot`/`subsc
The owner of a slot may put an `inject` face in the child declaration when every occupant needs the same capability. Plain members reach all occupants unchanged. Function-valued members inside its `hooks` object are hook factories; they receive the slot's standard props and optional per-render `hookContext`, then return the constrained hook exposed to the occupant. `conversation.chat.node` uses this mechanism to provide `useTurnData(key)` for the node currently being rendered.
-The session-scoped `conversation.chat.reasoning-body` chain receives unchanged `text` and `running` values for an expanded reasoning body. Its owner renders original compact Markdown when all entries decline; display contributions retain access to the original reasoning.
-
Use owner props for values already known at one render occurrence, registration `inject` for one entry's callbacks and private observables, slot-level `inject` for a capability controlled by the slot owner, and a declared store for mutable view state shared across entries or preserved across remounts. React nodes compose through child slots, not through injected values.
## Current hierarchy
@@ -147,15 +145,16 @@ root
│ └─ main.conversation
│ ├─ conversation.session
│ │ └─ conversation.view
-│ │ ├─ conversation.chat.node
-│ │ │ ├─ conversation.chat.assistant-actions
-│ │ │ ├─ conversation.chat.reasoning-body
-│ │ │ ├─ conversation.chat.commandview
-│ │ │ ├─ conversation.chat.turnTail
-│ │ │ └─ tool.call.toolview
-│ │ │ ├─ tool.call.images
-│ │ │ └─ tool.view.cordis
-│ │ ├─ conversation.message.images
+│ │ ├─ conversation.chat.flow
+│ │ │ ├─ conversation.chat.node
+│ │ │ │ ├─ conversation.chat.assistant-actions
+│ │ │ │ ├─ conversation.chat.reasoning.body
+│ │ │ │ ├─ conversation.chat.commandview
+│ │ │ │ ├─ conversation.chat.turnTail
+│ │ │ │ └─ tool.call.toolview
+│ │ │ │ ├─ tool.call.images
+│ │ │ │ └─ tool.view.cordis
+│ │ │ └─ conversation.message.images
│ │ └─ conversation.trajectory.images
│ ├─ conversation.header
│ │ ├─ conversation.header.leading
diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md
index bf8bc0cac7..3689583543 100644
--- a/docs/subsystems/slots.zh.md
+++ b/docs/subsystems/slots.zh.md
@@ -106,8 +106,6 @@ Renderer 还会根据声明的 store 创建 `useStore`,并根据声明的 loca
当每个 occupant 都需要同一种能力时,slot owner 可以在 child 声明里放置 `inject` face。普通成员会原样交给所有 occupant;其 `hooks` 对象中的函数成员是 hook factory,它会收到 slot 的标准 props 与可选的逐次渲染 `hookContext`,再返回提供给 occupant 的受限 hook。`conversation.chat.node` 正是通过这种机制,为当前渲染的 node 提供 `useTurnData(key)`。
-Session 作用域的 `conversation.chat.reasoning-body` 链为展开的思考正文提供未修改的 `text` 和 `running`。所有条目都拒绝认领时,owner 渲染原始紧凑 Markdown;显示贡献保留查看原始思考内容的能力。
-
一次渲染时 owner 已知的值走 owner props;单个 entry 的 callback 与私有 observable 走注册项 `inject`;由 slot owner 控制、所有 occupant 共享的能力走 slot 级 `inject`;需要跨 entry 共享或跨重新挂载保留的可变视图状态走声明的 store。React node 通过 child slot 组合,不通过注入值传递。
## 当前层级
@@ -147,15 +145,16 @@ root
│ └─ main.conversation
│ ├─ conversation.session
│ │ └─ conversation.view
-│ │ ├─ conversation.chat.node
-│ │ │ ├─ conversation.chat.assistant-actions
-│ │ │ ├─ conversation.chat.reasoning-body
-│ │ │ ├─ conversation.chat.commandview
-│ │ │ ├─ conversation.chat.turnTail
-│ │ │ └─ tool.call.toolview
-│ │ │ ├─ tool.call.images
-│ │ │ └─ tool.view.cordis
-│ │ ├─ conversation.message.images
+│ │ ├─ conversation.chat.flow
+│ │ │ ├─ conversation.chat.node
+│ │ │ │ ├─ conversation.chat.assistant-actions
+│ │ │ │ ├─ conversation.chat.reasoning.body
+│ │ │ │ ├─ conversation.chat.commandview
+│ │ │ │ ├─ conversation.chat.turnTail
+│ │ │ │ └─ tool.call.toolview
+│ │ │ │ ├─ tool.call.images
+│ │ │ │ └─ tool.view.cordis
+│ │ │ └─ conversation.message.images
│ │ └─ conversation.trajectory.images
│ ├─ conversation.header
│ │ ├─ conversation.header.leading
diff --git a/docs/subsystems/ssh.i18n.yaml b/docs/subsystems/ssh.i18n.yaml
index 1451fd22a2..d432de67ba 100644
--- a/docs/subsystems/ssh.i18n.yaml
+++ b/docs/subsystems/ssh.i18n.yaml
@@ -6,8 +6,8 @@
en: 847a37e7a77c8a3f
zh: 33f4de7837ec2faa
/ssh/execution-coordinates:
- en: 77968e8be38ab9be
- zh: f5bf0f62b32e45c7
+ en: 6dca62a58ecbc1ff
+ zh: 1ebb7dbe79927701
/ssh/transport-and-trust:
en: e807ecd0222acb80
zh: b2077393b549a7fe
diff --git a/docs/subsystems/ssh.md b/docs/subsystems/ssh.md
index 04480e6ec3..440e42c26d 100644
--- a/docs/subsystems/ssh.md
+++ b/docs/subsystems/ssh.md
@@ -8,7 +8,7 @@ The [SSH provider family](../../packages/ssh/README.md) supplies one remote file
Filesystem identities, executable lookup, process cwd, sandbox workspace roots and language-server file URLs refer to the SSH host. Providers canonicalize paths where the files exist, preserving filesystem interpretation of `symlink/..`. The policy resolver carries absolute execution-world spelling without trying to resolve remote paths on the Harness host.
-`processPath()` supplies a path usable by the paired subprocess provider. `processPathFromHostPath()` remains unavailable for SSH; installing a remote artifact does not make an arbitrary host path portable. [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.md) therefore takes an explicitly installed, digest-verified remote bootstrap.
+`processPath()` supplies a path usable by the paired subprocess provider. `processPathFromHostPath()` remains unavailable for SSH; installing a remote artifact does not make an arbitrary host path portable. [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.md) therefore takes the verified remote launch configuration: an installed script bootstrap or the [helper executable’s embedded worker](../../packages/ssh/ssh-helper-runtime/README.md).
## Transport and trust
@@ -30,23 +30,36 @@ See the [decision record](../../.agents/notes/implemented/architecture/2026-09-1
## Connection API
+```ts type-equiv
+/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
+type HelperLaunch = {
+ /** Run the installed script with a separately installed Node executable. */
+ kind: 'node-script'
+ /** Absolute remote Node executable. */
+ node: string
+ /** Absolute remote PTC bootstrap; requires bootstrapHash. */
+ bootstrapPath?: string
+ /** Lowercase SHA-256 of bootstrapPath; requires that path. */
+ bootstrapHash?: string
+} | {
+ /** Run the helper executable with its embedded Node and PTC worker. */
+ kind: 'executable'
+}
+```
+
```ts type-equiv
/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
interface Config {
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
host: string
- /** Absolute remote Node executable. */
- node: string
+ /** Explicit script or self-contained executable invocation. */
+ launch: HelperLaunch
/** Absolute path to the installed, bundled helper entry. */
helper: string
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
helperHash: string
/** Absolute remote default workspace. */
workspace: string
- /** Optional preinstalled built PTC entry, paired with its expected digest. */
- bootstrapPath?: string
- /** SHA-256 of bootstrapPath; both fields must be supplied together. */
- bootstrapHash?: string
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
requestTimeoutMs?: number
/** Maximum JSON payload bytes per helper request or response. */
@@ -67,10 +80,8 @@ declare class SshConnection extends Service {
constructor(ctx: Context, config: Config);
/** Hold plugin readiness until the remote identity and helper digest are verified. */
async [Service.init](): Promise;
- /** Verified remote Node executable for the paired PTC runtime. */
- get nodeExecutable(): string;
- /** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
- get bootstrapPath(): string;
+ /** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */
+ get ptcLaunch(): { kind: 'embedded'; executable: string } | { kind: 'node-script'; executable: string; bootstrapPath: string };
/**
* Send a helper operation; cancellation never replays an ambiguous mutation.
* @param method - the private helper operation.
diff --git a/docs/subsystems/ssh.zh.md b/docs/subsystems/ssh.zh.md
index 32d7c8b6c2..862d8402a1 100644
--- a/docs/subsystems/ssh.zh.md
+++ b/docs/subsystems/ssh.zh.md
@@ -8,7 +8,7 @@
文件系统身份、可执行文件查找、进程 cwd、沙箱工作区根目录及语言服务器文件 URL 都指向 SSH 主机。提供方在文件实际存在的位置规范化路径,保留文件系统对 `symlink/..` 的解释。策略解析器保留执行环境中的绝对路径写法,不尝试在 Harness 主机上解析远端路径。
-`processPath()` 提供配套子进程提供方可用的路径。SSH 的 `processPathFromHostPath()` 仍不可用;安装远端产物不意味着任意主机路径可移植。因此 [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md) 使用显式安装并经过摘要验证的远端引导程序。
+`processPath()` 提供配套子进程提供方可用的路径。SSH 的 `processPathFromHostPath()` 仍不可用;安装远端产物不意味着任意主机路径可移植。因此 [`NodePtcRuntime`](../../packages/ptc-runtime/ptc-runtime-node/README.zh.md) 使用验证后的远端启动配置:已安装的脚本引导程序,或 [helper 可执行文件内嵌的 worker](../../packages/ssh/ssh-helper-runtime/README.zh.md)。
## 传输与信任
@@ -30,23 +30,36 @@ headless 通过已挂载的文件系统提供方记录和检查 Session cwd。
## 连接 API
+```ts type-equiv
+/** Installed helper invocation; script deployments may also install a PTC bootstrap. */
+type HelperLaunch = {
+ /** Run the installed script with a separately installed Node executable. */
+ kind: 'node-script'
+ /** Absolute remote Node executable. */
+ node: string
+ /** Absolute remote PTC bootstrap; requires bootstrapHash. */
+ bootstrapPath?: string
+ /** Lowercase SHA-256 of bootstrapPath; requires that path. */
+ bootstrapHash?: string
+} | {
+ /** Run the helper executable with its embedded Node and PTC worker. */
+ kind: 'executable'
+}
+```
+
```ts type-equiv
/** Deployment-owned SSH identity and installed helper; no model argument selects these values. */
interface Config {
/** OpenSSH host alias, including its existing user, key and known-host configuration. */
host: string
- /** Absolute remote Node executable. */
- node: string
+ /** Explicit script or self-contained executable invocation. */
+ launch: HelperLaunch
/** Absolute path to the installed, bundled helper entry. */
helper: string
/** SHA-256 of that bundled helper; mismatches refuse the connection. */
helperHash: string
/** Absolute remote default workspace. */
workspace: string
- /** Optional preinstalled built PTC entry, paired with its expected digest. */
- bootstrapPath?: string
- /** SHA-256 of bootstrapPath; both fields must be supplied together. */
- bootstrapHash?: string
/** Connection and administrative-request deadline, at most 2,147,483,647 milliseconds. */
requestTimeoutMs?: number
/** Maximum JSON payload bytes per helper request or response. */
@@ -67,10 +80,8 @@ declare class SshConnection extends Service {
constructor(ctx: Context, config: Config);
/** Hold plugin readiness until the remote identity and helper digest are verified. */
async [Service.init](): Promise;
- /** Verified remote Node executable for the paired PTC runtime. */
- get nodeExecutable(): string;
- /** Verified preinstalled PTC entry; unconfigured runtimes fail before program execution. */
- get bootstrapPath(): string;
+ /** Verified remote PTC launch configuration; script deployments require a verified bootstrap. */
+ get ptcLaunch(): { kind: 'embedded'; executable: string } | { kind: 'node-script'; executable: string; bootstrapPath: string };
/**
* Send a helper operation; cancellation never replays an ambiguous mutation.
* @param method - the private helper operation.
diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md
index 8ad39194a1..5647fab452 100644
--- a/docs/subsystems/subagent.md
+++ b/docs/subsystems/subagent.md
@@ -184,7 +184,7 @@ interface ContinuableCreateSpec {
sendMessage authorizes the exact live sender and permits only adjacent local agents. Running children receive steering input; absent continuable children resume from persistence. External executions reject follow-ups. Both local and external executions automatically report their final text, structured result, diagnostic, and status in the parent settlement notice. Local children can also send messages through send_message. caller delivery suppresses settlement notices. drainDescendants and drainChildren await owned work teardown for local and external activations.
```ts type-equiv
-/** Durable attribution for one model-authored message between adjacent Agents. */
+/** Durable attribution for one model-authored message between Agents. */
interface AgentMessageSource {
readonly kind: 'agent-message'
/** A message another agent addressed to this one (`relay` context form). */
diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md
index 220ffe5d42..30262036b7 100644
--- a/docs/subsystems/subagent.zh.md
+++ b/docs/subsystems/subagent.zh.md
@@ -184,7 +184,7 @@ interface ContinuableCreateSpec {
sendMessage 根据确切的在线发送者实例授权,仅允许相邻本地代理通信。运行中的子代理接收引导输入;不在线的可继续子代理从持久化恢复。外部执行拒绝后续消息。本地与外部执行都会通过父级结束通知自动回传最终文本、结构化结果、诊断和状态。本地子代理也可以通过 send_message 发送消息。caller 回传会抑制结束通知。drainDescendants 与 drainChildren 对本地和外部 activation 都等待所属任务清理完成。
```ts type-equiv
-/** Durable attribution for one model-authored message between adjacent Agents. */
+/** Durable attribution for one model-authored message between Agents. */
interface AgentMessageSource {
readonly kind: 'agent-message'
/** A message another agent addressed to this one (`relay` context form). */
diff --git a/docs/subsystems/translation.i18n.yaml b/docs/subsystems/translation.i18n.yaml
index a6f25d640c..51904d7464 100644
--- a/docs/subsystems/translation.i18n.yaml
+++ b/docs/subsystems/translation.i18n.yaml
@@ -15,5 +15,5 @@
en: 40d175fbe66fd4cd
zh: bff18511a8d386e9
/text-translation/reasoning-display:
- en: 209dbbbf3000e087
- zh: f8e661359d0f3cb5
+ en: aaf7d361b682fe4a
+ zh: 485e89bdb063665e
diff --git a/docs/subsystems/translation.md b/docs/subsystems/translation.md
index c5de82676f..8cbf6d3ac8 100644
--- a/docs/subsystems/translation.md
+++ b/docs/subsystems/translation.md
@@ -30,7 +30,7 @@ The [optional bundle](../../packages/experimental/cot-translation-bundle/README.
`CotTranslationPreferences` contains the accepted provider and target language. `CotTranslationSnapshot` pairs these preferences with the translator's current `maxTextChars` and eligible provider IDs. The browser reads this Host snapshot before registering translation controls, including when local settings storage is unavailable. Accepted settings updates and reconnects refresh both values; a changed request limit cancels and splits the expanded text again.
-The session-scoped `conversation.chat.reasoning-body` chain receives the original `text` and `running` flag. Chat renders original Markdown when no contribution claims it. While enabled, the GUI translates completed paragraphs and request-sized prefixes of long streaming paragraphs only after expansion, and restores completed results from the current Session. Its local cache only optimizes that disclosure. The unfinished tail remains original. Original remains accessible. Closing the disclosure, switching provider or language, and unloading cancel outstanding calls and exclude late results. Provider failure preserves the original and requires an explicit retry; translated text never replaces original Session events or model input.
+The GUI registers in the session-scoped `conversation.chat.reasoning.body` single Slot, replacing Chat's default entry. Both entries reuse the official `conversation.chat.reasoning.content` Factory; the translation wrapper selects display text, owns its toolbar and view state, and supplies its localized labels without duplicating Markdown rendering. While enabled, the GUI translates completed paragraphs and request-sized prefixes of long streaming paragraphs only after expansion, and restores completed results from the current Session. Its local cache only optimizes that disclosure. The unfinished tail remains original. Original remains accessible. Closing the disclosure, switching provider or language, and unloading cancel outstanding calls and exclude late results. Provider failure preserves the original and requires an explicit retry; translated text never replaces original Session events or model input.
diff --git a/docs/subsystems/translation.zh.md b/docs/subsystems/translation.zh.md
index e3700edcbd..4600d369e4 100644
--- a/docs/subsystems/translation.zh.md
+++ b/docs/subsystems/translation.zh.md
@@ -30,7 +30,7 @@ Google 在 form POST 请求体中接收原文,Bing 在 JSON POST 请求体中
`CotTranslationPreferences` 包含已接受的翻译服务和目标语言。`CotTranslationSnapshot` 将这些偏好与 translator 当前的 `maxTextChars` 及可用服务 ID 一起返回。浏览器在注册翻译控件前读取此 Host 快照,本地设置存储不可用时也如此。接受的设置更新及重新连接会刷新这两项值;请求长度限制变化时,展开内容的请求被取消并重新分片。
-Session 作用域的 `conversation.chat.reasoning-body` chain 接收原始 `text` 和 `running` 标志。没有 contribution 接管时,Chat 渲染原始 Markdown。启用后,GUI 只在展开后翻译完整段落和流式长段落中达到请求长度的前缀,并从当前 Session 恢复已完成结果;本地缓存只优化当前展开区域。未完成的末尾片段保留原文。原文始终可访问。关闭展开内容、切换 Provider 或语言、卸载插件都会取消未完成调用并忽略迟到的结果。Provider 失败时保留原文,必须显式重试;译文从不替换原始 Session 事件或模型输入。
+GUI 向 Session 作用域的 `conversation.chat.reasoning.body` single Slot 注册,替换 Chat 的默认条目。两个条目均复用官方 `conversation.chat.reasoning.content` Factory;翻译包装选择显示文本,自行维护工具栏和视图状态,传入自己的本地化 labels,不重复实现 Markdown 渲染。启用后,GUI 只在展开后翻译完整段落和流式长段落中达到请求长度的前缀,并从当前 Session 恢复已完成结果;本地缓存只优化当前展开区域。未完成的末尾片段保留原文。原文始终可访问。关闭展开内容、切换 Provider 或语言、卸载插件都会取消未完成调用并忽略迟到的结果。Provider 失败时保留原文,必须显式重试;译文从不替换原始 Session 事件或模型输入。
diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml
index c2e4d0bec6..9a9d78794a 100644
--- a/docs/subsystems/web-server.i18n.yaml
+++ b/docs/subsystems/web-server.i18n.yaml
@@ -3,14 +3,14 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
/http-server:
- en: 778b8499fa5ec417
- zh: f2073db3d2ed8b70
+ en: c9b2f67d3248c496
+ zh: 5fa36d2837ff4f63
/http-server/routes:
- en: 940bc78642c990b0
- zh: 901d2ba7e354a766
+ en: e949c3738a35b58f
+ zh: 138e3aaeb319ddc4
/http-server/config:
- en: 30118110a0ea3702
- zh: 940f2b69d2dd54af
+ en: 4b162bd9f280eb06
+ zh: 3bea98dfee88c582
/http-server/the-service:
- en: e304734ab2143438
- zh: 065f9c2acbbb07ca
+ en: cc36972b8458279f
+ zh: c129044935d44769
diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md
index 2826bbe9d2..5150ddd01f 100644
--- a/docs/subsystems/web-server.md
+++ b/docs/subsystems/web-server.md
@@ -2,7 +2,7 @@
English | [中文](web-server.zh.md)
-[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
+[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
@@ -26,15 +26,34 @@ interface WebRoute {
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: Connection authenticates the dist root and configured index before their HTML is read; non-index assets remain public; non-GET/HEAD is 405, traversal outside the dist root is 403, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream.
+
+
## Config
```ts type-equiv
-/** Web server listen and response-compression config. */
+/** Web server listen, TLS, and response-compression config. */
interface Config {
- /** Listen host; the two supported values are loopback and all-interfaces. */
- host: '127.0.0.1' | '0.0.0.0'
+ /**
+ * Listen address: a concrete IPv4 or IPv6 literal of one local interface,
+ * for example the container's own Pod address from `hostname -i`. A loopback
+ * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
+ * server on this machine; any other literal serves the network that address
+ * belongs to, over plain HTTP unless `tls` is set. The unspecified address —
+ * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
+ * load: it would expose the port on every interface at once.
+ */
+ host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
+ /**
+ * Serve HTTPS with this certificate and key instead of plain HTTP. Both files
+ * are read once, before the listener binds: an unreadable or empty file,
+ * invalid PEM, or a key that does not match the certificate rejects
+ * initialization rather than falling back to HTTP. The material is never
+ * re-read, so replacing a certificate takes a reload. Omitted or null listens
+ * over plain HTTP.
+ */
+ tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -44,13 +63,27 @@ interface Config {
}
```
-`host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). The carrier itself owns no TLS, authentication, or Origin policy, so a non-loopback bind exposes the server unless the composition supplies those controls. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback and rejects `--host 0.0.0.0`; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat.
+```ts type-equiv
+/**
+ * TLS material for the HTTPS listener: one certificate chain file and the
+ * private key file it pairs with. Both hold PEM text and both resolve against
+ * the process working directory.
+ */
+interface TlsConfig {
+ /** Certificate chain file, leaf certificate first, PEM, no passphrase. */
+ certFile: string
+ /** Private key file for the chain's leaf certificate; unencrypted PEM. */
+ keyFile: string
+}
+```
+
+No composition can bind every interface at once: the unspecified address is rejected at load. The exported `isWildcardHost` and `isLoopbackHost` classify a bind address from its parsed value, and an IPv6 `%zone` is kept for `listen` while classification reads the address alone. The carrier owns no authentication or Origin policy, and it serves plain HTTP unless `tls` supplies a certificate and key. `compression` defaults to `none`; the shipped Web bundle selects gzip level 1 with a 1024-byte threshold. The shipped `dsh web` command selects loopback by default and accepts one concrete `--host` address, warning at startup when a non-loopback bind serves plain HTTP; its Connection plugin supplies Host/Origin checks plus browser-session authentication for every Host API route and stream, and the bind address plus `--trusted-host` govern which browser Host is accepted; `--public-url` changes only the advertised URL. Other compositions own their bind and route-authentication policy. The dist location is an assembly fact of the frontend plugin that claims the seat.
## The service
-`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
+`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
-A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
+A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
@@ -92,6 +125,8 @@ admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
+ * The cookie this mints is `Secure` when the mounted Web carrier serves TLS,
+ * which only that listener's protocol decides.
* @param request - root or configured-index HTTP request.
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
@@ -112,7 +147,7 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti
### `ctx.webServer` — `WebServer`
-The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber.
+The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers.
```ts cordis-catalog
/**
diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md
index 72ef366958..f91bfdcdbd 100644
--- a/docs/subsystems/web-server.zh.md
+++ b/docs/subsystems/web-server.zh.md
@@ -2,7 +2,7 @@
[English](web-server.md) | 中文
-[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
+[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
@@ -26,15 +26,34 @@ interface WebRoute {
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:Connection 在读取 dist 根目录和配置 index 的 HTML 前完成认证;非 index 资产保持公开;非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。
+
+
## 配置
```ts type-equiv
-/** Web server listen and response-compression config. */
+/** Web server listen, TLS, and response-compression config. */
interface Config {
- /** Listen host; the two supported values are loopback and all-interfaces. */
- host: '127.0.0.1' | '0.0.0.0'
+ /**
+ * Listen address: a concrete IPv4 or IPv6 literal of one local interface,
+ * for example the container's own Pod address from `hostname -i`. A loopback
+ * literal (any address in 127/8, `::1`, or a mapped form of either) keeps the
+ * server on this machine; any other literal serves the network that address
+ * belongs to, over plain HTTP unless `tls` is set. The unspecified address —
+ * IPv4 any, IPv6 any, and the IPv4-mapped forms of IPv4 any — is rejected at
+ * load: it would expose the port on every interface at once.
+ */
+ host: string
/** Listen port; zero requests an OS-assigned port. */
port: number
+ /**
+ * Serve HTTPS with this certificate and key instead of plain HTTP. Both files
+ * are read once, before the listener binds: an unreadable or empty file,
+ * invalid PEM, or a key that does not match the certificate rejects
+ * initialization rather than falling back to HTTP. The material is never
+ * re-read, so replacing a certificate takes a reload. Omitted or null listens
+ * over plain HTTP.
+ */
+ tls?: TlsConfig
/** Response compression for socket-backed HTTP requests. @default 'none' */
compression?: 'none' | 'gzip'
/** Gzip DEFLATE level from 0 through 9. @default 1 */
@@ -44,13 +63,27 @@ interface Config {
}
```
-`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(刻意的网络暴露)。载体本身不拥有 TLS、认证或 Origin 策略,因此绑定到非回环地址会暴露服务器,除非组合层提供这些控制。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令选择 loopback 并拒绝 `--host 0.0.0.0`;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
+```ts type-equiv
+/**
+ * TLS material for the HTTPS listener: one certificate chain file and the
+ * private key file it pairs with. Both hold PEM text and both resolve against
+ * the process working directory.
+ */
+interface TlsConfig {
+ /** Certificate chain file, leaf certificate first, PEM, no passphrase. */
+ certFile: string
+ /** Private key file for the chain's leaf certificate; unencrypted PEM. */
+ keyFile: string
+}
+```
+
+没有组合能一次性绑定所有网络接口:未指定地址在加载时即被拒绝。导出的 `isWildcardHost` 与 `isLoopbackHost` 按解析后的地址值分类,IPv6 的 `%zone` 会保留给 `listen`,而分类只读取地址本身。载体不拥有认证或 Origin 策略,并且除非 `tls` 提供证书与密钥,否则以明文 HTTP 提供服务。`compression` 默认为 `none`;随附的 Web 组合选择 gzip level 1 和 1024 字节阈值。随附的 `dsh web` 命令默认选择 loopback 并接受一个具体的 `--host` 地址,非 loopback 绑定以明文 HTTP 提供服务时会在启动时警告;其 Connection 插件为每个 Host API route 与 stream 提供 Host/Origin 校验和浏览器会话认证,绑定地址加 `--trusted-host` 决定接受哪个浏览器 Host;`--public-url` 只改变公告的 URL。其他组合自行拥有绑定与路由认证策略。dist 位置是认领席位的前端插件的组装事实。
## 服务
-`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
+`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
-处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
+处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
@@ -92,6 +125,8 @@ admit(request: ConnectionTrustRequest): PeerAdmission
/**
* Authenticate one frontend index request, owning a token redirect or 401.
+ * The cookie this mints is `Secure` when the mounted Web carrier serves TLS,
+ * which only that listener's protocol decides.
* @param request - root or configured-index HTTP request.
* @param response - response owned when the result is false.
* @returns true only when the frontend may serve index.html.
@@ -112,7 +147,7 @@ Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connecti
### `ctx.webServer` — `WebServer`
-The browser HTTP carrier service. Activation listens immediately. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers. A listen failure rejects initialization, and the boot process reports the failed fiber.
+The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers.
```ts cordis-catalog
/**
diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml
index e14eeb549f..2ab2f56624 100644
--- a/docs/tool-catalog.i18n.yaml
+++ b/docs/tool-catalog.i18n.yaml
@@ -9,8 +9,8 @@
en: 6cc2cd9ced70bb09
zh: 1c0e6cb4ba2ed637
/tool-schema-catalog/tool-package-map:
- en: 7caa258c67464109
- zh: 53bfb5581c48dc45
+ en: e18a3e3c916afb23
+ zh: 4284b50a5e9bd12f
/tool-schema-catalog/deepseek-ai-dsh-plugin-manager:
en: e0143916bb634762
zh: e0143916bb634762
@@ -264,8 +264,8 @@
en: f9457f033f75f00a
zh: a2d60ec44118c34c
/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/send-message:
- en: e99455aec4656ff2
- zh: a8f88ec35cfe38b8
+ en: ec50e6a0b86aecc6
+ zh: 29c8728536a5adf1
/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/spawn-teammate:
en: 02d6cc746e234b99
zh: 49bc60ac098bca48
@@ -282,8 +282,8 @@
en: 2eac7360ba68b1bd
zh: b5d676bf288c245e
/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-agent-team/wait-agent:
- en: fbc4d75a9bcf6816
- zh: 672131ac2705f411
+ en: e70c77a45832bb28
+ zh: 7d03bec294b65452
/tool-schema-catalog/deepseek-ai-dsh-tool-todo:
en: dea62d26fb18ff68
zh: dea62d26fb18ff68
diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md
index 143d1b29df..8aa9fb0096 100644
--- a/docs/tool-catalog.md
+++ b/docs/tool-catalog.md
@@ -40,7 +40,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance selects its backend through `provider` and reads model-selection settings through `modelSelectionSettings`. Every delegation returns a child id promptly; local children accept follow-ups, while external backends report their single execution automatically. |
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named discovery, interruption, and local-message tools over managed subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). |
| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands and PTY sends are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
-| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. |
+| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/task`, `agent/inbox/spliced`, `user/message on inbox claim`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. |
| `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | Reads or changes the calling Session directory. Existing shells and processes retain their own directories; origin metadata and permission roots stay fixed. |
| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | Explicit experimental composition only. Creates a new branch and checkout from a pinned local commit under existing write permissions, then changes the Session directory. Leaving retains the branch and checkout. |
@@ -2257,7 +2257,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper
### `send_message`
-Send one durable message to another Team member. A running target receives it at the nearest step boundary; an inactive target starts or resumes a turn.
+Send one message to another Team member. A running target receives it at the nearest step boundary; an inactive target starts or resumes a turn.
```json
{
@@ -2489,7 +2489,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper
### `wait_agent`
-Wait for the next teammate status, mailbox, or shared-task change after this call starts. This never wakes inactive members and returns noProgress immediately when no other member is running or provisioning. Re-list after wakeup or timeout instead of polling.
+Wait for the next teammate status, message, or shared-task change after this call starts. This never wakes inactive members and returns noProgress immediately when no other member is running or provisioning. Re-list after wakeup or timeout instead of polling.
```json
{
diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md
index 23ef011dc5..b505568dde 100644
--- a/docs/tool-catalog.zh.md
+++ b/docs/tool-catalog.zh.md
@@ -44,7 +44,7 @@
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `provider` 选择后端,通过 `modelSelectionSettings` 读取模型选择设置。每次委派都会立即返回子代理 id;本地子代理接受后续消息,外部后端自动汇报单次执行结果。 |
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是用于发现、中断托管 subagent 和向本地子代理发送消息的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 |
| `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令与 PTY 发送 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
-| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 |
+| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/task`、`agent/inbox/spliced`、`user/message on inbox claim`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 |
| `@deepseek-ai/dsh-tool-working-directory` | `working_directory` | `ctx.tools`, `ctx.workingDirectory` | `tool/call`, `working-directory/change`, `user context for directory changes`, `tool/result` | - | 读取或改变调用方 Session 的目录。已有 shell 和进程保留自己的目录;原始元数据和权限根目录保持不变。 |
| `@deepseek-ai/dsh-experimental-tool-worktree` | `create_worktree` | `ctx.tools`, `ctx.worktrees`, `a calling Agent` | `tool/call`, `Git branch and checkout`, `working-directory/change`, `tool/result` | - | 仅通过显式实验组合加载。在现有写权限下从固定的本地提交创建新分支与检出目录,再改变 Session 工作目录。离开时保留分支与检出目录。 |
@@ -2263,7 +2263,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `send_message`
-向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;inactive target 会启动或恢复一个 turn。
+向另一名 Team member 发送一条消息。running target 会在最近的步骤边界收到消息;inactive target 会启动或恢复一个 turn。
```json
{
@@ -2495,7 +2495,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
### `wait_agent`
-等待本次调用开始后下一次 teammate 状态、mailbox 或共享任务变更。它绝不会唤醒 inactive member;若没有其他 member 正在 running 或 provisioning,则立即返回 noProgress。唤醒或超时后应重新列出状态,而不是轮询。
+等待本次调用开始后下一次 teammate 状态、消息或共享任务变更。它绝不会唤醒 inactive member;若没有其他 member 正在 running 或 provisioning,则立即返回 noProgress。唤醒或超时后应重新列出状态,而不是轮询。
```json
{
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.i18n.yaml
new file mode 100644
index 0000000000..e7568e9555
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.md
+/:
+ en: 97a3763818ba118c
+ zh: ba856458827cb637
+/enable-test-update-policy-authentication-dialogs-locally:
+ en: 33515a78d8b30d7a
+ zh: 6f821057b75e319e
+/enable-test-update-policy-authentication-dialogs-locally/change:
+ en: 21d23ef8d1d3599c
+ zh: 389633b096767f73
+/enable-test-update-policy-authentication-dialogs-locally/migration:
+ en: e6b3bc48daaa13b1
+ zh: df50a981844d3de6
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.md
new file mode 100644
index 0000000000..4a70b5f34f
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.md
@@ -0,0 +1,18 @@
+---
+kind: upgrade-guide
+description: "Desktop test builds require local opt-in before showing any Feishu update-policy authentication dialogs."
+---
+
+# Enable test update-policy authentication dialogs locally
+
+English | [中文](guide.zh.md)
+
+## Change
+
+Test builds previously offered Feishu login after an unauthenticated startup or manual update-policy check. All test authentication dialogs now default to disabled, including manual checks and mandatory-update refreshes. Test gateway cookies do not survive restart. With login disabled, a gateway requiring authentication cannot provide new mandatory-update decisions; enable the setting, restart and sign in to obtain them. Authentication failures retain any mandatory block already known in the same process; production policy requests and product account login are unchanged.
+
+## Migration
+
+1. Launch the updated application once to create `app.getPath('userData')/desktop/settings.json` if missing. Existing files are retained, and a missing field defaults to `false`. See [local desktop settings](../../../../apps/desktop/README.md#local-desktop-settings) for platform paths and error recovery.
+2. To permit Feishu login, set `updates.allowTestAuthPopupWindow` to `true` in that file, then fully quit and restart Desktop. An unauthenticated test-policy response can now offer login. Leave the default unchanged if no login dialogs are wanted.
+3. To disable dialogs again, set the field to `false` and restart. Startup and manual checks must not show Feishu authentication dialogs. Updates still query policy, and authentication failures do not clear a known mandatory block.
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.zh.md
new file mode 100644
index 0000000000..2e803cee31
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/desktop-test-auth-popup/guide.zh.md
@@ -0,0 +1,18 @@
+---
+kind: upgrade-guide
+description: "桌面测试包需要在本地显式开启后,才能显示飞书更新策略鉴权弹窗。"
+---
+
+# 在本地开启测试更新策略鉴权弹窗
+
+[English](guide.md) | 中文
+
+## 变更
+
+测试包此前会在启动或手动检查更新策略未通过鉴权后提供飞书登录入口。现在所有测试鉴权弹窗默认关闭,包括手动检查和强更刷新。测试网关 Cookie 不跨重启保存。禁止登录时,需要鉴权的网关无法提供新的强更决策;需开启设置、重启并登录后才能获取。鉴权失败仍保留同一进程中已知的强更阻塞;正式环境策略请求和产品账号登录保持不变。
+
+## 迁移
+
+1. 启动更新后的应用一次,在 `app.getPath('userData')/desktop/settings.json` 缺失时生成该文件。已有文件会保留,字段缺失时默认为 `false`。平台路径和错误恢复方法见[本地桌面设置](../../../../apps/desktop/README.zh.md#local-desktop-settings)。
+2. 如需允许飞书登录,在文件中将 `updates.allowTestAuthPopupWindow` 设为 `true`,然后完全退出并重启 Desktop。此时测试策略的未鉴权响应可以提供登录入口。不需要登录弹窗时保持默认值即可。
+3. 如需再次关闭弹窗,将字段设为 `false` 并重启。启动和手动检查均不得显示飞书鉴权弹窗。更新仍会查询策略,鉴权失败不会解除已知强更阻塞。
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.i18n.yaml
index 870c91d93d..6a374bc79d 100644
--- a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.i18n.yaml
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.i18n.yaml
@@ -3,14 +3,14 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md
/:
- en: a77928d6130fe601
- zh: da46d612268c3ced
-/native-subagent-bundles-include-preset-tools:
- en: b5c79b41fc34574a
- zh: d784adcf343b0817
-/native-subagent-bundles-include-preset-tools/change:
- en: 7d4af3e4ab91ef72
- zh: 837377c96d5eee12
-/native-subagent-bundles-include-preset-tools/migration:
- en: 3257a5d18d07cada
- zh: b663a7ae020bbfee
+ en: 4e1c7b1bfb2ba5ca
+ zh: c3f5ac60d10a215f
+/native-subagent-bundles-include-global-tools:
+ en: 8f785a4b502f1f78
+ zh: 527952686410adba
+/native-subagent-bundles-include-global-tools/change:
+ en: 3432d6ba2ccf6492
+ zh: 41cdf90f9d5b330d
+/native-subagent-bundles-include-global-tools/migration:
+ en: 304dc5243b2f8159
+ zh: 38cd9aa136dc8009
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md
index 5a08637440..830ee3b60b 100644
--- a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.md
@@ -1,32 +1,31 @@
---
kind: upgrade-guide
-description: "Claude Code and Codex bundles add delegation tools to full Web presets while retaining their package and row identities."
+description: "Claude Code and Codex bundles add global delegation tools while retaining their package and row identities."
---
-# Native subagent bundles include preset tools
+# Native subagent bundles include global tools
English | [中文](guide.zh.md)
## Change
-`@deepseek-ai/dsh-subagent-claude-code` and `@deepseek-ai/dsh-subagent-codex` keep their package names, bundle selections, provider row IDs, configuration, and native authentication. In Web and Desktop, a selected bundle now adds enabled delegation tools to `preset-standard`, `preset-cordis`, and `preset-ptc`; previously the bundle registered only its provider. Existing Agents retain their composition; new Agents and subsequently reopened Sessions receive the selected tools.
+`@deepseek-ai/dsh-subagent-claude-code` and `@deepseek-ai/dsh-subagent-codex` keep their package names, bundle selections, provider row IDs, configuration, and native authentication. A selected bundle now also registers its delegation tool as a Host global tool, visible to every preset including minimal; previously the bundle registered only its provider. Live Agents see the tool from their next request after the bundle is enabled, and lose it after the bundle is switched off.
-The contributed rows retain `tool-subagent-claude-code` and `tool-subagent-codex`. Later user layers can override their configuration or disabled state, and a complete preset configuration replacement keeps its own child list. An override does not select an unselected bundle. Duplicate declarations of the same model-facing tool still fail.
+The contributed Host rows are `tool-subagent-claude-code` and `tool-subagent-codex`. Later user layers can override their configuration or disabled state by row id. An override does not select an unselected bundle. A preset that registers its own tool with the same name shadows the global tool for its Agents.
Delegation follows the shared managed-activation API: the tool returns a child ID, and completion arrives as a logged notice to the parent Agent. Remove obsolete `backgroundMode` settings; `maxDepth: provider-managed` remains supported for these native providers.
## Migration
1. Preserve selected bundle names, provider configuration, and authentication. Existing provider-only packages need **Update** in Plugins → Official to receive the tool-contributing bundle; restart when requested. To adopt a new provider, enable its card. [Installation targets](../../../../packages/boot/plugin-manager/README.md#use-this-package) follow the published DSH version or source checkout; equal version strings do not make an older registry package equivalent to a development link. Off only deselects; Remove remains separate.
-2. Configure the familiar tool row ID with a scoped patch. Configuration overrides still replace the complete configuration. For example, this keeps Codex disabled in standard while other full presets use their selected composition:
+2. Configure the tool with an ordinary row patch. Configuration overrides still replace the complete configuration. For example, this keeps the Codex tool disabled while the provider stays registered:
```yaml
- - preset: preset-standard
- id: tool-subagent-codex
+ - id: tool-subagent-codex
disabled: true
```
-3. If an additional manual row registers the same tool in a preset, remove that redundant row or disable the bundle's contribution explicitly. A copied complete preset can retain its existing tool declarations and disabled state. Verify the effective tool catalog in a new Session; minimal keeps its current shell and working-directory tools.
+3. If a manual Host row registers the same tool name, remove that redundant row or disable the bundle's row. Verify the effective tool catalog in a Session.
4. Plugin clients receive required `BundleInfo.official` and `BundleInfo.availability` fields; detail-slot `PluginPackageRef` receives them too. `official` identifies project maintenance, `availability` reports `installation`, `profile`, or `missing`, and `installed` still means a profile dependency declaration. Detail-slot readers that only display existing fields need no changes; constructors and exact validators must include the new fields. Optional `installTarget` identifies the offered version and registry or local-link spec.
-**Other profiles.** Headless, SDK, ACP, and custom profiles without the three full preset targets are outside this compatibility scope. Selecting these bundle layers fails for missing preset targets and can retain the saved selection; there is no automatic rollback. Such profiles can keep the installed provider package without selecting its bundle and explicitly compose provider/tool rows using the [Codex](../../../../packages/subagent/subagent-codex/README.md#exposing-the-tool) or [Claude Code](../../../../packages/subagent/subagent-claude-code/README.md#exposing-the-tool) example.
+**Other profiles.** The bundle layers insert Host rows only, so headless, SDK, ACP, and custom profiles can select them as well. Profiles that keep the provider package without selecting its bundle can compose the same rows using the [Codex](../../../../packages/subagent/subagent-codex/README.md#exposing-the-tool) or [Claude Code](../../../../packages/subagent/subagent-claude-code/README.md#exposing-the-tool) example.
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md
index e98a34f503..76c130bab3 100644
--- a/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/native-subagent-bundle-tools/guide.zh.md
@@ -1,32 +1,31 @@
---
kind: upgrade-guide
-description: "Claude Code 与 Codex bundle 为完整 Web 预设添加委派工具,同时保留包名和行标识。"
+description: "Claude Code 与 Codex bundle 添加全局委派工具,同时保留包名和行标识。"
---
-# 原生子代理 bundle 包含预设工具
+# 原生子代理 bundle 包含全局工具
[English](guide.md) | 中文
## 变更
-`@deepseek-ai/dsh-subagent-claude-code` 和 `@deepseek-ai/dsh-subagent-codex` 保留包名、bundle 选择、提供方行 ID、配置及原生认证。在 Web 和 Desktop 中,已选中的 bundle 现在会为 `preset-standard`、`preset-cordis` 和 `preset-ptc` 添加启用的委派工具;此前 bundle 只注册提供方。已有 Agent 保留其组合;新 Agent 和之后重新打开的 Session 使用选中的工具。
+`@deepseek-ai/dsh-subagent-claude-code` 和 `@deepseek-ai/dsh-subagent-codex` 保留包名、bundle 选择、提供方行 ID、配置及原生认证。已选中的 bundle 现在还会把委派工具注册为 Host 全局工具,包括 minimal 在内的所有预设都可见;此前 bundle 只注册提供方。启用 bundle 后,运行中的 Agent 从下一次请求起即可看到该工具;关闭后则不再可见。
-贡献行保留 `tool-subagent-claude-code` 和 `tool-subagent-codex`。后续用户层可以覆盖其配置或禁用状态,完整的预设配置替换保留自己的子列表。覆盖不会选中未选中的 bundle。重复声明同一个面向模型的工具仍会失败。
+贡献的 Host 行为 `tool-subagent-claude-code` 和 `tool-subagent-codex`。后续用户层可以按行 ID 覆盖其配置或禁用状态。覆盖不会选中未选中的 bundle。预设自行注册的同名工具会为其 Agent 遮蔽该全局工具。
委派遵循共享的托管 activation API:工具返回子级 ID,完成结果通过已记录的通知发送给父 Agent。移除已废弃的 `backgroundMode` 设置;这些原生提供方仍支持 `maxDepth: provider-managed`。
## 迁移
1. 保留已选中的 bundle 名称、提供方配置和认证。已有仅提供方的包需要在插件 → 官方中**更新**才能获得贡献工具的 bundle;按提示重启。要使用新的提供方,启用其卡片。[安装目标](../../../../packages/boot/plugin-manager/README.zh.md#use-this-package)对应已发布的 DSH 版本或源码 checkout;版本字符串相同不代表旧注册表包等同于开发链接。关闭只取消选择;删除仍为独立操作。
-2. 使用作用域补丁配置熟悉的工具行 ID。配置覆盖仍会替换整份配置。例如,以下补丁在 standard 中保持 Codex 禁用,其他完整预设则使用其选中的组合:
+2. 使用普通行补丁配置该工具。配置覆盖仍会替换整份配置。例如,以下补丁在保留提供方注册的同时禁用 Codex 工具:
```yaml
- - preset: preset-standard
- id: tool-subagent-codex
+ - id: tool-subagent-codex
disabled: true
```
-3. 如果额外的手动行在预设中注册了同一个工具,移除该重复行,或显式禁用 bundle 的贡献行。复制的完整预设可以保留其现有工具声明和禁用状态。在新 Session 中验证有效工具目录;minimal 保留当前的 shell 和工作目录工具。
+3. 如果手动的 Host 行注册了同名工具,移除该重复行,或禁用 bundle 的贡献行。在 Session 中验证有效工具目录。
4. 插件客户端收到必填的 `BundleInfo.official` 和 `BundleInfo.availability` 字段;详情 slot 的 `PluginPackageRef` 也会收到它们。`official` 标识项目维护,`availability` 表示 `installation`、`profile` 或 `missing`,`installed` 仍表示 profile 的依赖声明。只展示已有字段的详情 slot 读取方无需修改;构造方和精确校验器必须包含新增字段。可选的 `installTarget` 标识所提供的版本及注册表或本地链接 spec。
-**其他 profile。** Headless、SDK、ACP 和缺少三个完整预设目标的自定义 profile 不在此次兼容范围内。选择这些 bundle 层会因缺少预设目标而失败,并可能保留已保存的选择;没有自动回滚。这些 profile 可以保留已安装的提供方包而不选择其 bundle,并参考 [Codex](../../../../packages/subagent/subagent-codex/README.zh.md#exposing-the-tool) 或 [Claude Code](../../../../packages/subagent/subagent-claude-code/README.zh.md#exposing-the-tool) 示例显式组合提供方和工具行。
+**其他 profile。** 这些 bundle 层只插入 Host 行,因此 headless、SDK、ACP 和自定义 profile 也可以选择它们。保留提供方包而不选择其 bundle 的 profile,可以参考 [Codex](../../../../packages/subagent/subagent-codex/README.zh.md#exposing-the-tool) 或 [Claude Code](../../../../packages/subagent/subagent-claude-code/README.zh.md#exposing-the-tool) 示例组合相同的行。
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml
index 01ecc3a8b0..a7b23a204d 100644
--- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.i18n.yaml
@@ -9,8 +9,8 @@
en: 5e4b26181326018f
zh: 99bc75dcd12bf647
/start-a-fresh-agent-with-new-session/change:
- en: ab12aadb31d0d3e8
- zh: 637b446aef86456e
+ en: 45a6960304768d90
+ zh: 4b105688ffdd36e1
/start-a-fresh-agent-with-new-session/migration:
- en: 9565e66b6bcdf269
- zh: 226e470de85aa8b6
+ en: 4642b2a1aee12fb3
+ zh: 53ae60e9d612ab6b
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md
index bc735fe299..2c699ccfa2 100644
--- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.md
@@ -9,12 +9,12 @@ English | [中文](guide.zh.md)
## Change
-In Web and Desktop, New Session creates a fresh Session and Agent using the current preset definition. It previously reused an existing empty Session in the workspace, which could retain tools from before an optional bundle changed.
+In Web and Desktop, New Session creates a fresh Session and Agent using the current preset definition. It previously reused an existing empty Session in the workspace, which could retain an earlier preset definition.
-Client calls to `uiWorkspace.startSession(workspaceId)` follow the same behavior. Workspace reconnection through `openWorkspace` or `connectWorkspace` still reuses an eligible blank. Calls with draft-preparation options preserve their existing reuse and content rules. Existing Agents retain their composition.
+Client calls to `uiWorkspace.startSession(workspaceId)` follow the same behavior. Workspace reconnection through `openWorkspace` or `connectWorkspace` still reuses an eligible blank. Calls with draft-preparation options preserve their existing reuse and content rules. Existing Agents retain their preset composition; global optional tools update on their next request when a bundle is switched.
## Migration
-1. After changing optional bundles, use New Session to obtain the current tool composition. Continue in an existing Session when its retained composition is intentional.
+1. After changing a preset definition, use New Session to create an Agent with the current definition. Continue in an existing Session when its retained preset composition is intentional.
2. Client integrations that reconnect a workspace should use `openWorkspace` or `connectWorkspace`. Keep explicit draft-preparation options when preparing an existing draft.
-3. Confirm that the new Agent has the selected tools; no persisted Session files or profile patches need rewriting.
+3. Confirm that the new Agent uses the updated preset definition; no persisted Session files or profile patches need rewriting.
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md
index b3371908a1..baf065ad9c 100644
--- a/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/new-session-generations/guide.zh.md
@@ -9,12 +9,12 @@ description: "新会话会创建新的 Agent,不再复用已有空 Session。"
## 变更
-在 Web 和 Desktop 中,新会话按当前预设定义创建新的 Session 和 Agent。此前该操作会复用工作区内已有的空 Session,因而可能保留可选组合包变更前的工具。
+在 Web 和 Desktop 中,新会话按当前预设定义创建新的 Session 和 Agent。此前该操作会复用工作区内已有的空 Session,因而可能保留较早的预设定义。
-客户端调用 `uiWorkspace.startSession(workspaceId)` 采用相同行为。通过 `openWorkspace` 或 `connectWorkspace` 重连工作区时仍复用符合条件的空 Session。带草稿准备选项的调用保留已有复用与内容规则。已有 Agent 保留其组合。
+客户端调用 `uiWorkspace.startSession(workspaceId)` 采用相同行为。通过 `openWorkspace` 或 `connectWorkspace` 重连工作区时仍复用符合条件的空 Session。带草稿准备选项的调用保留已有复用与内容规则。已有 Agent 保留其预设组合;切换组合包后,全局可选工具会在其下一次请求时更新。
## 迁移
-1. 更改可选组合包后,通过新会话取得当前工具组合。需要保留原组合时,继续使用已有 Session。
+1. 更改预设定义后,通过新会话创建使用当前定义的 Agent。需要保留原预设组合时,继续使用已有 Session。
2. 重连工作区的客户端集成应使用 `openWorkspace` 或 `connectWorkspace`。准备已有草稿时保留显式草稿准备选项。
-3. 确认新 Agent 拥有已选择的工具;无需重写持久化 Session 文件或 profile patch。
+3. 确认新 Agent 使用更新后的预设定义;无需重写持久化 Session 文件或 profile patch。
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.i18n.yaml
deleted file mode 100644
index bf382c449c..0000000000
--- a/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.i18n.yaml
+++ /dev/null
@@ -1,16 +0,0 @@
-# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
-# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
-# After editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.md
-/:
- en: 25de822f0f884eb9
- zh: c910706695f91c76
-/identify-bundle-rows-by-their-preset:
- en: 95cdaba0de8fe664
- zh: f7382ff6b9f65a49
-/identify-bundle-rows-by-their-preset/change:
- en: e43402c18f6b6de8
- zh: 32f3e46546e9d1f8
-/identify-bundle-rows-by-their-preset/migration:
- en: 89cead6b9cd199bd
- zh: 8a13a234d9feacbb
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.md
deleted file mode 100644
index 225cf96268..0000000000
--- a/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-kind: upgrade-guide
-description: "Plugin-manager bundle inventories include preset-scoped rows whose row IDs are unique only within their preset."
----
-
-# Identify bundle rows by their preset
-
-English | [中文](guide.zh.md)
-
-## Change
-
-The next release adds preset-scoped declarations to `PluginManager.listBundles().rows`. A bundle can declare the same `rowId` in several presets. `PluginRowRef` values passed to detail slots carry the same optional `preset` identity. These rows carry `preset`, optional `composition` state, and `readOnlyReason: 'preset-managed'`; they do not carry an editable root `entryId`.
-
-Clients that key rows only by bundle name and `rowId`, or treat every row as a root plugin switch, must distinguish preset declarations. Existing root rows retain their current identity and controls.
-
-## Migration
-
-1. Include `preset` in row identity, alongside the bundle name and `rowId`. Keep the absent-preset case distinct from every named preset.
-2. Render `preset-managed` rows read-only in direct row controls. Use the bundle switch or an explicit scoped profile patch to configure them. Only pass a returned root `entryId` to `setPluginEnabled`.
-3. Read preset row state from `composition`, instead of looking up a root Loader entry with the same `rowId`.
-4. For YAML edits, use `preset` to address the outer declaration and `id` to address its child:
-
- ```yaml
- - preset: preset-standard
- id: optional-tool-str-replace-editor
- disabled: true
- ```
-
-5. Verify that a bundle contributing to standard, cordis, and ptc shows three distinct rows and that root-row switches continue to work. The [profile patch reference](../../../../packages/boot/app-boot/README.md) describes ordering and validation.
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.zh.md
deleted file mode 100644
index d2800b9591..0000000000
--- a/docs/upgrade-guide/v0.2.1-alpha.1/preset-bundle-rows/guide.zh.md
+++ /dev/null
@@ -1,29 +0,0 @@
----
-kind: upgrade-guide
-description: "插件管理器的组合包清单包含预设作用域行,其行 ID 仅在所属预设内唯一。"
----
-
-# 按所属预设区分组合包行
-
-[English](guide.md) | 中文
-
-## 变更
-
-下一个版本在 `PluginManager.listBundles().rows` 中加入预设作用域声明。一个组合包可以在多个预设中声明相同的 `rowId`。详情插槽中的 `PluginRowRef` 也携带可选的 `preset` 标识。这些行包含 `preset`、可选的 `composition` 状态和 `readOnlyReason: 'preset-managed'`,不包含可编辑的根级 `entryId`。
-
-仅用组合包名和 `rowId` 作为行标识,或将所有行都视为根插件开关的客户端,必须区分预设声明。已有根行保留当前标识和控制方式。
-
-## 迁移
-
-1. 将 `preset` 与组合包名、`rowId` 一起纳入行标识。区分没有预设的情况与每个具名预设。
-2. 在直接行控制中只读展示 `preset-managed` 行。通过组合包开关或显式的预设作用域 profile patch 配置这些行。只将实际返回的根级 `entryId` 传给 `setPluginEnabled`。
-3. 从 `composition` 读取预设行状态,不要按同一个 `rowId` 查找根 Loader 条目。
-4. 编辑 YAML 时,用 `preset` 定位外层声明,用 `id` 定位其子行:
-
- ```yaml
- - preset: preset-standard
- id: optional-tool-str-replace-editor
- disabled: true
- ```
-
-5. 确认同时向 standard、cordis 和 ptc 贡献行的组合包显示三个不同的行,且根行开关仍可使用。[Profile patch 参考](../../../../packages/boot/app-boot/README.zh.md)说明顺序与校验规则。
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.i18n.yaml
new file mode 100644
index 0000000000..f972f05aa0
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md
+/:
+ en: 08f294cc358eaffb
+ zh: 46ef86d9f933ea58
+/migrate-session-title-providers:
+ en: c9e33d3bdc0595c6
+ zh: 55a049680ff8ae9b
+/migrate-session-title-providers/change:
+ en: 3a8d72fb2c793d51
+ zh: 9acc89004dd31096
+/migrate-session-title-providers/migration:
+ en: ea9efdc5234c0541
+ zh: 845d80a7c6c05672
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md
new file mode 100644
index 0000000000..bf0eb87a91
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md
@@ -0,0 +1,20 @@
+---
+kind: upgrade-guide
+description: "Session-title providers register directly, own their strategy settings, and share only prepared execution."
+---
+
+# Migrate session-title providers
+
+English | [中文](guide.zh.md)
+
+## Change
+
+`@deepseek-ai/dsh-session-title-llm` no longer exports `registerSessionTitleLlmProvider`, `generateSessionTitleWithLlm`, or `SessionTitleLlmMessageSelector`. `SessionTitleLlmConfig` now holds only execution controls (`maxInputBytes`, `maxOutputTokens`, `timeoutMs`, and the optional `provider`/`model` pair); `targetWords` and `targetCjkCharacters` moved into each provider's own `Config`. Each provider owns its system instruction, message framing, title parsing, reasoning selection, and output interpretation, registers directly with `ctx.sessionTitle.register()`, and calls `executeSessionTitleLlm(ctx, config, request, providerId, { system, input, messageSeqs, selectReasoningEffort })`. That function returns the assembled content blocks, the terminal finish, and the used model route. `SessionTitleProviderRequest` gains an optional `currentTitle` snapshot.
+
+## Migration
+
+1. Replace a `registerSessionTitleLlmProvider(...)` call with a direct `ctx.sessionTitle.register({ id, automatic, generate })`; keep the same `id` and cadence.
+2. In `generate(request)`, build your own system prompt and user input, pass a `selectReasoningEffort` function, then call `executeSessionTitleLlm` with `{ system, input, messageSeqs, selectReasoningEffort }`.
+3. Reject a `tool-calls` or `max-tokens` terminal finish and tool-call blocks; derive the title from the text blocks.
+4. Read `request.currentTitle` when the strategy should preserve an accepted provider title; it is absent before any title.
+5. Generate a title and confirm the saved result plus the exact `session/title-llm-request` record.
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.zh.md
new file mode 100644
index 0000000000..9bf62d63fe
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.zh.md
@@ -0,0 +1,20 @@
+---
+kind: upgrade-guide
+description: "会话标题提供方直接注册、拥有自己的策略设置,并只共享准备好的执行。"
+---
+
+# 迁移会话标题提供方
+
+[English](guide.md) | 中文
+
+## 变更
+
+`@deepseek-ai/dsh-session-title-llm` 不再导出 `registerSessionTitleLlmProvider`、`generateSessionTitleWithLlm` 或 `SessionTitleLlmMessageSelector`。`SessionTitleLlmConfig` 现在只包含执行控制(`maxInputBytes`、`maxOutputTokens`、`timeoutMs` 以及可选的 `provider`/`model` 对);`targetWords` 与 `targetCjkCharacters` 已移入各提供方自己的 `Config`。各提供方拥有自己的系统指令、消息封装、标题解析、推理选择与输出解释,直接通过 `ctx.sessionTitle.register()` 注册,并调用 `executeSessionTitleLlm(ctx, config, request, providerId, { system, input, messageSeqs, selectReasoningEffort })`。该函数返回组装后的内容块、终止结束原因与所用模型路由。`SessionTitleProviderRequest` 新增可选的 `currentTitle` 快照。
+
+## 迁移
+
+1. 把 `registerSessionTitleLlmProvider(...)` 调用替换为直接 `ctx.sessionTitle.register({ id, automatic, generate })`;保持相同的 `id` 与节奏。
+2. 在 `generate(request)` 中构建自己的系统提示词与用户输入,传入 `selectReasoningEffort` 函数,然后用 `{ system, input, messageSeqs, selectReasoningEffort }` 调用 `executeSessionTitleLlm`。
+3. 拒绝 `tool-calls` 或 `max-tokens` 终止结束原因与工具调用块;从文本块派生标题。
+4. 当策略需要保留已接纳的提供方标题时读取 `request.currentTitle`;任何标题被接纳前该值为缺省。
+5. 生成一次标题,确认保存的结果以及确切的 `session/title-llm-request` 记录。
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.i18n.yaml
new file mode 100644
index 0000000000..4f0fc7d434
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md
+/:
+ en: ece09d74b8b3da0e
+ zh: 4762937e93aa8bf0
+/ssh-helper-and-ptc-launch-configuration:
+ en: e757aad6a0030eb3
+ zh: 2f87638444248a40
+/ssh-helper-and-ptc-launch-configuration/change:
+ en: 7e78555e386835b1
+ zh: 5a0d9de8e1312682
+/ssh-helper-and-ptc-launch-configuration/migration:
+ en: 33f5268892e099c9
+ zh: c97f51289a61d323
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md
new file mode 100644
index 0000000000..5f32a8363d
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.md
@@ -0,0 +1,19 @@
+---
+kind: upgrade-guide
+description: "SSH and Node PTC configurations use explicit launch modes, and SSH clients require a matching protocol-2 helper."
+---
+
+# SSH helper and PTC launch configuration
+
+English | [中文](guide.zh.md)
+
+## Change
+
+Custom SSH compositions must update their `cordis.yml` and installed helper together. `dsh-ssh` replaces top-level `node`, `bootstrapPath` and `bootstrapHash` with `launch`, and requires SSH protocol 2. `dsh-ptc-runtime-node` replaces `nodeExecutable` and `bootstrapPath` with `launch`. The connection's `nodeExecutable` and `bootstrapPath` getters are replaced by `ptcLaunch`.
+
+## Migration
+
+1. For a script helper, move `node`, `bootstrapPath` and `bootstrapHash` under `launch: { kind: node-script }`. Keep `helper`, `helperHash`, `host` and `workspace` at their existing locations. Install the matching helper script and update `helperHash`; update the bootstrap and its hash together when configured.
+2. For a packaged helper, extract the complete archive and set `launch: { kind: executable }`, `helper` to the executable's absolute remote path, and `helperHash` to its digest from `manifest.json`.
+3. Configure the remote PTC provider with `launch: ctx.ssh.ptcLaunch` in the composition's existing JavaScript configuration. For an explicitly configured local Node worker, use `launch: { kind: 'node-script', executable: previousNodeExecutable, bootstrapPath: previousBootstrapPath }`. Omitted local launch configuration still selects the current process's runtime.
+4. Start the configured profile and exercise a remote file read, a process command and a PTC call. A protocol or digest mismatch requires updating the installed helper and its configuration together. See the [SSH deployment requirements](../../../../packages/ssh/ssh/README.md#use-this-package).
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.zh.md
new file mode 100644
index 0000000000..eb6dca6dfa
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/ssh-helper-launch/guide.zh.md
@@ -0,0 +1,19 @@
+---
+kind: upgrade-guide
+description: "SSH 和 Node PTC 配置改用显式启动模式,SSH 客户端要求使用匹配的协议 2 helper。"
+---
+
+# SSH helper 与 PTC 启动配置
+
+[English](guide.md) | 中文
+
+## 变更
+
+自定义 SSH 组合必须一起更新 `cordis.yml` 和已安装的 helper。`dsh-ssh` 将顶层 `node`、`bootstrapPath` 和 `bootstrapHash` 替换为 `launch`,并要求 SSH 协议 2。`dsh-ptc-runtime-node` 将 `nodeExecutable` 和 `bootstrapPath` 替换为 `launch`。连接的 `nodeExecutable` 和 `bootstrapPath` getter 替换为 `ptcLaunch`。
+
+## 迁移
+
+1. 使用脚本 helper 时,将 `node`、`bootstrapPath` 和 `bootstrapHash` 移入 `launch: { kind: node-script }`。`helper`、`helperHash`、`host` 和 `workspace` 保持原位置。安装匹配的 helper 脚本并更新 `helperHash`;配置了 bootstrap 时,一起更新该文件及其摘要。
+2. 使用打包后的 helper 时,解压完整发行包,设置 `launch: { kind: executable }`,将 `helper` 设为可执行文件的远端绝对路径,将 `helperHash` 设为 `manifest.json` 中该文件的摘要。
+3. 在组合现有的 JavaScript 配置中,通过 `launch: ctx.ssh.ptcLaunch` 配置远端 PTC 提供方。显式配置本地 Node worker 时,使用 `launch: { kind: 'node-script', executable: previousNodeExecutable, bootstrapPath: previousBootstrapPath }`。省略本地启动配置时,仍选择当前进程的运行时。
+4. 启动配置好的 profile,执行一次远端文件读取、进程命令和 PTC 调用。出现协议或摘要不匹配时,需要一起更新已安装的 helper 及其配置。参见 [SSH 部署要求](../../../../packages/ssh/ssh/README.zh.md#use-this-package)。
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml
new file mode 100644
index 0000000000..0f10d46677
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md
+/:
+ en: d154c0e25274bb2f
+ zh: 7e59bdf067d2b141
+/agent-teams-messages-use-target-inboxes:
+ en: 04d7dcbdc1679eae
+ zh: 8b083641a9262d80
+/agent-teams-messages-use-target-inboxes/change:
+ en: 9968decf3dd340af
+ zh: 736a32caac4b5103
+/agent-teams-messages-use-target-inboxes/migration:
+ en: 7ffd442bfd55769e
+ zh: ffb11a143f11ce7f
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md
new file mode 100644
index 0000000000..974c1aa13c
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.md
@@ -0,0 +1,21 @@
+---
+kind: upgrade-guide
+description: "Agent Teams removes its pending-message limit and stops delivering historical queued mail."
+---
+
+# Agent Teams messages use target inboxes
+
+English | [中文](guide.zh.md)
+
+## Change
+
+The experimental Agent Teams plugin sends new messages directly to the target Agent inbox instead of retaining a separate Team mailbox. `send_message` returns only `{ "sent": true }`; it exposes neither a message ID nor an `accepted` or `queued` status. Acceptance does not confirm model processing or a synchronous storage flush. Failed sends are not retained for retry, and explicit resends are not deduplicated.
+
+The `maxPendingMessagesPerMember` configuration key is removed. Historical `team/message/queued` and `team/message/delivered` records remain readable, but queued messages that were not delivered before upgrading are never dispatched. Roster and task state remain durable.
+
+## Migration
+
+1. Before upgrading, let pending Team messages finish and confirm that their targets received them. After upgrading, explicitly resend any required undelivered message; check the target's history first to avoid duplicates. Do not edit historical Session records.
+2. Remove `maxPendingMessagesPerMember` from the `@deepseek-ai/dsh-experimental-agent-team` plugin configuration in your `cordis.yml` or overlays. There is no replacement Team queue limit; `maxMessageBytes` still bounds each complete sender-framed message.
+3. Update any consumer of `send_message` results to read `sent` instead of message IDs or delivery statuses, and handle send errors explicitly. Do not treat success as completed target work. The `ctx.agentTeams.sendMessage()` service still returns `messageId`.
+4. Start your Team-enabled profile, send a message to another member, and confirm that the tool returns `{ "sent": true }` and that the target receives it. Consult the [package reference](../../../../packages/experimental/agent-team/README.md#use-this-package) for delivery semantics.
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md
new file mode 100644
index 0000000000..c10791fbf7
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/team-direct-inbox/guide.zh.md
@@ -0,0 +1,21 @@
+---
+kind: upgrade-guide
+description: "Agent Teams 移除待投递消息数量限制,并停止投递历史排队消息。"
+---
+
+# Agent Teams 消息使用目标收件箱
+
+[English](guide.md) | 中文
+
+## 变更
+
+实验性 Agent Teams 插件将新消息直接发送到目标 Agent 收件箱,不再保留独立的 Team 邮箱。`send_message` 仅返回 `{ "sent": true }`,不暴露消息 ID,也不返回 `accepted` 或 `queued` 状态。接收成功不表示模型已处理消息,也不保证同步刷新到存储。失败的发送不会保留以供重试,显式重发也不会去重。
+
+配置键 `maxPendingMessagesPerMember` 已移除。历史 `team/message/queued` 和 `team/message/delivered` 记录仍可读取,但升级前尚未投递的排队消息永远不会被派发。成员名单与任务状态仍然持久保存。
+
+## 迁移
+
+1. 升级前,让待投递的 Team 消息完成投递,并确认目标已收到。升级后,显式重发仍需处理的未投递消息;先检查目标历史,避免重复。不要编辑历史 Session 记录。
+2. 从 `cordis.yml` 或覆盖配置中 `@deepseek-ai/dsh-experimental-agent-team` 插件的配置移除 `maxPendingMessagesPerMember`。没有替代的 Team 队列限制;`maxMessageBytes` 仍限制包含发送者标识的完整消息大小。
+3. 更新所有读取 `send_message` 结果的调用方,改为读取 `sent` 而非消息 ID 或投递状态,并显式处理发送错误。不要把发送成功视为目标工作已完成。`ctx.agentTeams.sendMessage()` 服务仍返回 `messageId`。
+4. 启动启用 Team 的 profile,向其他成员发送消息,确认工具返回 `{ "sent": true }` 且目标收到消息。投递语义参见[包参考](../../../../packages/experimental/agent-team/README.zh.md#use-this-package)。
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.i18n.yaml
new file mode 100644
index 0000000000..6e50b44007
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.i18n.yaml
@@ -0,0 +1,16 @@
+# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
+# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
+# After editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md
+/:
+ en: af5832d4e9eece99
+ zh: 8f97dad6921ed8a0
+/web-listener-and-trust-configuration-moves-to-webstartup:
+ en: de3788e700062659
+ zh: 9def0e8f38896304
+/web-listener-and-trust-configuration-moves-to-webstartup/change:
+ en: 19f901fb2974aab5
+ zh: b4ef886f85b61637
+/web-listener-and-trust-configuration-moves-to-webstartup/migration:
+ en: 30917eb37ead3e10
+ zh: fd1b26eada570dd3
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md
new file mode 100644
index 0000000000..6f18f4d7a1
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.md
@@ -0,0 +1,36 @@
+---
+kind: upgrade-guide
+description: "The Web profile replaces the `webRuntime` service and the `web-runtime` row's `trustedHosts` config with `webStartup`, and rejects a wildcard listener host."
+---
+
+# Web listener and trust configuration moves to `webStartup`
+
+English | [中文](guide.zh.md)
+
+## Change
+
+The Web profile no longer provides the `webRuntime` service, and the `web-runtime` row no longer declares a `trustedHosts` config value. A profile, overlay, or `--patch` file that injects `webRuntime` now waits for a service that never mounts, and an expression reading `ctx.webRuntime.trustedHosts` fails to evaluate, so the required Connection cannot start.
+
+The `web-startup` row provides one `webStartup` service carrying the invocation's `--trusted-host` authorities, and the `connection` row reads them from `ctx.webStartup.trustedHosts`. The listener host must also be one concrete IPv4 or IPv6 address of a local interface: `host: 0.0.0.0`, `--host ::`, and every other unspecified address are rejected at load, because binding every interface exposes the port to the network.
+
+## Migration
+
+1. Replace `inject: [webRuntime]` with `inject: [webStartup]` on every row that needs invocation trust.
+2. Replace `ctx.webRuntime.trustedHosts` expressions with `ctx.webStartup.trustedHosts`, and drop `ctx.webRuntime.lanAddresses`, which no longer exists.
+3. Rewrite the `connection` overlay for `webStartup`, and move any `trustedHosts` value that the `web-runtime` row carried into its expression. A patch replaces the matched row's whole `config`, so restate that config completely, and write an array-valued `!!js` expression as a quoted scalar:
+
+ ```yaml
+ # before
+ - id: connection
+ inject: [webRuntime]
+ config:
+ trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]"
+ # after
+ - id: connection
+ inject: [webStartup]
+ config:
+ trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]"
+ ```
+
+4. Replace a wildcard `host` with one concrete address of a local interface. The bind IP itself is accepted by the Host fence with no `--trusted-host` entry; a proxy or DNS authority still needs one.
+5. Boot with the migrated overlay: `dsh --profile web --patch ./extra.yml --no-open` must print the `dsh web:` URL line and serve. A row still waiting on `webRuntime` leaves the required Connection pending and reports an activation failure, and a rejected `host` fails at load. `dsh --profile web --patch ./extra.yml --dump-config` prints the composed patch before boot. [The Web bundle README](../../../../packages/bundle/web-app/README.md) owns the composed rows.
diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.zh.md
new file mode 100644
index 0000000000..5e9f3d211f
--- /dev/null
+++ b/docs/upgrade-guide/v0.2.1-alpha.1/web-listener-trust-config/guide.zh.md
@@ -0,0 +1,36 @@
+---
+kind: upgrade-guide
+description: "Web profile 用 `webStartup` 取代 `webRuntime` 服务与 `web-runtime` 行的 `trustedHosts` 配置,并拒绝通配监听地址。"
+---
+
+# Web 监听与信任配置迁移到 `webStartup`
+
+[English](guide.md) | 中文
+
+## 变更
+
+Web profile 不再提供 `webRuntime` 服务,`web-runtime` 行也不再声明 `trustedHosts` 配置值。仍注入 `webRuntime` 的 profile、overlay 或 `--patch` 文件会等待一个永不挂载的服务,读取 `ctx.webRuntime.trustedHosts` 的表达式则会求值失败,导致必需的 Connection 无法启动。
+
+`web-startup` 行提供唯一的 `webStartup` 服务,携带本次调用的 `--trusted-host` authority;`connection` 行通过 `ctx.webStartup.trustedHosts` 读取它们。监听 host 现在也必须是本机网卡的一个具体 IPv4 或 IPv6 地址:`host: 0.0.0.0`、`--host ::` 及其他未指定地址都会在加载时被拒绝,因为绑定所有接口会把端口暴露到网络。
+
+## 迁移
+
+1. 将所有需要调用信任的行的 `inject: [webRuntime]` 换成 `inject: [webStartup]`。
+2. 将 `ctx.webRuntime.trustedHosts` 表达式换成 `ctx.webStartup.trustedHosts`,并删除已不存在的 `ctx.webRuntime.lanAddresses`。
+3. 按 `webStartup` 改写 `connection` overlay,并把原先由 `web-runtime` 行携带的 `trustedHosts` 值并入其表达式。patch 会整体替换所匹配行的 `config`,因此必须完整重述该配置;数组值的 `!!js` 表达式要写成带引号的标量:
+
+ ```yaml
+ # before
+ - id: connection
+ inject: [webRuntime]
+ config:
+ trustedHosts: !!js "['app.internal', ...ctx.webRuntime.trustedHosts]"
+ # after
+ - id: connection
+ inject: [webStartup]
+ config:
+ trustedHosts: !!js "['app.internal', ...ctx.webStartup.trustedHosts]"
+ ```
+
+4. 将通配 `host` 换成本机网卡的一个具体地址。所绑定的 IP 会被 Host 栅栏直接接受,无需 `--trusted-host`;代理或 DNS authority 仍需显式配置。
+5. 使用迁移后的 overlay 启动:`dsh --profile web --patch ./extra.yml --no-open` 必须打印 `dsh web:` URL 行并正常服务。仍在等待 `webRuntime` 的行会让必需的 Connection 保持 pending 并报告激活失败;被拒绝的 `host` 会在加载时失败。`dsh --profile web --patch ./extra.yml --dump-config` 可在启动前打印组合后的 patch。[Web 组合包 README](../../../../packages/bundle/web-app/README.zh.md)说明这些行。
diff --git a/docs/user/guide/public-deployments.i18n.yaml b/docs/user/guide/public-deployments.i18n.yaml
index 7bd0d94673..cba21115ff 100644
--- a/docs/user/guide/public-deployments.i18n.yaml
+++ b/docs/user/guide/public-deployments.i18n.yaml
@@ -3,17 +3,17 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/public-deployments.md
/publish-the-web-ui-behind-a-reverse-proxy:
- en: 15b92e92d2526f37
- zh: b58079cf6eee78cc
+ en: 867565aa675f3fb3
+ zh: c60606b5e5bdb778
/publish-the-web-ui-behind-a-reverse-proxy/what-public-url-advertises:
en: 576292b93ccf299a
zh: ded5adc55396869c
/publish-the-web-ui-behind-a-reverse-proxy/what-the-proxy-must-do:
- en: 8dd9d910a93fe54d
- zh: 1e9e21d9949c8ab2
+ en: 2f93051763125365
+ zh: 1dd699cb84ecf034
/publish-the-web-ui-behind-a-reverse-proxy/trust-the-authority-browsers-use:
- en: 907b5f90c9860733
- zh: dcbffd3f00d6c64f
+ en: 08e5a6cc45918876
+ zh: d59178e10e915748
/publish-the-web-ui-behind-a-reverse-proxy/secure-the-external-leg:
- en: a833880da5c8bbf4
- zh: 6f9bd565b7266241
+ en: 2afac1f10e2d2e88
+ zh: f9b4b03a5aad27e1
diff --git a/docs/user/guide/public-deployments.md b/docs/user/guide/public-deployments.md
index 22bf5f8b0c..b6b4efc0ca 100644
--- a/docs/user/guide/public-deployments.md
+++ b/docs/user/guide/public-deployments.md
@@ -2,7 +2,7 @@
English | [中文](public-deployments.zh.md)
-`dsh --profile web` serves the GUI over plain HTTP on a loopback port, so a browser on another machine cannot reach it, and the process knows nothing about the address you do use. A reverse proxy in front of it owns that external leg — the public host name, TLS, and the path prefix it strips before forwarding to the listener — and `--public-url` tells DSH which address browsers use:
+`dsh --profile web` serves the GUI on a loopback port, so a browser on another machine cannot reach it, and the process knows nothing about the address you do use. A reverse proxy in front of it owns that external leg — the public host name, TLS, and the path prefix it strips before forwarding to the listener — and `--public-url` tells DSH which address browsers use:
```sh
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
@@ -17,17 +17,21 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
- **Preserve the browser-facing `Host`.** The fence compares the received `Host` against the accepted authorities, so the proxy forwards it unchanged instead of rewriting it to the listener's address.
- **Strip the mount prefix.** The listener answers origin-root routes, so a request for `/ui/api/...` must arrive as `/api/...`.
- **Forward upgrades.** Every request and WebSocket upgrade the page opens must reach the listener with its `Upgrade` and `Connection` headers intact; TLS terminates on the proxy's external leg when it serves HTTPS.
-- **Rewrite cookie scope.** The backend always issues host-only `Path=/` cookies without `Secure`; the proxy rewrites `Path` to the mount (`/ui/`) and adds `Secure` on its HTTPS leg.
+- **Rewrite cookie scope.** A backend serving plain HTTP issues host-only `Path=/` cookies without `Secure`; the proxy then rewrites `Path` to the mount (`/ui/`) and adds `Secure` on its HTTPS leg. A backend listener serving its own TLS already marks the cookie `Secure`, so the proxy only rewrites `Path`.
- **Redirect the bare mount.** `/ui/` is the only entry: it alone exchanges the launch token for the session cookie, and it or `/ui/index.html` serves the document to a browser that already holds that cookie, because the served document resolves URLs against its own directory. A request to `/ui` must arrive as `/ui/`, and the stripped backend cannot reconstruct that external path.
## Trust the authority browsers use
-The fence accepts loopback plus every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it.
+The fence accepts loopback, the listener's own bind address, and every authority `--trusted-host` names. A browser that reaches the deployment under any other authority gets 403 for every API call, however correct the proxy is, so name the browser-visible authority with `--trusted-host`; advertising it with `--public-url` is display only and does not admit it. An entry without a port matches any port, which suits a tunnel that binds a different one each time. The fence only admits the request; the launch token in the printed URL and the signed session cookie authenticate it. TLS, wherever it terminates, changes none of this: the certificate identifies the server to the browser, not the browser to the server.
Neither the advertised URL nor the fence protects the listening port itself, so restrict the port to the trusted proxy or network.
## Secure the external leg
-Terminate TLS at the proxy. An `http://` advertised root sends the launch token and the session cookie unencrypted, and an `https://` root encrypts only the browser-to-proxy leg. The printed URL carries a process credential, so share it only with intended users.
+Terminate TLS at the proxy, on the listener itself, or on both; `--public-url` remains an advertisement. An `http://` browser-facing root exposes the launch token in plaintext. An `https://` proxy root protects the browser-to-proxy leg; encryption between proxy and listener depends on the listener's TLS configuration.
-The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) owns the `--public-url` and `--trusted-host` command-line contract and the `publicUrl` and `trustedHosts` fields.
+To terminate on the listener, pass `--tls-cert` and `--tls-key`, each a path resolved against the process working directory: the certificate file holds the full chain and the key file an unencrypted PEM private key. DSH never obtains, renews, or watches certificates, and the carrier reads the files once, so replace them and reload the listener or restart the process to change them; startup fails on a missing or invalid pair and never falls back to HTTP. The browser applies its own trust store and name checks, so the certificate must cover the hostname or IP you open. The default port stays 3080. Native TLS needs no proxy, but proxy deployments and `--public-url` remain supported independently. A TLS listener marks session cookies `Secure`; keep the public browser-facing leg HTTPS so browsers can return those cookies.
+
+The printed URL carries a process credential, so share it only with intended users.
+
+The [Web app reference](../../../packages/bundle/web-app/README.md#public-deployments) documents the `--public-url`, `--trusted-host`, and `--tls-cert`/`--tls-key` command-line options. In the Web profile, `publicUrl` configures the `web-runtime` row, `trustedHosts` configures the [Connection row](../../../packages/client/connection/README.md), and `tls` configures the [webserver row](../../../docs/subsystems/web-server.md#config).
diff --git a/docs/user/guide/public-deployments.zh.md b/docs/user/guide/public-deployments.zh.md
index 54abc1e30a..2cb0e8fd4a 100644
--- a/docs/user/guide/public-deployments.zh.md
+++ b/docs/user/guide/public-deployments.zh.md
@@ -2,7 +2,7 @@
[English](public-deployments.md) | 中文
-`dsh --profile web` 在 loopback 端口上以明文 HTTP 提供 GUI,因此其他机器上的浏览器无法访问它,进程也无从得知你实际使用的地址。它前面的反向代理拥有这条外部链路——公开主机名、TLS,以及转发到监听器之前剥离的路径前缀——`--public-url` 则告诉 DSH 浏览器使用的是哪个地址:
+`dsh --profile web` 在 loopback 端口上提供 GUI,因此其他机器上的浏览器无法访问它,进程也无从得知你实际使用的地址。它前面的反向代理拥有这条外部链路——公开主机名、TLS,以及转发到监听器之前剥离的路径前缀——`--public-url` 则告诉 DSH 浏览器使用的是哪个地址:
```sh
dsh --profile web --public-url https://app.example/ui/ --trusted-host app.example
@@ -17,17 +17,21 @@ dsh --profile web --public-url https://app.example/ui/ --trusted-host app.exampl
- **保留浏览器可见的 `Host`。** 栅栏把收到的 `Host` 与被接受的 authority 比对,因此代理应原样转发,而不要改写成监听器的地址。
- **剥离挂载前缀。** 监听器应答的是 origin-root 路由,因此对 `/ui/api/...` 的请求必须以 `/api/...` 抵达。
- **转发升级请求。** 页面发出的每个请求与 WebSocket 升级都必须连同其 `Upgrade` 与 `Connection` 头抵达监听器;代理对外提供 HTTPS 时,TLS 在其外部链路上终止。
-- **改写 cookie 作用域。** 后端始终签发不带 `Secure` 的 host-only `Path=/` cookie;代理把 `Path` 改写为挂载(`/ui/`),并仅在其 HTTPS 链路上添加 `Secure`。
+- **改写 cookie 作用域。** 以明文 HTTP 提供服务的后端签发不带 `Secure` 的 host-only `Path=/` cookie;代理再把 `Path` 改写为挂载(`/ui/`),并仅在其 HTTPS 链路上添加 `Secure`。监听器自身提供 TLS 时 cookie 已带 `Secure`,代理只需改写 `Path`。
- **重定向裸挂载。** `/ui/` 是唯一入口:只有它把启动 token 换成会话 cookie;已持有该 cookie 的浏览器可由它或 `/ui/index.html` 取得文档,因为所提供的文档以自身目录解析 URL。对 `/ui` 的请求必须以 `/ui/` 抵达,而被剥离路径的后端无法重建该外部路径。
## 信任浏览器使用的 authority
-栅栏接受 loopback,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。
+栅栏接受 loopback、监听器自身的绑定地址,以及每个由 `--trusted-host` 点名的 authority。浏览器若以其他任何 authority 访问部署,无论代理多么正确,每个 API 调用都会得到 403,因此请用 `--trusted-host` 点名浏览器可见的 authority;用 `--public-url` 公告它只是展示,并不等于接纳。不带端口的条目匹配任意端口,适合每次绑定不同端口的隧道。栅栏只放行请求;打印 URL 中的启动 token 与签名会话 cookie 才完成认证。无论 TLS 在哪里终止都不改变这一切:证书只是向浏览器标识服务器,而不是向服务器标识浏览器。
无论是公告 URL 还是栅栏都不保护监听端口本身,因此请把端口限制在可信代理或网络内。
## 保护外部链路
-在代理处终止 TLS。`http://` 公告根会以明文发送启动 token 与会话 cookie,而 `https://` 根只加密浏览器到代理这一段。打印的 URL 携带进程凭据,只应与预期用户分享。
+可以在代理处、监听器本身或两者上终止 TLS;`--public-url` 仍只是公告。面向浏览器的 `http://` 根会明文暴露启动 token。`https://` 代理根保护浏览器到代理这一段;代理与监听器之间是否加密取决于监听器的 TLS 配置。
-[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)负责 `--public-url` 与 `--trusted-host` 的命令行约定,以及 `publicUrl` 与 `trustedHosts` 字段。
+要在监听器上终止 TLS,请传入 `--tls-cert` 与 `--tls-key`,两者各是相对进程工作目录解析的路径:证书文件包含完整证书链,密钥文件为不带口令的 PEM 私钥。DSH 从不获取、续期或监视证书,且载体只读取一次文件,因此更换文件后需重新加载监听器或重启进程;文件缺失或无效时启动失败,绝不回退到 HTTP。浏览器使用自己的信任库与名称校验,因此证书必须覆盖你所访问的 hostname 或 IP。默认端口仍为 3080。原生 TLS 不需要代理,但代理部署与 `--public-url` 仍分别受支持。TLS 监听器会给会话 cookie 加上 `Secure`;公开部署的浏览器侧链路应保持 HTTPS,才能让浏览器回传这些 cookie。
+
+打印的 URL 携带进程凭据,只应与预期用户分享。
+
+[Web 应用参考](../../../packages/bundle/web-app/README.zh.md#public-deployments)说明 `--public-url`、`--trusted-host` 与 `--tls-cert`/`--tls-key` 命令行选项。在 Web profile 中,`publicUrl` 配置在 `web-runtime` 行,`trustedHosts` 配置在 [Connection 行](../../../packages/client/connection/README.zh.md),`tls` 配置在 [webserver 行](../../../docs/subsystems/web-server.zh.md#config)。
diff --git a/package.json b/package.json
index 837bf1d905..26a5e5b305 100644
--- a/package.json
+++ b/package.json
@@ -246,6 +246,7 @@
"@yarnpkg/cli-dist": "4.18.0",
"eslint-plugin-sonarjs": "^4.2.0",
"execa": "^10.0.1",
+ "zod": "^4.4.3",
"extract-zip": "^2.0.1",
"fast-check": "^4.9.0",
"fflate": "^0.8.2",
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 18627b3c99..94dd4e518f 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
- en: ae2715276ec70675
- zh: 0937d1335c21eff2
+ en: d26a58a8f2406cd5
+ zh: 82135eceb9adb628
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index d5a7f2a7a7..ca922877f8 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -31,7 +31,9 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
-Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering.
+Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
+
+List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections.
Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index c6c7f51633..585c4e0363 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -31,7 +31,9 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
-每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。
+每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
+
+列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。
Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。
diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json
index 4b76abccb5..54109695b4 100644
--- a/packages/api/session-controller/package.json
+++ b/packages/api/session-controller/package.json
@@ -144,6 +144,7 @@
"@deepseek-ai/dsh-scope": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
+ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts
index 076188c1e0..08d6ad2367 100644
--- a/packages/api/session-controller/src/client/contract/sessions.ts
+++ b/packages/api/session-controller/src/client/contract/sessions.ts
@@ -91,12 +91,12 @@ export interface ISessions {
/**
* Resolve an already discovered direct-parent address without opening it.
* @param id - possible addressed child id.
- * @returns a retained or loaded-catalog address, without retaining a new selection or scope.
+ * @returns a retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined
/**
- * Load all Session projections once per connection; retry an unsuccessful initial read.
+ * Read projections without starting migration; retry failed or migration-deferred reads.
* @param sessionId - Session to inspect without opening its conversation.
* @returns completion of the current or newly started refresh.
*/
@@ -134,6 +134,8 @@ export interface ISessions {
atSeq?: number
increaseTitle?: boolean
onCreated?: (childId: SessionId) => void
+ /** False refuses source migration; omission preserves the Host default. */
+ allowMigration?: boolean
}): Promise
/**
* Borrow an already-retained Agent-scoped Context without extending its lifetime.
diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts
index b0b49311f3..e74cd9dce5 100644
--- a/packages/api/session-controller/src/client/sessions/lineage.ts
+++ b/packages/api/session-controller/src/client/sessions/lineage.ts
@@ -27,6 +27,8 @@ export interface SessionListEntry {
parentSessionId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
+ /** Format classification supplied by the Host list. */
+ formatStatus?: SessionSummary['formatStatus']
cwd?: string
/** Current host-computed projection values for list consumers. */
projectionValues?: Readonly>
diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts
index db7236c1b2..9c2df4ba0e 100644
--- a/packages/api/session-controller/src/client/sessions/manager.ts
+++ b/packages/api/session-controller/src/client/sessions/manager.ts
@@ -61,7 +61,7 @@ export interface SessionListSnapshot {
/** Shared projection values and the lifecycle of their explicit baseline read. */
export interface SessionProjectionSnapshot {
readonly values: Readonly>
- readonly state: 'idle' | 'loading' | 'ready' | 'error'
+ readonly state: 'idle' | 'loading' | 'ready' | 'migration-required' | 'error'
readonly error: RemoteFailure | null
}
@@ -153,8 +153,9 @@ export class SessionManager {
/**
* Resolve an address for breadcrumb navigation without retaining transport authority.
- * @param sessionId - possible child id in an already-loaded catalog.
- * @returns A retained or catalog-derived direct-parent address.
+ * @param sessionId - possible child id in received Session metadata.
+ * @returns A retained address, then a catalog address, then an unknown-mode address
+ * from a listed subagent header; undefined without that evidence.
*/
subagentAddress(sessionId: SessionId): SubagentAddress | undefined {
const retained = this.addresses.get(sessionId)
@@ -170,7 +171,13 @@ export class SessionManager {
}
}
}
- return undefined
+ const summary = this.summaries.find(summary => summary.sessionId === sessionId)
+ if (summary?.origin !== 'subagent' || summary.parentSessionId === undefined) return undefined
+ return {
+ parentSessionId: summary.parentSessionId,
+ childSessionId: sessionId,
+ mode: 'unknown',
+ }
}
// ---- Instance management ----
@@ -332,7 +339,7 @@ export class SessionManager {
}
/**
- * Load a complete projection baseline once per connection; retry unsuccessful reads.
+ * Read projections without starting migration; retry failed or migration-deferred reads.
* @param sessionId - Session to inspect without opening its conversation.
* @returns completion of the current or newly started read.
*/
@@ -350,13 +357,26 @@ export class SessionManager {
const result = await this.remote.session.projections({ sessionId }, controller.signal)
if (controller.signal.aborted) return
if (result.ok) {
- if (result.value !== null) {
- store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) })
+ const value = result.value
+ if (value !== null) {
+ switch (value.kind) {
+ case 'sequenced':
+ store.seed({ values: value.values, asOfSeq: sessionSeqCursor(value.asOfSeq) })
+ break
+ case 'migration-required':
+ store.applyCached(value.values)
+ break
+ default:
+ assertNever(value, 'session projections result')
+ }
} else if (store.values() === initialValues) {
// A later frame proves existence independently of an earlier missing read.
store.clear()
}
- this.projectionLoads.set(sessionId, { state: 'ready', error: null })
+ this.projectionLoads.set(sessionId, {
+ state: value?.kind === 'migration-required' ? 'migration-required' : 'ready',
+ error: null,
+ })
} else {
this.projectionLoads.set(sessionId, { state: 'error', error: result.error })
}
@@ -526,16 +546,17 @@ export class SessionManager {
* preserve it or lower it after an exact cut before the first `turn/start`;
* lineage rides parentSessionId. A child published before Workspace
* attachment fails is also reconciled into the list.
- * @param opts - source session and the optional exact inclusive boundary seq.
+ * @param opts - source session, optional exact inclusive boundary seq, and permission to start migration.
* @returns the fork result (the child session id).
*/
async fork(
- opts: { sessionId: SessionId; atSeq?: SessionSeq },
+ opts: { sessionId: SessionId; atSeq?: SessionSeq; allowMigration?: boolean },
): Promise> {
const source = this.summaries.find(s => s.sessionId === opts.sessionId)
const result = await this.remote.session.fork({
sessionId: opts.sessionId,
...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq },
+ ...opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration },
})
const childId = result.ok
? result.value.sessionId
@@ -774,6 +795,7 @@ export class SessionManager {
&& prev.blank === entry.blank
&& prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd
&& prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth
+ && prev.formatStatus === entry.formatStatus
&& prev.projectionValues === entry.projectionValues
) return prev
this.entryCache.set(entry.sessionId, entry)
@@ -813,6 +835,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
...(mutation.kind === 'upsert' ? {
agentAvailable: mutation.summary.agentAvailable,
running: mutation.summary.running,
+ ...(mutation.summary.formatStatus === undefined ? {} : { formatStatus: mutation.summary.formatStatus }),
} : {}),
...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}),
...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined
@@ -823,6 +846,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi
if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId
&& filled.origin === existing.origin && filled.blank === existing.blank
&& filled.agentAvailable === existing.agentAvailable && filled.running === existing.running
+ && filled.formatStatus === existing.formatStatus
) return [...summaries]
return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary)
}
diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts
index ae36cea19c..c10c81c475 100644
--- a/packages/api/session-controller/src/client/sessions/projection-store.ts
+++ b/packages/api/session-controller/src/client/sessions/projection-store.ts
@@ -6,8 +6,8 @@
* (`{ value, seq }`) comes from the connected Host — a follow opening
* baseline, a Session Controller `projection` frame, a list block the Host
* computed for an attached Session — and merges under **higher seq wins**
- * against other sequenced rows. A `cached` row (`{ value }`) comes from the
- * session list's view of the persisted checkpoint, carries no comparable
+ * against other sequenced rows. A `cached` row (`{ value }`) comes from
+ * a Host view of the persisted checkpoint, carries no comparable
* seq, and yields to every sequenced write. No client-side domain folding
* exists: a domain ships projection support with zero client code. Per-key
* bare observable faces feed `useProjection` (ui-renderer binds them).
@@ -166,7 +166,7 @@ export class ProjectionValueStore {
}
/**
- * Fill keys from a session-list block the Host labeled `cached`: a zero-I/O
+ * Fill keys from a Host block labeled `cached`: a zero-I/O
* view of the persisted checkpoint. A cached value lands only where no
* sequenced row exists: a connected Session has already answered for such
* a key, and the list's view of the persisted checkpoint cannot be newer
diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts
index 964105a486..dd86e7cac2 100644
--- a/packages/api/session-controller/src/client/sessions/service.ts
+++ b/packages/api/session-controller/src/client/sessions/service.ts
@@ -21,6 +21,7 @@ import { SessionManager } from './manager.ts'
import type { SessionRemotes } from './remotes.ts'
import type { SessionListPhase, SessionSearchResultItem, SessionProjectionSnapshot } from './manager.ts'
import type { Session } from './session.ts'
+import type { SessionListEntry } from './lineage.ts'
/** Session list row projected from the host list RPC plus live stream increments. */
export interface SessionSummary {
@@ -33,6 +34,8 @@ export interface SessionSummary {
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
+ /** Format classification supplied by the Host list, when available. */
+ formatStatus?: SessionListEntry['formatStatus']
/** Host running state for `ids` members; a display fallback for other rows. */
running: boolean
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
@@ -332,14 +335,14 @@ export class ClientSessions implements ISessions {
* Resolve an already discovered direct-parent address without opening it.
* Feature plugins use this to avoid Agent-bound RPCs in persisted child views.
* @param id - possible addressed child id.
- * @returns A retained or loaded-catalog address, without retaining a new selection or scope.
+ * @returns A retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope.
*/
subagentAddress(id: SessionId): SubagentAddress | undefined {
return this.manager.subagentAddress(id)
}
/**
- * Load all Session projections once per connection; retry an unsuccessful initial read.
+ * Read projections without starting migration; retry failed or migration-deferred reads.
* @param sessionId - Session to inspect without opening its conversation.
*/
refreshProjections(sessionId: SessionId): Promise {
@@ -443,7 +446,7 @@ export class ClientSessions implements ISessions {
* seq (a real event seq the caller already knows; a cut inside an open
* turn is balanced Host-side with synthetic closers, and omission selects
* the latest completed-turn prefix), and whether to increment an
- * inherited durable title before resolving.
+ * inherited durable title before resolving, plus permission to start source migration.
* @returns the child session id.
* @throws {SessionForkError} with the source id.
* @throws {Error} when a requested child-title rename fails after creation.
@@ -453,6 +456,7 @@ export class ClientSessions implements ISessions {
atSeq?: number
increaseTitle?: boolean
onCreated?: (childId: SessionId) => void
+ allowMigration?: boolean
}): Promise {
const sourceTitle = opts.increaseTitle
? this.list.getSnapshot().byId[opts.sessionId]?.title
@@ -460,6 +464,7 @@ export class ClientSessions implements ISessions {
const result = await this.manager.fork({
sessionId: opts.sessionId,
...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }),
+ ...(opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration }),
})
if (!result.ok) throw new SessionForkError(result.error, opts.sessionId)
this.projectList()
@@ -622,6 +627,7 @@ export class ClientSessions implements ISessions {
retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy,
blank: entry.blank,
updatedAt: entry.updatedAt,
+ formatStatus: entry.formatStatus,
...(entry.projectionValues === undefined
? {}
: { projectionValues: entry.projectionValues }),
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index 8e38822fbc..0ba5bf8ff0 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-file-upload'
import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
+import type {} from '@deepseek-ai/dsh-session-projection-cache'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import {
@@ -415,11 +416,19 @@ export class SessionController extends TypertRemoteService {
* Fork one cold-readable exact event prefix into a new Session. An omitted
* boundary selects the latest completed-turn prefix; an open cut receives
* synthetic fork closers.
- * @param request - source Session and optional exact inclusive event boundary.
+ * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice.
* @returns the new Session identity.
*/
@Remote('fork')
- fork(request: SessionForkRequest): Promise {
+ async fork(request: SessionForkRequest): Promise {
+ if (request.allowMigration === false && this.ctx.sessions.get(request.sessionId) === undefined) {
+ const stored = await this.ctx.get('sessionPersistence')?.stat(request.sessionId)
+ if (stored?.formatStatus === 'migration-required' && this.ctx.sessions.get(request.sessionId) === undefined) {
+ throw new RemoteError('session/migration-required', 'Open the source Session to complete migration before forking', {
+ sessionId: request.sessionId,
+ })
+ }
+ }
return this.commands.fork(request)
}
@@ -489,10 +498,10 @@ export class SessionController extends TypertRemoteService {
}
/**
- * Read all registered projections without activating an Agent.
+ * Read exact projections, returning cached hints only when migration is required.
* @param request - Session whose current values are required.
- * @param signal - cancellation for the Session observation.
- * @returns complete baseline, or null when the Session does not exist.
+ * @param signal - cancellation for the Session read.
+ * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent.
*/
@Remote('projections')
async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise {
@@ -501,16 +510,29 @@ export class SessionController extends TypertRemoteService {
throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {})
}
try {
+ signal.throwIfAborted()
+ const live = this.liveProjections(sessionId)
+ if (live !== undefined) return live
+ const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal })
+ signal.throwIfAborted()
+ const attached = this.liveProjections(sessionId)
+ if (attached !== undefined) return attached
+ if (stored === undefined) return null
+ if (stored.formatStatus === 'migration-required') {
+ const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header)
+ return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues }
+ }
using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal })
const projections = observation.projections
if (projections === undefined) {
throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {})
}
- return { asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues }
+ return { kind: 'sequenced', asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues }
} catch (error: unknown) {
- if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return null
- if (signal.aborted
- || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
+ if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
+ return this.liveProjections(sessionId) ?? null
+ }
+ if (signal.aborted || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error })
}
if (error instanceof RemoteError) throw error
@@ -518,6 +540,15 @@ export class SessionController extends TypertRemoteService {
}
}
+ private liveProjections(
+ sessionId: SessionId,
+ ): Extract | undefined {
+ const session = this.ctx.sessions.get(sessionId)
+ if (session === undefined) return undefined
+ const snapshot = this.ctx.sessionProjections.snapshot(session)
+ return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues }
+ }
+
/**
* Stream a complete live-control baseline followed by replacement frames.
* @param signal - cancellation owned by the Remote stream carrier.
diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts
index c51d119805..d7ef1ad43c 100644
--- a/packages/api/session-controller/src/list.ts
+++ b/packages/api/session-controller/src/list.ts
@@ -8,7 +8,7 @@ import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
-import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
+import { SessionQueryError, type SessionRecord, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
import {
@@ -116,6 +116,7 @@ export class ApiSessionList {
sessionId: session.id,
updatedAt: updatedAt(session.header, metadata),
agentAvailable: this.ctx.agents.get(session.id)?.session === session,
+ formatStatus: 'current',
running: this.ctx.agents.get(session.id)?.status === 'running',
blank: metadata?.blank ?? session.seq === 0,
...listFields(session.header),
@@ -133,7 +134,7 @@ export class ApiSessionList {
const records = await this.ctx.sessionQuery.listSessions(signal)
signal?.throwIfAborted()
const items: SessionSummary[] = []
- const cold: SessionHeader[] = []
+ const cold: SessionRecord[] = []
let yieldDeadline = performance.now() + this.workSliceMs
for (const record of records) {
signal?.throwIfAborted()
@@ -141,7 +142,7 @@ export class ApiSessionList {
if (live !== undefined) {
items.push(this.summaryFor(live))
} else if (record.header.cwd !== undefined) {
- cold.push(record.header)
+ cold.push(record)
}
if (performance.now() >= yieldDeadline) {
await scheduler.yield()
@@ -149,9 +150,9 @@ export class ApiSessionList {
yieldDeadline = performance.now() + this.workSliceMs
}
}
- for (const header of cold) {
+ for (const record of cold) {
signal?.throwIfAborted()
- items.push(this.summarizeCold(header))
+ items.push(this.summarizeCold(record))
if (performance.now() >= yieldDeadline) {
await scheduler.yield()
signal?.throwIfAborted()
@@ -163,13 +164,14 @@ export class ApiSessionList {
return items
}
- private summarizeCold(header: SessionHeader): SessionSummary {
+ private summarizeCold({ header, formatStatus }: SessionRecord): SessionSummary {
const projections = this.projectionsFor(header, undefined)
const metadata = projections?.values.sessionListMetadata
return {
sessionId: header.id,
updatedAt: updatedAt(header, metadata),
agentAvailable: false,
+ ...(formatStatus === undefined ? {} : { formatStatus }),
running: false,
// A large, metadata-less, or inaccessible cache miss remains unknown and visible.
blank: metadata?.blank ?? false,
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index a8f0d7e450..3455db1b9f 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/packages/api/session-controller/src/types.ts
@@ -177,6 +177,8 @@ export type QueueAction =
export interface SessionSummary {
/** Whether this Session currently owns a live Agent. */
readonly agentAvailable: boolean
+ /** Current for attached Sessions; otherwise the persistence format status when reported. */
+ readonly formatStatus?: 'current' | 'migration-required'
readonly sessionId: SessionId
readonly updatedAt: number
readonly running: boolean
@@ -224,6 +226,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'session/steer-unavailable': { readonly itemId: MessageId }
'session/title-invalid': { readonly sessionId: SessionId }
'session/fork-unavailable': { readonly sessionId: SessionId }
+ 'session/migration-required': { readonly sessionId: SessionId }
'subagent/not-found': {
readonly parentSessionId: SessionId
readonly childSessionId: SessionId
@@ -322,6 +325,8 @@ export interface SessionForkRequest {
readonly sessionId: SessionId
/** Exact inclusive source event seq; omission selects the latest completed-turn prefix. */
readonly atSeq?: number
+ /** False asks the Host to check for required migration before forking. Defaults to true. */
+ readonly allowMigration?: boolean
}
/** Identity of a newly forked Session. */
@@ -419,8 +424,11 @@ export interface SessionProjectionsRequest {
readonly sessionId: SessionId
}
-/** Complete Session projection baseline; null when the Session does not exist. */
-export type SessionProjectionsValue = SessionProjectionBaseline | null
+/** Exact projections carry their event cut; required migration returns sequence-free cache hints. Null means absent. */
+export type SessionProjectionsValue =
+ | (SessionProjectionBaseline & { readonly kind: 'sequenced' })
+ | { readonly kind: 'migration-required'; readonly values: SessionProjectionValues }
+ | null
/** One raw Session event in the Remote journal. */
export interface SessionEventEntry {
diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts
index 4da138a716..2097854412 100644
--- a/packages/api/session-controller/tests/manager.client.spec.ts
+++ b/packages/api/session-controller/tests/manager.client.spec.ts
@@ -4,6 +4,7 @@
*/
import { describe, expect, onTestFinished, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
@@ -14,6 +15,7 @@ import {
} from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import type {} from '@deepseek-ai/dsh-session-title/client'
import { SessionManager } from '../src/client/sessions/manager.ts'
+import { ClientSessions } from '../src/client/sessions/service.ts'
import type { SessionRemotes } from '../src/client/sessions/remotes.ts'
import { entries, plainTurn } from './event-script.client.ts'
import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts'
@@ -647,6 +649,7 @@ describe('list lifecycle', () => {
const items = manager.getListSnapshot().items
// Cold row: title surfaces straight from the list block — no open, no history.
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
+ expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('idle')
// A cached list block never displaces a sequenced value, whatever watermark its record holds.
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
})
@@ -741,12 +744,83 @@ describe('subagent catalogs', () => {
expect(manager.getListSnapshot().items).toEqual([])
})
+ it('prefers retained and catalog addresses to header-derived unknown modes', ({ mock, remote }) => {
+ const manager = makeManager(mock, remote)
+ onTestFinished(() => manager.dispose())
+ manager.handleSessionAdded(summary(S2, { parentSessionId: S1, origin: 'subagent' }))
+ manager.handleControlFrame({
+ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0,
+ value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
+ })
+
+ expect(manager.subagentAddress(S2)).toEqual({
+ parentSessionId: S1, childSessionId: S2, mode: 'continuable',
+ })
+ const retained: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'one-shot' }
+ manager.resolveTarget(retained)
+ expect(manager.subagentAddress(S2)).toBe(retained)
+ expect(remote.session.list).not.toHaveBeenCalled()
+ expect(remote.session.projections).not.toHaveBeenCalled()
+ })
+
+ it.for([
+ ['ordinary session', {}],
+ ['ordinary fork', { parentSessionId: S1 }],
+ ['parentless subagent', { origin: 'subagent' }],
+ ] as const)('does not derive a subagent address for an %s', ([, fields], { mock, remote }) => {
+ const manager = makeManager(mock, remote)
+ onTestFinished(() => manager.dispose())
+ manager.handleSessionAdded(summary(S2, fields))
+
+ expect(manager.subagentAddress(S2)).toBeUndefined()
+ expect(manager.subagentAddress(S1)).toBeUndefined()
+ expect(manager.resolveTarget(S2)).toBe(S2)
+ expect(manager.get(S2).getSnapshot().subagent).toBeNull()
+ expect(remote.session.list).not.toHaveBeenCalled()
+ expect(remote.session.projections).not.toHaveBeenCalled()
+ })
+
+ it('retains a header-discovered ancestor through subagent follow without discovery reads', async ({ mock, remote, start }) => {
+ const grandchild = 'header-grandchild' as SessionId
+ const descendantAddress: SubagentAddress = {
+ parentSessionId: S2, childSessionId: grandchild, mode: 'continuable',
+ }
+ const ancestorAddress: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'unknown' }
+ mock.load(sessionWorld)
+ remote.session.list.mockResolvedValueOnce(ok({ items: [
+ summary(S1),
+ summary(S2, { parentSessionId: S1, origin: 'subagent' }),
+ summary(grandchild, { parentSessionId: S2, origin: 'subagent' }),
+ ] }))
+ const client = await start()
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ const sessions = new ClientSessions(ctx, client.ctx.remote)
+ await sessions.refresh()
+ using descendant = sessions.retain(descendantAddress, { source: 'mainView' })
+ await descendant.ready
+
+ using ancestor = sessions.retain(S2, { source: 'mainView' })
+ await ancestor.ready
+
+ expect(mock.log.requests(FOLLOW)).toMatchObject([
+ { address: { kind: 'subagent', ...descendantAddress } },
+ { address: { kind: 'subagent', ...ancestorAddress } },
+ ])
+ expect(ancestor.binding.session.getSnapshot()).toMatchObject({
+ openState: 'open', subagent: { address: ancestorAddress },
+ })
+ expect(remote.session.list).toHaveBeenCalledOnce()
+ expect(remote.session.projections).not.toHaveBeenCalled()
+ expect(mock.log.requests('session/page')).toEqual([])
+ })
+
it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => {
remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent' }),
] as never[] })))
- remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: S2, mode: 'continuable', label: 'worker',
}] } })))
mock.load(sessionWorld)
@@ -859,6 +933,74 @@ describe('subagent catalogs', () => {
expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([])
})
+ it.for(['control', 'follow'] as const)('replaces cached projection hints with the %s baseline', async (source, { mock, remote, start }) => {
+ mock.load(sessionWorld)
+ remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: {
+ title: 'Cached title',
+ subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }],
+ } }))
+ const client = await start()
+ const manager = new SessionManager(client.ctx.remote)
+ onTestFinished(() => manager.dispose())
+
+ await manager.refreshProjections(S1)
+ expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
+ state: 'migration-required', error: null,
+ values: { title: 'Cached title', subagentCatalog: [{ id: S2, mode: 'unknown' }] },
+ })
+ expect(remote.session.follow).not.toHaveBeenCalled()
+ const session = manager.get(S1)
+ expect(session.projections.seqOf('subagentCatalog')).toBeUndefined()
+ const baseline = { asOfSeq: 0, values: {
+ subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' as const, label: 'Loaded child' }],
+ } }
+ if (source === 'control') {
+ manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } })
+ } else {
+ mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline })))
+ await session.open()
+ }
+
+ expect(manager.getListSnapshot().projectionsBySession[S1]?.values).toEqual(baseline.values)
+ expect(session.projections.seqOf('subagentCatalog')).toBe(0)
+ expect(session.projections.get('title')).toBeUndefined()
+ })
+
+ it.for(['control', 'follow'] as const)('keeps %s projections when a cached read arrives late', async (source, { mock, remote, start }) => {
+ mock.load(sessionWorld)
+ const response = Promise.withResolvers>>()
+ remote.session.projections.mockReturnValueOnce(response.promise)
+ const client = await start()
+ const manager = new SessionManager(client.ctx.remote)
+ const read = manager.refreshProjections(S1)
+ const baseline = { asOfSeq: 4, values: {
+ title: 'Loaded title',
+ subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable' as const, label: 'Loaded child' }],
+ } }
+ try {
+ expect(remote.session.projections).toHaveBeenCalledOnce()
+ if (source === 'control') {
+ manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } })
+ } else {
+ mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline })))
+ await manager.get(S1).open()
+ }
+ response.resolve(ok({ kind: 'migration-required', values: {
+ title: 'Cached title', subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }],
+ } }))
+ await read
+
+ expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
+ state: 'migration-required', error: null, values: baseline.values,
+ })
+ expect(manager.get(S1).projections.seqOf('subagentCatalog')).toBe(4)
+ } finally {
+ response.resolve(ok(null))
+ await read
+ await manager.dispose()
+ }
+ })
+
it('keeps newer pushed membership when the initial read returns an older baseline', async ({ mock, remote }) => {
const response = Promise.withResolvers>>()
remote.session.projections.mockImplementation(() => response.promise)
@@ -868,12 +1010,27 @@ describe('subagent catalogs', () => {
type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 5,
value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'pushed' }],
})
- response.resolve(ok({ asOfSeq: 3, values: { subagentCatalog: [] } }))
+ response.resolve(ok({ kind: 'sequenced', asOfSeq: 3, values: { subagentCatalog: [] } }))
await read
expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog?.[0]?.label).toBe('pushed')
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
})
+ it('retries a migration-deferred projection read and accepts the completed catalog', async ({ mock, remote }) => {
+ const manager = makeManager(mock, remote)
+ remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: {} }))
+ await manager.refreshProjections(S1)
+ expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
+ state: 'migration-required', values: {},
+ })
+ remote.session.projections.mockResolvedValueOnce(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
+ await manager.refreshProjections(S1)
+ expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
+ state: 'ready', values: { subagentCatalog: [] },
+ })
+ expect(remote.session.projections).toHaveBeenCalledTimes(2)
+ })
+
it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => {
const response = Promise.withResolvers>>()
remote.session.projections.mockImplementation(() => response.promise)
@@ -885,7 +1042,7 @@ describe('subagent catalogs', () => {
manager.handleSessionStatus(S1, false)
response.resolve(failure
? err(new RemoteError('gateway/internal', 'offline', {}))
- : ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
+ : ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
await read
expect(child.getSnapshot().subagent?.parentAvailable).toBe(true)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
@@ -925,7 +1082,7 @@ describe('subagent catalogs', () => {
const read = manager.refreshProjections(S1)
expect(manager.refreshProjections(S1)).toBe(read)
expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('loading')
- response.resolve(ok({ asOfSeq: 0, values: {
+ response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: {
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' }],
title: 'cold parent',
} }))
@@ -946,7 +1103,7 @@ describe('subagent catalogs', () => {
const read = manager.refreshProjections(S1)
manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1 }))
manager.handleSessionStatus(S2, true)
- response.resolve(ok({ asOfSeq: 0, values: {
+ response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: {
subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }],
} }))
await read
@@ -980,7 +1137,7 @@ describe('subagent catalogs', () => {
value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] })
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'error', values: { subagentCatalog: [{ id: S2 }] } })
- remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 2, values: { subagentCatalog: [] } })))
+ remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 2, values: { subagentCatalog: [] } })))
await manager.refreshProjections(S1)
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'ready', values: { subagentCatalog: [{ id: S2 }] } })
})
@@ -991,7 +1148,7 @@ describe('subagent catalogs', () => {
const manager = makeManager(mock, remote)
const read = manager.refreshProjections(S1)
manager.handleSessionRemoved(S1)
- response.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
+ response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
await read
expect(manager.getListSnapshot().items.some(item => item.sessionId === S1)).toBe(false)
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1)
@@ -1081,6 +1238,20 @@ describe('remaining branches', () => {
expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd')
})
+ it.for([undefined, false, true])('forwards fork migration permission %s to the Remote request', async (allowMigration, { mock, remote }) => {
+ const manager = makeManager(mock, remote)
+ onTestFinished(() => manager.dispose())
+ remote.session.fork.mockResolvedValueOnce(ok({ sessionId: S2 }))
+ const permission = allowMigration === undefined ? {} : { allowMigration }
+
+ await expect(manager.fork({ sessionId: S1, atSeq: SessionSeq(7), ...permission }))
+ .resolves.toEqual(ok({ sessionId: S2 }))
+
+ expect(remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: S1, atSeq: 7, ...permission })
+ expect(remote.session.follow).not.toHaveBeenCalled()
+ expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S2, parentSessionId: S1 })
+ })
+
it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => {
remote.session.fork.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', {
sessionId: S2, workspaceId: 'w1',
@@ -1317,7 +1488,7 @@ describe('connected generation', () => {
summary(S1),
summary(S2, { parentSessionId: S1, origin: 'subagent', running: hostRunning }),
] as never[] })))
- remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })))
+ remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })))
const manager = makeManager(mock, remote)
try {
await manager.refreshList()
@@ -1341,13 +1512,13 @@ describe('connected generation', () => {
const previous = Promise.withResolvers>>()
remote.session.projections
.mockReturnValueOnce(previous.promise)
- .mockResolvedValue(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
+ .mockResolvedValue(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
const manager = makeManager(mock, remote)
try {
const read = manager.refreshProjections(S1)
manager.handleConnected()
expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(2)
- previous.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))
+ previous.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))
await read
await vi.waitFor(() => {
expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({
@@ -1395,7 +1566,7 @@ describe('connected generation', () => {
manager.handleConnected()
expect(manager.get(S2).getSnapshot().subagent).toEqual({ address })
- parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ parent.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: S2, mode: 'continuable', label: 'worker',
}] } }))
diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts
new file mode 100644
index 0000000000..0a14c90f0a
--- /dev/null
+++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts
@@ -0,0 +1,424 @@
+/** Projection reads distinguish ordinary cold history from history that still needs migration. */
+
+import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
+import type { SessionHeader } from '@deepseek-ai/dsh-session'
+import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SessionProjectionCache, {
+ checkpointRecord,
+ projectionCacheDomainSpec,
+ type CheckpointRecord,
+} from '@deepseek-ai/dsh-session-projection-cache'
+import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title'
+import Storage from '@deepseek-ai/dsh-storage'
+import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
+import * as StorageJson from '@deepseek-ai/dsh-storage-json'
+import { subagentCatalogProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/catalog.ts'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { createSessionTestController } from './test-remote.ts'
+
+const contexts: Context[] = []
+const roots: string[] = []
+const readAudits: Array<() => void> = []
+
+afterEach(async () => {
+ try {
+ for (const audit of readAudits.splice(0)) audit()
+ } finally {
+ vi.restoreAllMocks()
+ try {
+ await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+ } finally {
+ await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
+ }
+ }
+})
+
+async function harness(withPersistence = true) {
+ const root = await mkdtemp(join(tmpdir(), 'dsh-projection-discovery-'))
+ roots.push(root)
+ const ctx = new Context()
+ contexts.push(ctx)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
+ ctx.sessionProjections.register(titleProjectionDefinition)
+ if (withPersistence) {
+ await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' })
+ const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing'))
+ readAudits.push(() => {
+ expect(list).not.toHaveBeenCalled()
+ })
+ }
+ const controller = createSessionTestController(ctx, {
+ defaultModelSelection: () => ({ provider: 'test', model: 'test' }),
+ cwd: root,
+ })
+ const list = vi.spyOn(ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected Session listing'))
+ readAudits.push(() => {
+ expect(list).not.toHaveBeenCalled()
+ })
+ return { ctx, root, controller }
+}
+
+type Harness = Awaited>
+
+async function stored(
+ fixture: Harness,
+ id: SessionId,
+ fields: Partial> = {},
+ version = SESSION_FORMAT_VERSION,
+) {
+ const header: SessionHeader = {
+ version: SESSION_FORMAT_VERSION,
+ id,
+ createdAt: 10,
+ delegationDepth: 0,
+ isSeeded: false,
+ ...fields,
+ }
+ const path = join(fixture.root, 'logs', '_no-cwd', id, `session.v${version}.jsonl`)
+ const bytes = `${JSON.stringify({ type: 'session', ...header, version })}\n`
+ await mkdir(dirname(path), { recursive: true })
+ await writeFile(path, bytes)
+ return { header, path, bytes }
+}
+
+async function seedCache(
+ fixture: Harness,
+ header: SessionHeader,
+ catalog: 'child' | 'empty' | 'absent',
+ matchingIdentity = true,
+) {
+ const rows: CheckpointRecord['rows'] = {
+ title: { ver: titleProjectionDefinition.stateVersion, seq: SessionSeq(50), val: 'cached title' },
+ }
+ if (catalog !== 'absent') {
+ const empty = subagentCatalogProjectionDefinition.init(header, SessionLogOffset(0))
+ const state = catalog === 'empty' ? empty : subagentCatalogProjectionDefinition.apply(empty, {
+ type: 'subagent/catalog',
+ seq: SessionSeq(0),
+ time: 1,
+ data: {
+ version: 1,
+ childId: SessionId('cached-child'),
+ childCreatedAt: 2,
+ mode: 'one-shot',
+ label: 'cached child',
+ },
+ })
+ const record = checkpointRecord.parse({
+ identity: { createdAt: header.createdAt },
+ rows: { subagentCatalog: { ver: subagentCatalogProjectionDefinition.stateVersion, seq: 50, val: state } },
+ })
+ Object.assign(rows, record.rows)
+ }
+ const record: CheckpointRecord = {
+ identity: {
+ formatVersion: SESSION_FORMAT_VERSION,
+ createdAt: matchingIdentity ? header.createdAt : header.createdAt + 1,
+ cwd: header.cwd,
+ isSeeded: header.isSeeded,
+ inheritedEventCount: SessionLogOffset(0),
+ },
+ rows,
+ }
+ const cacheRoot = join(fixture.root, 'cache')
+ const path = join(cacheRoot, projectionCacheDomainSpec.name, 'sessions', `${header.id}.json`)
+ const bytes = JSON.stringify({ version: projectionCacheDomainSpec.version, record })
+ await mkdir(dirname(path), { recursive: true })
+ await writeFile(path, bytes)
+ await fixture.ctx.plugin(Storage)
+ await fixture.ctx.plugin(StorageJson, { root: cacheRoot })
+ await fixture.ctx.plugin(StorageDomain, { backend: 'json' })
+ await fixture.ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
+ const write = vi.spyOn(fixture.ctx.sessionProjectionCache, 'write').mockRejectedValue(new Error('unexpected cache write'))
+ const hydrate = vi.spyOn(fixture.ctx.sessionProjectionCache, 'hydratePrepared').mockImplementation(() => {
+ throw new Error('unexpected cached hydration')
+ })
+ readAudits.push(() => {
+ expect(write).not.toHaveBeenCalled()
+ expect(hydrate).not.toHaveBeenCalled()
+ })
+ return { path, bytes }
+}
+
+describe('Session projection discovery', () => {
+ it('defers V3 migration without reading child bodies or publishing a successor', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('parent'), {}, 3)
+ const child = await stored(fixture, SessionId('child'), { origin: 'subagent', parentSession: parent.header.id, createdAt: 20 }, 3)
+ const grandchild = await stored(fixture, SessionId('grandchild'), { origin: 'subagent', parentSession: child.header.id, createdAt: 30 }, 3)
+ const fork = await stored(fixture, SessionId('ordinary-fork'), { parentSession: parent.header.id, isSeeded: true }, 3)
+ const unrelated = await stored(fixture, SessionId('unrelated'), { origin: 'subagent', parentSession: SessionId('another-parent') }, 3)
+ const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open')
+ const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession')
+
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
+ kind: 'migration-required',
+ values: {},
+ })
+ expect(open).not.toHaveBeenCalled()
+ expect(observe).not.toHaveBeenCalled()
+ expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({
+ formatStatus: 'migration-required',
+ })
+ expect(fixture.ctx.sessions.list()).toEqual([])
+ for (const source of [parent, child, grandchild, fork, unrelated]) {
+ expect(await readFile(source.path, 'utf8')).toBe(source.bytes)
+ expect(await readdir(dirname(source.path))).toEqual(['session.v3.jsonl'])
+ }
+ })
+
+ it.each(['child', 'empty'] as const)('serves a valid %s catalog cache when migration is required', async (catalog) => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('cached-parent'), {}, 3)
+ const cache = await seedCache(fixture, parent.header, catalog)
+ const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected fallback'))
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
+ kind: 'migration-required',
+ values: {
+ title: 'cached title',
+ subagentCatalog: catalog === 'empty' ? [] : [{ id: 'cached-child', createdAt: 2, mode: 'one-shot', label: 'cached child' }],
+ },
+ })
+ expect(list).not.toHaveBeenCalled()
+ expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes)
+ })
+
+ it('serves other cached hints without inventing a missing catalog', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('title-parent'), {}, 3)
+ await stored(fixture, SessionId('title-child'), { origin: 'subagent', parentSession: parent.header.id })
+ const cache = await seedCache(fixture, parent.header, 'absent')
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
+ kind: 'migration-required',
+ values: { title: 'cached title' },
+ })
+ expect(fixture.ctx.sessionProjectionCache.cachedSnapshot(parent.header)?.values).toEqual({ title: 'cached title' })
+ expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes)
+ })
+
+ it('returns no hints from an unrelated cache identity', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('recreated-parent'), {}, 3)
+ await seedCache(fixture, parent.header, 'child', false)
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({
+ kind: 'migration-required', values: {},
+ })
+ })
+
+ it.each([true, false])('returns null for an absent Session (persistence mounted: %s)', async (withPersistence) => {
+ const fixture = await harness(withPersistence)
+ const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions')
+ await expect(fixture.controller.projections({ sessionId: SessionId('missing') }, new AbortController().signal)).resolves.toBeNull()
+ expect(list).not.toHaveBeenCalled()
+ })
+
+ it('reads a current-format cold catalog from disk when its cache is absent', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('current-cold'))
+ const event = {
+ type: 'subagent/catalog', seq: 0, time: 1,
+ data: { version: 1, childId: 'current-child', childCreatedAt: 2, mode: 'continuable', label: 'worker' },
+ }
+ await writeFile(parent.path, `${parent.bytes}${JSON.stringify(event)}\n`)
+ const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open')
+ const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession')
+ const signal = new AbortController().signal
+
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)).resolves.toMatchObject({
+ kind: 'sequenced', asOfSeq: 0,
+ values: { subagentCatalog: [{ id: 'current-child', mode: 'continuable', label: 'worker' }] },
+ })
+ expect(observe).toHaveBeenCalledWith(parent.header.id, { signal })
+ expect(open).toHaveBeenCalledWith(parent.header.id, 'read', { signal })
+ expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined()
+ expect(fixture.ctx.agents.get(parent.header.id)).toBeUndefined()
+ })
+
+ it('reports read-prepared historical files as migration-required until a successor is published', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('prepared-cold'), {}, 3)
+ const observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id)
+ observation[Symbol.dispose]()
+ const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open').mockRejectedValue(new Error('unexpected body reread'))
+
+ expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({
+ formatStatus: 'migration-required',
+ })
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
+ .resolves.toEqual({ kind: 'migration-required', values: {} })
+ expect(open).not.toHaveBeenCalled()
+ expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined()
+ expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes)
+ expect(await readdir(dirname(parent.path))).toEqual(['session.v3.jsonl'])
+ })
+
+ it('reads sequenced projections after write publication replaces the disk format status', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('published-cold'), {}, 3)
+ using observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id)
+ const expected = observation.projections
+ const signal = new AbortController().signal
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal))
+ .resolves.toEqual({ kind: 'migration-required', values: {} })
+
+ const writer = await fixture.ctx.sessionPersistence.open(parent.header.id, 'write')
+ try {
+ await writer.flush()
+ } finally {
+ await writer.close()
+ }
+
+ expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({
+ formatStatus: 'current',
+ })
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal))
+ .resolves.toEqual({ kind: 'sequenced', ...expected })
+ expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined()
+ expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes)
+ // POSIX retains the write-lock inode after close; Windows uses a semaphore.
+ expect((await readdir(dirname(parent.path))).filter(name => name !== 'session.lock').sort())
+ .toEqual(['session.v3.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`].sort())
+ })
+
+ it('reports unsupported current-format events instead of deferring migration', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('corrupt-current'))
+ await writeFile(parent.path, `${parent.bytes}${JSON.stringify({ type: 'missing/plugin-event', seq: 0, time: 1, data: {} })}\n`)
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
+ .rejects.toMatchObject({ code: 'gateway/internal' })
+ })
+
+ it('reports unavailable projections from an otherwise readable observation', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('without-projections'))
+ using source = await fixture.ctx.sessionQuery.observeSession(parent.header.id)
+ const { projections: _projections, ...observation } = source
+ vi.spyOn(fixture.ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(observation)
+
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
+ .rejects.toMatchObject({ code: 'session/projections-unavailable' })
+ })
+
+ it.each([false, true])('rechecks live history when the stored source disappears after preflight (attached: %s)', async (attached) => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('missing-after-preflight'))
+ const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id)
+ vi.spyOn(fixture.ctx.sessionPersistence, 'stat')
+ .mockResolvedValueOnce(snapshot)
+ .mockImplementationOnce(() => {
+ if (attached) fixture.ctx.sessions.create(parent.header.id, { meta: parent.header })
+ return Promise.resolve(undefined)
+ })
+
+ const result = await fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)
+ expect(result).toEqual(attached
+ ? { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(fixture.ctx.sessions.get(parent.header.id)!) }
+ : null)
+ })
+
+ it('serves the live registry cut instead of a conflicting cached hint', async () => {
+ const fixture = await harness()
+ const live = fixture.ctx.sessions.create(SessionId('live'), { meta: { cwd: fixture.root } })
+ live.append('turn/start', { turn: 1 })
+ const expected = fixture.ctx.sessionProjections.snapshot(live)
+ await seedCache(fixture, live.header, 'child')
+ const cached = vi.spyOn(fixture.ctx.sessionProjectionCache, 'cachedSnapshot')
+ const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(new Error('metadata unavailable'))
+ await expect(fixture.controller.projections({ sessionId: live.id }, new AbortController().signal)).resolves.toEqual({
+ kind: 'sequenced', ...expected,
+ })
+ expect(stat).not.toHaveBeenCalled()
+ expect(cached).not.toHaveBeenCalled()
+ })
+
+ it.each([true, false])('prefers a Session attached during stat (stored header present: %s)', async (present) => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('stat-race'), {}, 3)
+ const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id)
+ const entered = Promise.withResolvers()
+ const finish = Promise.withResolvers()
+ vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(() => {
+ entered.resolve(undefined)
+ return finish.promise
+ })
+ const operation = fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)
+ try {
+ await entered.promise
+ const live = fixture.ctx.sessions.create(parent.header.id, { meta: parent.header })
+ live.append('turn/start', { turn: 1 })
+ finish.resolve(present ? snapshot : undefined)
+ await expect(operation).resolves.toEqual({ kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) })
+ } finally {
+ finish.resolve(undefined)
+ await Promise.allSettled([operation])
+ }
+ })
+
+ it('reports target metadata I/O failures instead of an empty result', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('failed-metadata'))
+ const failure = new Error('target metadata unavailable')
+ vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(failure)
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal))
+ .rejects.toMatchObject({ code: 'gateway/internal', cause: failure })
+ })
+
+ it('honors cancellation while waiting for target metadata', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('cancelled-metadata'))
+ const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id)
+ const entered = Promise.withResolvers()
+ const finish = Promise.withResolvers()
+ vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(async () => {
+ entered.resolve(undefined)
+ await finish.promise
+ return snapshot
+ })
+ const abort = new AbortController()
+ const operation = fixture.controller.projections({ sessionId: parent.header.id }, abort.signal)
+ try {
+ await entered.promise
+ abort.abort(new Error('cancelled by caller'))
+ finish.resolve(undefined)
+ await expect(operation).rejects.toMatchObject({ code: 'gateway/cancelled' })
+ } finally {
+ finish.resolve(undefined)
+ await Promise.allSettled([operation])
+ }
+ })
+
+ it('rejects an already-cancelled read before metadata access', async () => {
+ const fixture = await harness()
+ const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat')
+ await expect(fixture.controller.projections({ sessionId: SessionId('cancelled') }, AbortSignal.abort()))
+ .rejects.toMatchObject({ code: 'gateway/cancelled' })
+ expect(stat).not.toHaveBeenCalled()
+ })
+
+ it('preserves Remote failures while reading the migration cache metadata', async () => {
+ const fixture = await harness()
+ const parent = await stored(fixture, SessionId('typed-failures'), {}, 3)
+ const failure = new RemoteError('gateway/internal', 'metadata failure', {})
+ vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValueOnce(failure)
+ await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).rejects.toBe(failure)
+ })
+
+ it('rejects an empty identity without querying metadata', async () => {
+ const fixture = await harness()
+ const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat')
+ await expect(fixture.controller.projections({ sessionId: SessionId('') }, new AbortController().signal))
+ .rejects.toMatchObject({ code: 'gateway/bad-request' })
+ expect(stat).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts
index 4d86abcf9e..591dd60985 100644
--- a/packages/api/session-controller/tests/remote/session.client.ts
+++ b/packages/api/session-controller/tests/remote/session.client.ts
@@ -145,7 +145,7 @@ export const sessionWorld: RemoteTable = {
data: 'AA==',
}),
'session/page': pageRule(ok({ records: [], hasMore: false })),
- 'session/projections': ok({ asOfSeq: -1, values: { subagentCatalog: [] } }),
+ 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }),
'subagents/prompt': ok({ messageId: 'fake-message' }),
'subagents/interruptByParent': ok({ accepted: true }),
},
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index aefd8b9288..a6b7a033dd 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -77,6 +77,53 @@ function conversationEvents(): SessionEvent[] {
}
describe('sessions.list cold merge', () => {
+ it('preserves listed format status and prefers live readiness without extra persistence reads', async () => {
+ const ctx = new Context()
+ try {
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
+ const historical = header('format-historical', 100)
+ const current = header('format-current', 200)
+ const unknown = header('format-unknown', 300)
+ const shared = ctx.sessions.create(sid('format-shared'), { meta: { createdAt: 400, cwd: '/proj' } }).header
+ const liveOnly = ctx.sessions.create(sid('format-live'), { meta: { cwd: '/proj' } })
+ const snapshots: SessionPersistenceSnapshot[] = [
+ { ...statSnapshot(historical), formatStatus: 'migration-required' },
+ { ...statSnapshot(current), formatStatus: 'current' },
+ statSnapshot(unknown),
+ { ...statSnapshot(shared), formatStatus: 'migration-required' },
+ ]
+ const list = vi.fn(async () => snapshots)
+ const stat = vi.fn()
+ const open = vi.fn()
+ ctx.provide('sessionPersistence', { list, stat, open } as never)
+ const remote = createSessionTestRemote(ctx, {
+ defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
+ cwd: '/tmp',
+ })
+ const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
+ try {
+ const response = await remote.list(request({}))
+ if (!response.ok) throw new Error('list failed')
+ const byId = new Map(response.value.items.map(item => [item.sessionId, item]))
+ expect(byId.get(historical.id)).toMatchObject({ formatStatus: 'migration-required' })
+ expect(byId.get(current.id)).toMatchObject({ formatStatus: 'current' })
+ expect(byId.get(unknown.id)).toMatchObject({ sessionId: unknown.id })
+ expect(byId.get(unknown.id)).not.toHaveProperty('formatStatus')
+ expect(byId.get(shared.id)).toMatchObject({ formatStatus: 'current' })
+ expect(byId.get(liveOnly.id)).toMatchObject({ formatStatus: 'current' })
+ expect(list).toHaveBeenCalledTimes(1)
+ expect(stat).not.toHaveBeenCalled()
+ expect(open).not.toHaveBeenCalled()
+ expect(observe).not.toHaveBeenCalled()
+ } finally {
+ observe.mockRestore()
+ }
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
it('uses a predecessor title hint with zero cold stat or body reads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts
index 137eeeb190..e6ba8321ce 100644
--- a/packages/api/session-controller/tests/session-fork.host.spec.ts
+++ b/packages/api/session-controller/tests/session-fork.host.spec.ts
@@ -11,6 +11,7 @@ import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
+import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { Workspace } from '@deepseek-ai/dsh-workspace'
@@ -95,6 +96,151 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, {
})
describe('sessions.fork', () => {
+ it.each([
+ { allowMigration: undefined, status: 'current' },
+ { allowMigration: false, status: 'current' },
+ { allowMigration: false, status: undefined },
+ { allowMigration: undefined, status: 'migration-required' },
+ { allowMigration: true, status: 'migration-required' },
+ ] as const)('forks cold history with allowMigration=$allowMigration and format=$status', async ({ allowMigration, status }) => {
+ const ctx = await composed()
+ try {
+ const source = ctx.sessions.prepare(sid('current-cold'), { meta: { cwd: '/proj' } })
+ source.append('turn/start', { turn: 1 })
+ source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ const snapshot: SessionPersistenceSnapshot = {
+ header: source.header,
+ revision: SessionPersistenceRevision('test:cold-source'),
+ ...status === undefined ? {} : { formatStatus: status },
+ }
+ ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
+ list: () => Promise.resolve([source.header]),
+ stat: () => Promise.resolve(snapshot),
+ inspect: () => Promise.resolve({ meta: source.header, events: source.snapshotEvents() }),
+ }) as never)
+ const open = vi.spyOn(ctx.sessionPersistence, 'open')
+
+ const response = await remote(ctx).fork({
+ sessionId: source.id,
+ ...allowMigration === undefined ? {} : { allowMigration },
+ })
+
+ if (!response.ok) throw response.error
+ expect(open).toHaveBeenCalledWith(source.id, 'read', undefined)
+ expect(ctx.sessions.get(source.id)).toBeUndefined()
+ expect(ctx.sessions.get(response.value.sessionId)?.inheritedEventCount).toBe(2)
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('reports required migration before creating a fork when the caller disallows it', async () => {
+ const ctx = await composed()
+ try {
+ const sourceId = sid('requires-migration')
+ const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } })
+ const snapshot: SessionPersistenceSnapshot = {
+ header: source.header,
+ revision: SessionPersistenceRevision('test:requires-migration'),
+ formatStatus: 'migration-required',
+ }
+ const stat = vi.fn(async () => snapshot)
+ ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
+ const open = vi.spyOn(ctx.sessionPersistence, 'open')
+ const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
+ const create = vi.spyOn(ctx.agents, 'create')
+
+ await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
+ ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } },
+ })
+ expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId)
+ expect(open).not.toHaveBeenCalled()
+ expect(observe).not.toHaveBeenCalled()
+ expect(create).not.toHaveBeenCalled()
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('forks a resident Session without consulting disk metadata or requiring a live Agent', async () => {
+ const ctx = await composed()
+ try {
+ const source = ctx.sessions.create(sid('resident-source'), { meta: { cwd: '/proj' } })
+ source.append('turn/start', { turn: 1 })
+ source.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read')))
+ ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
+
+ const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false })
+
+ if (!result.ok) throw result.error
+ expect(stat).not.toHaveBeenCalled()
+ expect(ctx.agents.get(source.id)).toBeUndefined()
+ expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2)
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('forks a source attached while its historical format stat is pending', async () => {
+ const ctx = await composed()
+ try {
+ const sourceId = sid('attached-during-stat')
+ const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } })
+ const snapshot: SessionPersistenceSnapshot = {
+ header: source.header,
+ revision: SessionPersistenceRevision('test:attaching-source'),
+ formatStatus: 'migration-required',
+ }
+ const entered = Promise.withResolvers()
+ const finish = Promise.withResolvers()
+ const stat = vi.fn(() => {
+ entered.resolve(undefined)
+ return finish.promise
+ })
+ ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
+ const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false })
+ try {
+ await entered.promise
+ const live = ctx.sessions.create(sourceId, { meta: source.header })
+ live.append('turn/start', { turn: 1 })
+ live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+ finish.resolve(snapshot)
+
+ const result = await operation
+ if (!result.ok) throw result.error
+ expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId)
+ expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2)
+ } finally {
+ finish.resolve(undefined)
+ await Promise.allSettled([operation])
+ }
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
+ it('reports a stat failure without treating it as required migration or opening the source', async () => {
+ const ctx = await composed()
+ try {
+ const sourceId = sid('failed-source-stat')
+ const stat = vi.fn(() => Promise.reject(new Error('source metadata unavailable')))
+ ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
+ const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
+ const create = vi.spyOn(ctx.agents, 'create')
+
+ await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
+ ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' },
+ })
+
+ expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId)
+ expect(observe).not.toHaveBeenCalled()
+ expect(create).not.toHaveBeenCalled()
+ } finally {
+ await ctx.fiber.dispose()
+ }
+ })
+
it('keeps the completed manual compaction checkpoint when atSeq is omitted', async () => {
const ctx = new Context()
try {
diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts
index e25339bc93..d92a83198e 100644
--- a/packages/api/session-controller/tests/sessions-service.client.spec.ts
+++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts
@@ -79,6 +79,31 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise {
}
describe('list store projection', () => {
+ it('publishes format-status changes and preserves a newer update over an in-flight list', async ({ bench }) => {
+ const b = bench()
+ const historical = {
+ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, agentAvailable: false,
+ formatStatus: 'migration-required' as const,
+ }
+ b.mock.remote.session.list.mockResolvedValue(ok({ items: [historical] }))
+ await b.svc.refresh()
+ await Promise.resolve()
+ expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('migration-required')
+
+ const pending = Promise.withResolvers>>()
+ b.unblock.push(() => { pending.resolve(ok({ items: [historical] })) })
+ b.mock.remote.session.list.mockReturnValueOnce(pending.promise)
+ const refreshing = b.svc.refresh()
+ b.svc.handleSessionAdded({ ...historical, formatStatus: 'current' })
+ await Promise.resolve()
+ expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current')
+ pending.resolve(ok({ items: [historical] }))
+ await refreshing
+ await Promise.resolve()
+ expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current')
+ expect(b.mock.remote.session.projections).not.toHaveBeenCalled()
+ })
+
it('projects durable titles separately from cwd/id display fallbacks and parent links', async ({ bench }) => {
const b = bench()
b.svc.handleControlFrame({
@@ -730,7 +755,7 @@ describe('catalog-addressed navigation', () => {
const b = bench()
await feedList(b, [{ id: 'root' }])
b.mock.remote.session.projections.mockResolvedValue(ok({
- asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] },
+ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] },
}))
await b.svc.refreshProjections(sid('root'))
const address = b.svc.subagentAddress(sid('child'))!
@@ -761,7 +786,7 @@ describe('catalog-addressed navigation', () => {
type: 'projection', sessionId: sid('one-shot'), key: 'title', value: title, seq: 2,
})
b.mock.remote.session.projections.mockResolvedValue(ok({
- asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] },
+ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] },
}))
await b.svc.refreshProjections(sid('root'))
expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe(title ?? 'one-shot')
@@ -780,16 +805,16 @@ describe('catalog-addressed navigation', () => {
b.mock.remote.session.projections.mockImplementation((payload) => {
const { sessionId } = payload as { sessionId: SessionId }
if (sessionId === sid('root')) {
- return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('child'), mode: 'continuable', label: 'Child',
}] } }))
}
if (sessionId === sid('child')) {
- return Promise.resolve(ok({ asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1,
+ return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1,
id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
}] } }))
}
- return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
+ return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
})
await feedList(b, [
{ id: 'root' },
@@ -819,16 +844,16 @@ describe('catalog-addressed navigation', () => {
b.mock.remote.session.projections.mockImplementation((payload) => {
const { sessionId } = payload as { sessionId: SessionId }
if (sessionId === sid('root')) {
- return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('child'), mode: 'continuable', label: 'Child',
}] } }))
}
if (sessionId === sid('child')) {
- return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
+ return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1,
id: sid('grandchild'), mode: 'continuable', label: 'Grandchild',
}] } }))
}
- return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } }))
+ return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } }))
})
await feedList(b, [{ id: 'root' }])
await b.svc.refreshProjections(sid('root'))
@@ -907,6 +932,38 @@ describe('create', () => {
})
describe('fork', () => {
+ it.for([undefined, false, true])('forwards migration permission %s without opening the source', async (allowMigration, { bench }) => {
+ const b = bench()
+ b.mock.remote.session.fork.mockResolvedValueOnce(ok({ sessionId: sid('child') }))
+ const source = sid('source')
+ const permission = allowMigration === undefined ? {} : { allowMigration }
+
+ await expect(b.svc.fork({ sessionId: source, atSeq: 7, ...permission })).resolves.toBe(sid('child'))
+
+ expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, atSeq: 7, ...permission })
+ expect(b.svc.binding(source)).toBeUndefined()
+ expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
+ })
+
+ it('preserves the Host migration refusal without creating or opening a Session', async ({ bench }) => {
+ const b = bench()
+ const source = sid('source')
+ const error = new RemoteError('session/migration-required', 'migration required', { sessionId: source })
+ const onCreated = vi.fn<(childId: SessionId) => void>()
+ b.mock.remote.session.fork.mockResolvedValueOnce(err(error))
+
+ const failure = await b.svc.fork({ sessionId: source, allowMigration: false, onCreated })
+ .catch((cause: unknown) => cause)
+
+ expect(failure).toBeInstanceOf(SessionForkError)
+ expect(failure).toMatchObject({ sourceSessionId: source, rpcError: error })
+ expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, allowMigration: false })
+ expect(onCreated).not.toHaveBeenCalled()
+ expect(b.mock.remote.session.rename).not.toHaveBeenCalled()
+ expect(b.svc.list.getSnapshot().ids).toEqual([])
+ expect(b.mock.log.requests(FOLLOW)).toHaveLength(0)
+ })
+
it('propagates a failed fork without creating or retaining a child', async ({ bench }) => {
const b = bench()
const error = new RemoteError('session/not-found', 'source missing', { sessionId: sid('source') })
diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
index 4d9c4360fc..de1852991a 100644
--- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
+++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
@@ -2,12 +2,10 @@ import { Context } from '@deepseek-ai/cordis'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
-import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
-import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
-import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createSessionTestController,
installSessionReadTestServices,
@@ -41,8 +39,15 @@ const events: SessionEvent[] = [{
},
}]
+const contexts: Context[] = []
+afterEach(async () => {
+ vi.restoreAllMocks()
+ await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+})
+
async function bench() {
const ctx = new Context()
+ contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
@@ -82,20 +87,48 @@ describe('SessionController subagent catalog', () => {
}
})
- it('requests a stored parent catalog by id without loading an Agent', async () => {
+ it.each([false, true])('reads an uncached parent catalog without loading an Agent (explicit current format: %s)', async (withFormat) => {
const { ctx, controller } = await bench()
+ const stored = await ctx.sessionPersistence.stat(PARENT)
+ if (stored === undefined) throw new Error('parent fixture is missing')
+ const stat = vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce(withFormat
+ ? { ...stored, formatStatus: 'current' }
+ : stored)
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
+ const open = vi.spyOn(ctx.sessionPersistence, 'open')
+ const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('unexpected Agent resume'))
const signal = new AbortController().signal
- await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ values: { subagentCatalog: [{
- id: CHILD,
- mode: 'continuable',
- label: 'worker',
- }] } })
- expect(observe).toHaveBeenCalledOnce()
- expect(observe).toHaveBeenCalledWith(PARENT, {
- signal,
+ await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({
+ kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'continuable', label: 'worker' }] },
})
+ expect(stat).toHaveBeenCalledWith(PARENT, { signal })
+ expect(observe).toHaveBeenCalledWith(PARENT, { signal })
+ expect(open).toHaveBeenCalledWith(PARENT, 'read', { signal })
+ expect(resume).not.toHaveBeenCalled()
+ expect(ctx.sessions.get(PARENT)).toBeUndefined()
+ expect(ctx.agents.get(PARENT)).toBeUndefined()
+ })
+
+ it('uses disk migration status instead of the normalized header version to defer a cold catalog read', async () => {
+ const { ctx, controller } = await bench()
+ const stored = await ctx.sessionPersistence.stat(PARENT)
+ if (stored === undefined) throw new Error('parent fixture is missing')
+ vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce({
+ ...stored,
+ formatStatus: 'migration-required',
+ })
+ const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
+ const open = vi.spyOn(ctx.sessionPersistence, 'open')
+
+ expect(stored.header.version).toBe(SESSION_FORMAT_VERSION)
+ await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal))
+ .resolves.toEqual({ kind: 'migration-required', values: {} })
+
+ expect(observe).not.toHaveBeenCalled()
+ expect(open).not.toHaveBeenCalled()
+ expect(ctx.sessions.get(PARENT)).toBeUndefined()
+ expect(ctx.agents.get(PARENT)).toBeUndefined()
})
it('serves a live parent from its maintained registry state without folding its log', async () => {
@@ -113,7 +146,7 @@ describe('SessionController subagent catalog', () => {
const hydrate = vi.spyOn(ctx.sessionProjections, 'hydrate')
const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
- expect(result).toMatchObject({ values: { subagentCatalog: [{
+ expect(result).toMatchObject({ kind: 'sequenced', values: { subagentCatalog: [{
id: CHILD,
mode: 'one-shot',
label: 'live worker',
@@ -140,7 +173,7 @@ describe('SessionController subagent catalog', () => {
const unavailable = await controller.list({}, new AbortController().signal)
expect(unavailable.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(false)
const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal)
- expect(result).toMatchObject({ asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } })
+ expect(result).toMatchObject({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } })
})
it('does not republish a Session when its Agent outlives the Session registration', async () => {
@@ -158,6 +191,7 @@ describe('SessionController subagent catalog', () => {
it('reads registered projections without a subagent provider or catalog key', async () => {
const ctx = new Context()
+ contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
@@ -165,7 +199,8 @@ describe('SessionController subagent catalog', () => {
const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } })
const expected = ctx.sessionProjections.snapshot(session)
expect(expected.values.subagentCatalog).toBeUndefined()
- await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).resolves.toEqual(expected)
+ await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal))
+ .resolves.toEqual({ kind: 'sequenced', ...expected })
expect(ctx.agents.get(PARENT)).toBeUndefined()
})
@@ -183,36 +218,19 @@ describe('SessionController subagent catalog', () => {
})
})
- it('maps query cancellation, missing projections, and unexpected failures', async () => {
+ it('reports a live snapshot failure without falling back to stored metadata', async () => {
const { ctx, controller } = await bench()
- const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
- observe.mockRejectedValueOnce(new SessionQueryError(
- 'query cancelled',
- 'SESSION_QUERY_ABORTED',
- ))
- await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
- code: 'gateway/cancelled',
+ const parent = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } })
+ const failure = new Error('projection snapshot failed')
+ const snapshot = vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementationOnce(() => {
+ throw failure
})
-
- observe.mockRestore()
- // An observation without the catalog state means no registry served it.
- const stateless: SessionObservation = {
- source: 'prepared',
- header,
- inheritedEventCount: SessionLogOffset(0),
- events,
- cursor: SessionSeq(0),
- retain: () => stateless,
- [Symbol.dispose]: () => {},
- }
- vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(stateless)
- await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
- code: 'session/projections-unavailable',
- })
-
- vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValueOnce(new Error('storage offline'))
+ const stat = vi.spyOn(ctx.sessionPersistence, 'stat')
await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({
code: 'gateway/internal',
+ cause: failure,
})
+ expect(snapshot).toHaveBeenCalledWith(parent)
+ expect(stat).not.toHaveBeenCalled()
})
})
diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml
index df0e4f782e..20dc764eec 100644
--- a/packages/boot/app-boot/README.i18n.yaml
+++ b/packages/boot/app-boot/README.i18n.yaml
@@ -21,11 +21,11 @@
en: 2fdfe02f2ed4a258
zh: d748228ece579c4a
/deepseek-ai-dsh-app-boot/use-this-package/starting-the-app:
- en: 1099528bde0e2aee
- zh: b68772bdb9bb7856
+ en: 4a424cd401556f20
+ zh: 0b34dd18e0db6cea
/deepseek-ai-dsh-app-boot/use-this-package/profiles:
- en: 1db121100677e45f
- zh: c6a8c6316020eb1b
+ en: d50b0fdaf62353dd
+ zh: 0fccb6968602e5de
/deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration:
en: 646a0ccdd7881ad1
zh: e3696acb1573dd7b
@@ -33,11 +33,11 @@
en: c4a301652b851ce9
zh: 784871e19cd9db0a
/deepseek-ai-dsh-app-boot/use-this-package/reading-plugin-display-metadata:
- en: ebf6cb3960a5b378
- zh: 41ba129a0f86c168
+ en: a9afb263b03d112c
+ zh: 31f5e797cd953c15
/deepseek-ai-dsh-app-boot/use-this-package/startup-and-reload-failures:
- en: b2e5cfdf8847d214
- zh: 8d65771a3bd8a7c9
+ en: 09890e218ab49c84
+ zh: e3d86597e7fb1adb
/deepseek-ai-dsh-app-boot/use-this-package/telling-the-agent-where-the-harness-lives:
en: 07c459f26f4f8517
zh: 210f1e4894731b1a
diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md
index 4821673423..093c79252b 100644
--- a/packages/boot/app-boot/README.md
+++ b/packages/boot/app-boot/README.md
@@ -40,7 +40,7 @@ installFailLoud('dsh')
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
```
-`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
+`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`.
### Profiles
@@ -62,21 +62,7 @@ Your machine-local preferences also live in the Harness home:
The enabled `dsh-hmr` plugin watches the profile manifest and both user patch files, re-reads the ordered bundle layers, and applies the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) configuration writes; package operations run outside its queue. The launcher does not install HMR or watchers; disabled or absent HMR means changes require restart.
-Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows, their nested groups, and inserted preset definitions; existing-entry name assertions and replacement `config` values remain literal.
-
-A profile patch may set `preset` to an outer row id and apply the remaining ordinary patch fields inside that row's literal `config.plugins` list. Any bundle or user layer can contribute these operations. Each operation sees the current child list; later user operations take precedence, and a later whole-preset `config` replacement replaces every earlier child contribution. Ordinary outer patches retain Include's single-index lookup: children introduced by replacing a group's `config` are not addressable outer targets. Missing or malformed outer preset targets and duplicate child ids introduced by an insertion fail composition. An unmatched inner `id` warns and skips, so disabling a bundle does not invalidate an old user override of its child.
-
-```yaml
-- preset: preset-standard
- insert:
- - id: optional-string-editor
- name: '@deepseek-ai/dsh-tool-str-replace-editor'
-- preset: preset-standard
- id: optional-string-editor
- disabled: true
-```
-
-`ProfilePatch` extends the native patch fields with `preset`. `applyProfilePatches` composes these operations; `compileProfilePatches` returns ordinary Include patches. Startup, profile reload, effective-config dumps, schema inspection, and compatibility preflight share this compiler. Scoped `insert` module paths resolve beside their declaring patch, including nested groups; `!!js` remains unevaluated until each child activates. Native included-file patch lists keep their ordinary Include syntax. Loaded scoped insertions without IDs receive stable internal IDs derived from the canonical declaring file and entry position, including nested groups; ordinary anonymous root rows keep Loader’s existing behavior. Generated IDs identify contributions across reloads and retained revisions, and are implementation details rather than names to use in authored patches. Compiler failures name the declaring patch file and operation, including after profile layers are combined.
+Inserted plugin names may be absolute filesystem paths, file URLs, or package specifiers. Patch loading converts absolute paths and patch-relative `./` or `../` paths to file URLs within `insert` rows and their nested groups; existing-entry name assertions and replacement `config` values remain literal.
Before mounting profile rows, the `dsh` launcher computes one immutable runtime resolution from the installation and ordered bundle dependency graphs. Every profile launcher uses runtime resolution, including plain Node, packaged executables, and the Electron Host. It installs the runtime resolution through Node's ESM and CommonJS resolvers without creating fallback links.
@@ -98,8 +84,6 @@ The projector preserves native omission behavior by checking literal defaults ag
`resolvePluginResource(specifier, parentURL)` resolves a plugin module or exported resource to its local filesystem path through the active Node ESM resolver without evaluating it. It throws when the resolver is unavailable or the resource cannot resolve to a local file.
-`realModuleFile(path)` canonicalizes an existing module resource in Node and packaged executables. It follows physical file symlinks and preserves archive-backed resources through their canonical containing directory. Missing or inaccessible resources throw.
-
Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. For a package-root specifier, missing locale fields fall back to the accessible `package.json`; a subpath specifier never reads a `package.json`. Malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads an image data URL, even when locale text is complete: a package root uses its manifest `icon`, or `/icon` when that field is omitted; a subpath uses `/icon`. An icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format.
`ON_DEMAND_BUNDLES` names the public Official packages installed only on request. `OFFICIAL_ON_DEMAND_CATALOG` embeds their package-owned localized metadata and icons for offline discovery; `pnpm gen-official-bundle-catalog` explicitly updates generated content, and `verify-official-bundle-catalog` checks resource completeness and freshness; builds consume the committed catalog without importing provider code. The catalog contains no install target: [Plugin Manager](../plugin-manager/README.md#use-this-package) derives it from the running version and installation and uses the ordinary bundle installer. The [product-use check](../../../scripts/verify-product-use.ts) composes shipped and on-demand selections against Web without executing providers.
@@ -107,7 +91,7 @@ Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifi
### Startup and reload failures
-Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Active root rows that disappear or become literally disabled release their resources before replacement rows activate, so a singleton provider can drain in-flight work before its successor registers. Compilation and disposal checks use the same parsed root entries for each reconciliation. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update.
+Profile reconciliation returns diagnostics for unchanged inactive entries without failing an unrelated mutation. A new inactive entry, a changed configuration or fiber, or a changed diagnostic fails reconciliation; removed fibers must still finish disposal. Explicit enablement targets must activate even when their failure predates the operation. Successful reconciliation emits `app-boot/config-reload` after lifecycle settlement and diagnostic checks, including programmatic updates without HMR. The event carries no diff or parsed config. Successful reconciliation returns after lifecycle settlement and diagnostic checks; volatile-only entry changes are committed by Loader during the update.
After the Loader settles, app-boot warns when only optional entries are inactive. If an enabled required entry cannot activate, `boot()` rejects with `StartupError` after disposal. An independently owned logger exporter retains warning and error records through asynchronous disposal and is released before `boot()` settles. Its message groups all failed plugins and pending services, marks required entries, and retains original stacks, nested causes, and aggregate members. The CLI prints that message once and saves [full startup diagnostics](../../../apps/cli/reference/README.md#startup-diagnostics) before exiting with code 1; unrelated exceptions retain their normal stack output. In the table, stopping startup means disposing any mounted plugins and exiting nonzero without reporting readiness; continuing keeps successful plugins running. Later configuration HMR does not repeat the required-startup audit and does not roll back the whole update.
diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md
index 236c59d522..379d602176 100644
--- a/packages/boot/app-boot/README.zh.md
+++ b/packages/boot/app-boot/README.zh.md
@@ -40,7 +40,7 @@ installFailLoud('dsh')
const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT))
```
-`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
+`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。
### Profile
@@ -62,21 +62,7 @@ profile 导入插件前,DSH 会检查其 `peerDependencies` 中对 `@deepseek-
启用的 `dsh-hmr` 插件会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的配置写入串行化;包操作在其队列之外执行。启动器不安装 HMR 或监视器;HMR 被禁用或不存在时,更改需要重启。
-插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目、其嵌套分组和插入的 preset 定义中的绝对路径,以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
-
-profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch 字段应用到该条目的字面量 `config.plugins` 列表中。任何 bundle 或用户层都可以提供这些操作。每个操作作用于当前子条目列表;较晚的用户操作优先,而较晚的整个 preset `config` 替换会覆盖此前所有子条目贡献。普通外层 patch 保留 Include 的单次索引查找语义:替换 group 的 `config` 所引入的子条目无法作为外层目标。外层 preset 目标缺失或格式错误、插入造成子条目 id 重复都会使组合失败。内层 `id` 不匹配时只警告并跳过,因此禁用 bundle 不会使用户保留的子条目覆盖失效。
-
-```yaml
-- preset: preset-standard
- insert:
- - id: optional-string-editor
- name: '@deepseek-ai/dsh-tool-str-replace-editor'
-- preset: preset-standard
- id: optional-string-editor
- disabled: true
-```
-
-`ProfilePatch` 在原生 patch 字段上增加 `preset`。`applyProfilePatches` 组合这些操作;`compileProfilePatches` 返回普通 Include patch。启动、profile 重载、有效配置 dump、schema 检查及兼容性预检共用此编译器。限定到 preset 的 `insert` 模块路径以声明它的 patch 所在目录为基准,包括嵌套分组;`!!js` 保持不求值,直至对应子条目激活。原生被 Include 引入文件的 patch 列表仍使用普通 Include 语法。 读取的作用域插入行若未声明 ID,会依据声明文件的规范路径及条目位置获得稳定的内部 ID,嵌套组内的行也如此;普通匿名根行保留 Loader 的既有行为。生成的 ID 用于在重载和保留代际之间识别贡献项,是实现细节,不是作者应在 patch 中引用的名称。编译失败会标明声明 patch 文件和操作序号,profile 层合并后也保留这些信息。
+插入条目的插件名可以是绝对文件系统路径、文件 URL 或包标识符。patch 加载会把 `insert` 条目及其嵌套分组中的绝对路径以及相对于 patch 文件的 `./` 或 `../` 路径转换为文件 URL;对已有条目名称的断言及替换用的 `config` 值保持原样。
挂载 profile 条目前,`dsh` launcher 会从安装依赖图与有序 bundle 依赖图计算一份不可变的 runtime resolution。普通 Node、打包可执行文件与 Electron Host 等所有 profile 启动器都使用 runtime 解析,将 runtime resolution 安装到 Node 的 ESM 与 CommonJS 解析器中,不创建 fallback 链接。
@@ -98,8 +84,6 @@ profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch
`resolvePluginResource(specifier, parentURL)` 通过活动的 Node ESM 解析器将插件模块或导出资源解析为本地文件路径,不执行其代码。解析器不可用或资源无法解析为本地文件时会抛出错误。
-`realModuleFile(path)` 在 Node 和打包可执行程序中规范化已有模块资源的路径。它跟随物理文件符号链接,并通过规范化后的所在目录保留归档资源。资源不存在或无法访问时抛出错误。
-
使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。包根标识缺失的 locale 字段回退到可访问的 `package.json`;子路径标识从不读取 `package.json`。格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会加载图片 data URL:包根使用清单 `icon`,省略该字段时使用 `<包名>/icon`;子路径使用 `<标识>/icon`。图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。
`ON_DEMAND_BUNDLES` 列出仅按需安装的官方公开包。`OFFICIAL_ON_DEMAND_CATALOG` 内嵌这些包拥有的本地化元信息和图标,支持离线发现;`pnpm gen-official-bundle-catalog` 显式更新生成内容,`verify-official-bundle-catalog` 检查资源完整性与新鲜度;构建只读取已提交的目录,不导入提供者代码。目录不保存安装目标;[插件管理器](../plugin-manager/README.zh.md#use-this-package)根据当前版本和安装位置确定目标,并使用普通组合包安装器。[产品用途检查](../../../scripts/verify-product-use.ts)将随附和按需选择与 Web 组合,但不执行提供者。
@@ -107,7 +91,7 @@ profile patch 可以将 `preset` 设为外层条目 id,把其余普通 patch
### 启动与重载失败
-profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。消失或被字面量禁用的活跃根条目先释放资源,再激活替代条目,因此单实例提供方可以先排空进行中的工作,再让后继者注册。每次重组时,编译与释放检查使用同一份已解析的根条目。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。
+profile 重载返回未变化的已有故障诊断,不让无关修改因此失败。新增未激活条目、配置或 fiber 变化、诊断变化都会使重载失败;被移除的 fiber 仍须完成释放。显式启用的目标必须成功激活,即使它的故障早于本次操作。成功重载在生命周期结束及诊断检查通过后发出 `app-boot/config-reload`,包括未启用 HMR 时的程序化更新。事件不携带 diff 或解析后的配置。 成功重载在生命周期结束及诊断检查通过后返回;仅 volatile 的条目变化由 Loader 在更新过程中提交。
Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如果已启用的 required 条目无法激活,`boot()` 会在释放资源后以 `StartupError` 拒绝。独立管理生命周期的 logger exporter 会保留异步资源释放期间的警告和错误记录,并在 `boot()` 结算前释放。其消息分组列出所有失败插件和等待的服务,标记 required 条目,并保留原始堆栈、嵌套原因和聚合错误成员。CLI 仅输出该消息一次,并在保存[完整启动诊断](../../../apps/cli/reference/README.zh.md#startup-diagnostics)后以退出码 1 结束;其他异常保留正常堆栈输出。表中的“终止启动”指释放已挂载插件并以非零码退出,不报告就绪;“继续”指保留成功运行的插件。后续配置 HMR 不会再次执行 required 启动审计,也不会回滚整个更新。
diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json
index af2930239b..2050837c3b 100644
--- a/packages/boot/app-boot/package.json
+++ b/packages/boot/app-boot/package.json
@@ -37,7 +37,7 @@
"@eslint-community/regexpp": "^4.12.2",
"ajv": "^8.20.0",
"js-yaml": "^4.3.2",
- "node-addon-require-builtin": "^0.1.6",
+ "node-addon-require-builtin": "^0.1.9",
"resolve.exports": "^2.0.3",
"semver": "^7.8.5"
},
diff --git a/packages/boot/app-boot/src/compatibility-preflight.ts b/packages/boot/app-boot/src/compatibility-preflight.ts
index 1e33abe184..5b4589b117 100644
--- a/packages/boot/app-boot/src/compatibility-preflight.ts
+++ b/packages/boot/app-boot/src/compatibility-preflight.ts
@@ -7,7 +7,6 @@ import type { Context } from '@deepseek-ai/cordis'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { load } from 'js-yaml'
-import { applyProfilePatches, type ProfilePatch } from './profile-patches.ts'
import { resolvePluginResource } from './package-meta.ts'
import { barePackageName } from './profile-resolution/resolver.ts'
import type {} from './profile-resolution/service.ts'
@@ -179,10 +178,10 @@ function preflight(
* @returns One prepared insertion patch for profiles, or the original patches for non-profile callers.
*/
export function prepareProfilePatches(
- ctx: Context, patches: ProfilePatch[], parentURL: string, binName = 'dsh',
+ ctx: Context, patches: PatchOptions[], parentURL: string, binName = 'dsh',
): PatchOptions[] {
if (ctx.get('profileContext') === undefined) return patches
- const entries = applyProfilePatches([], patches, patchWarning(ctx))
+ const entries = applyEntryPatches([], patches, patchWarning(ctx))
const rows = prepareProfileEntries(ctx, entries, parentURL, binName)
return rows.length === 0 ? [] : [{ insert: rows }]
}
diff --git a/packages/boot/app-boot/src/config-schema/document.ts b/packages/boot/app-boot/src/config-schema/document.ts
index 768fb3c152..70428c6671 100644
--- a/packages/boot/app-boot/src/config-schema/document.ts
+++ b/packages/boot/app-boot/src/config-schema/document.ts
@@ -143,23 +143,13 @@ export async function buildConfigSchemaDocument(
required: ['id'], properties: { id: { const: id } },
allOf: [
{ not: { required: ['insert'] } },
- { not: { required: ['preset'] } },
{ anyOf: [{ not: { required: ['name'] } }, { properties: { name: { enum: ['', target.name] } } }] },
],
},
then: { properties: { config: { $ref: configRef } } },
})
}
- definitions.patch = {
- ...patchStructure(), allOf: [ref('entryMetadata'), ...patchRules],
- properties: {
- insert: ref('entryList'),
- preset: {
- type: 'string', pattern: '\\S',
- description: 'Outer preset row id. The remaining patch fields apply to its current config.plugins list; a missing or unaddressable preset fails composition.',
- },
- },
- }
+ definitions.patch = { ...patchStructure(), allOf: [ref('entryMetadata'), ...patchRules] }
const positions = new Map(entries.map((entry, index) => [entry.path, index]))
diagnostics.sort((left, right) => (positions.get(left.path ?? '') ?? -1) - (positions.get(right.path ?? '') ?? -1))
return {
diff --git a/packages/boot/app-boot/src/config-schema/index.ts b/packages/boot/app-boot/src/config-schema/index.ts
index e515c961f0..44bb553249 100644
--- a/packages/boot/app-boot/src/config-schema/index.ts
+++ b/packages/boot/app-boot/src/config-schema/index.ts
@@ -1,6 +1,6 @@
/** Profile schema generation: composition diagnostics, runtime resolution, and boot-free discovery. */
-import type { ProfilePatch } from '../profile-patches.ts'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { composeEntries, createRuntimeResolution, type Profile } from '../profile.ts'
import { collectConfigSchemas } from './collect.ts'
import type { ConfigSchemaDiagnostic, ConfigSchemaDump } from './types.ts'
@@ -20,7 +20,7 @@ export type { ConfigSchemaDump, NativeConfigSchema } from './types.ts'
*/
export async function generateConfigSchema(
profile: Profile,
- layers: readonly ProfilePatch[][],
+ layers: readonly PatchOptions[][],
installAnchor: string,
): Promise {
const diagnostics: ConfigSchemaDiagnostic[] = []
diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts
index 38abd1f256..d02c5d554f 100644
--- a/packages/boot/app-boot/src/index.ts
+++ b/packages/boot/app-boot/src/index.ts
@@ -13,7 +13,7 @@ import { basename, dirname, isAbsolute, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context, type FiberState } from '@deepseek-ai/cordis'
import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import Include, { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import Group from '@deepseek-ai/cordis-plugin-group'
import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
@@ -25,12 +25,9 @@ export {
setProfileVersionExemption, type ProfileCompatibility,
} from './profile-compatibility.ts'
import { prepareProfilePatches } from './compatibility-preflight.ts'
-import { applyProfilePatches, compileProfilePatches, profilePatchPreset, prepareLoadedProfilePatches, type ProfilePatch } from './profile-patches.ts'
-export { applyProfilePatches, compileProfilePatches, profilePatchPreset, type ProfilePatch, type ProfilePatchWarning } from './profile-patches.ts'
export { prepareProfileEntries, prepareProfilePatches } from './compatibility-preflight.ts'
export { readPluginMeta, resolvePluginResource } from './package-meta.ts'
export { ON_DEMAND_BUNDLES, OFFICIAL_ON_DEMAND_CATALOG, type OfficialBundleCatalogEntry } from './official-bundles.ts'
-export { realModuleFile } from './profile-resolution/legacy-links.ts'
export { generateConfigSchema, type ConfigSchemaDump, type NativeConfigSchema } from './config-schema/index.ts'
export { createConfigProjector, LOADER_EXPRESSION_SCHEMA, type ConfigProjection } from './config-schema/projector.ts'
export { isNativeConfigSchema } from './config-schema/native.ts'
@@ -276,7 +273,7 @@ const userPatchesSchema = entryListSchema
* @returns Diagnostics for unchanged pre-existing inactive entries; new or changed failures reject.
*/
export async function reconcileProfilePatches(
- ctx: Context, patches: ProfilePatch[], binName: string, requiredIds: readonly string[] = [],
+ ctx: Context, patches: PatchOptions[], binName: string, requiredIds: readonly string[] = [],
): Promise {
const entry = bootstrapIncludes.get(ctx)
if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`)
@@ -290,9 +287,7 @@ export async function reconcileProfilePatches(
const { patches: _previous, ...includeConfig } = entry.options.config as Include.Config
// The recomposition judges the rows the launch judged, resolved from the file this Include read.
const parentURL = new URL('.', new URL(includeConfig.path, entry.parent.tree.ctx.baseUrl)).href
- const initial = readRootEntries(includeConfig.path, parentURL, binName)
- const prepared = prepareRootPatches(ctx, patches, includeConfig.path, parentURL, binName, initial)
- await drainRetiringEntries(ctx, entry, initial, prepared)
+ const prepared = prepareProfilePatches(ctx, patches, parentURL, binName)
await entry.update({ config: { ...includeConfig, patches: prepared } })
const results = await Promise.allSettled(previousFibers.map(({ fiber }) => fiber.await()))
await ctx.loader.await()
@@ -308,35 +303,10 @@ export async function reconcileProfilePatches(
return failures.map(inactiveDiagnostic)
}
-/** Disappearing and literal-disabled rows release singleton services before their replacements activate. */
-async function drainRetiringEntries(
- ctx: Context, root: Entry, initial: EntryOptions[], patches: readonly ProfilePatch[],
-): Promise {
- const rows = applyProfilePatches(initial, patches)
- const next = new Map()
- const visit = (rows: EntryOptions[], outerDisabled = false): void => {
- for (const row of rows) {
- const disabled = outerDisabled || row.disabled === true
- next.set(row.id, !row.group && disabled)
- if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[], disabled)
- }
- }
- visit(rows)
- const retiring = [...ctx.loader.entries()].flatMap((entry) => {
- const fiber = entry.fiber
- return entry.parent.tree === root.subtree && fiber?.state === FIBER_ACTIVE
- && (!next.has(entry.options.id) || next.get(entry.options.id)) ? [fiber] : []
- })
- await Promise.all(retiring.map(async (fiber) => {
- await fiber.dispose()
- await fiber.await()
- }))
-}
-
/**
- * Load an optional patch-list file: a top-level YAML array of profile patch
- * entries: id-targeted overrides, `insert` lists, and preset-scoped operations,
- * with `!!js` expressions allowed. A missing
+ * Load an optional patch-list file: a top-level YAML array of loader patch
+ * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config
+ * overrides and `insert` lists, with `!!js` expressions allowed. A missing
* file means "no layer"; an unreadable, unparsable, or non-array file throws —
* a present patch file that cannot apply is a misconfiguration and must fail
* loud at boot, never be silently skipped.
@@ -344,7 +314,7 @@ async function drainRetiringEntries(
* @param file - absolute path of the patch file.
* @returns the parsed patches, or `undefined` when the file does not exist.
*/
-export function loadOptionalPatches(binName: string, file: string): ProfilePatch[] | undefined {
+export function loadOptionalPatches(binName: string, file: string): PatchOptions[] | undefined {
let content: string
try {
content = readFileSync(file, 'utf8')
@@ -364,7 +334,7 @@ export function loadOptionalPatches(binName: string, file: string): ProfilePatch
* @param file - absolute path of the overlay file.
* @returns the parsed patch list.
*/
-export function loadOverlayPatches(binName: string, file: string): ProfilePatch[] {
+export function loadOverlayPatches(binName: string, file: string): PatchOptions[] {
let content: string
try {
content = readFileSync(file, 'utf8')
@@ -375,30 +345,24 @@ export function loadOverlayPatches(binName: string, file: string): ProfilePatch[
}
/** Convert inserted filesystem paths to file URLs, anchoring relative paths beside the patch; keep assertion names literal. */
-function anchorInsertedPluginNames(patches: ProfilePatch[], file: string): ProfilePatch[] {
+function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] {
const base = dirname(resolve(file))
const visit = (entry: EntryOptions): void => {
if (typeof entry.name === 'string' && (isAbsolute(entry.name) || entry.name.startsWith('./') || entry.name.startsWith('../'))) {
entry.name = pathToFileURL(resolve(base, entry.name)).href
}
if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit)
- const config = entry.config as { plugins?: unknown } | undefined
- if (entry.name === '@deepseek-ai/dsh-agent-preset' && Array.isArray(config?.plugins)) {
- (config.plugins as EntryOptions[]).forEach(visit)
- }
- }
- for (const patch of patches) {
- patch.insert?.forEach(visit)
}
+ for (const patch of patches) patch.insert?.forEach(visit)
return patches
}
/**
* Parse one loader patch list: a top-level YAML array of
- * profile patches (id-targeted overrides, `insert` lists, and preset-scoped
- * operations, with `!!js` expressions allowed). Invalid lists and malformed
- * preset targets throw because they cannot be applied. An ordinary or scoped
- * child patch whose target is absent stays a per-entry Loader warning; a
- * missing outer preset target fails composition.
+ * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and
+ * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws,
+ * because a patch file that cannot be applied at all is a misconfiguration; a
+ * single patch whose target row is absent stays a per-entry Loader warning, so
+ * one overlay shared across surfaces does not have to match every tree.
* @param binName - the diagnostic prefix on the thrown error.
* @param file - the source path, quoted in errors.
* @param content - the file's text.
@@ -407,7 +371,7 @@ function anchorInsertedPluginNames(patches: ProfilePatch[], file: string): Profi
*/
function parsePatchList(
binName: string, file: string, content: string, label: string,
-): ProfilePatch[] {
+): PatchOptions[] {
let parsed: unknown
try {
parsed = yaml.load(content, { schema: userPatchesSchema })
@@ -422,7 +386,7 @@ function parsePatchList(
throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
}
})
- return anchorInsertedPluginNames(prepareLoadedProfilePatches(parsed as ProfilePatch[], file, binName), file)
+ return anchorInsertedPluginNames(parsed as PatchOptions[], file)
}
/** One overlay patch list with the source label printed in dump comments. */
@@ -430,13 +394,13 @@ export interface ConfigDumpLayer {
/** Source name shown in dump comments (a file basename or path). */
label: string
/** The layer's patches, from {@link loadOverlayPatches} / {@link loadOptionalPatches}. */
- patches: ProfilePatch[]
+ patches: PatchOptions[]
}
/**
* Compose the configured entry list: parse the base config file with the
- * include's entry-list dialect, compile preset operations, then apply the
- * flattened native patch list through Include's `applyEntryPatches` — the
+ * include's entry-list dialect, apply every layer's patches as ONE flattened
+ * list through the include's own patch algorithm (`applyEntryPatches`) — the
* same call `boot()` makes, so even patch-visibility corner cases (a later
* layer targeting a group child a plain config replacement introduced, which
* the single-pass id index never sees) compose identically — then render the
@@ -456,8 +420,8 @@ export interface ConfigDumpLayer {
* A patch that matches no row is reported through `warn` with its layer
* label, mirroring the Loader's boot-time warning. Earlier layers' patches
* see an identical preceding state in every snapshot that includes them, so
- * additional warnings belong to the added layer. Warning multiplicity is
- * preserved when scoped and ordinary diagnostics have different ordering.
+ * each snapshot's warning list extends the previous one and the new tail
+ * belongs to the added layer.
* @param binName - the diagnostic prefix on read/parse errors.
* @param absoluteConfigPath - the base config file `boot()` would include.
* @param layers - overlay layers in application order (later wins).
@@ -490,14 +454,16 @@ export function renderConfigDump(
// YAML parsing yields untyped rows; the include validates each entry
// at mount, and the dump prints whatever the file holds, so `EntryOptions`
// here is structural trust in the same file `boot()` would include.
- const base = parsed as EntryOptions[]
+ const base = parsed as Parameters[0]
// snapshot_k = ONE application of layers 1..k flattened, using the exact
// arguments boot passes for that prefix. snapshot_N is the mounted composition.
- // The compiler clones patch objects for each snapshot: native insertions retain
- // references, so sharing them would let later snapshots mutate earlier results.
- const snapshot = (count: number, warnings: string[]): EntryOptions[] => {
- const flattened = layers.slice(0, count).flatMap(layer => layer.patches)
- return applyProfilePatches(base, flattened, (message: string, ...args: unknown[]) => {
+ // The patches are cloned per call: applyEntryPatches detaches the entry
+ // list but pushes `insert` rows by reference from the patch list, so
+ // sharing patch objects across snapshot calls would leak a later
+ // snapshot's mutations into an earlier one's result.
+ const snapshot = (count: number, warnings: string[]): ReturnType => {
+ const flattened = structuredClone(layers.slice(0, count).flatMap(layer => layer.patches))
+ return applyEntryPatches(base, flattened, (message: string, ...args: unknown[]) => {
// The include logs through cordis's printf-style logger (`%C` = code); a
// dump has no logger, so substitute inline for a plain line.
let index = 0
@@ -514,11 +480,8 @@ export function renderConfigDump(
if (layer === undefined) continue
const warnings: string[] = []
composed = snapshot(count, warnings)
- const remaining = [...previousWarnings]
- for (const line of warnings) {
- const previousIndex = remaining.indexOf(line)
- if (previousIndex >= 0) remaining.splice(previousIndex, 1)
- else warn(`${binName}: [${layer.label}] ${line}`)
+ for (const line of warnings.slice(previousWarnings.length)) {
+ warn(`${binName}: [${layer.label}] ${line}`)
}
const before = previous.map(entry => JSON.stringify(entry))
for (let index = 0; index < composed.length; index += 1) {
@@ -562,22 +525,6 @@ function groupedDump(
return lines.join('\n') + '\n'
}
-/** Compile profile operations before passing an ordinary patch list to the root Include. */
-function readRootEntries(configPath: string, parentURL: string, binName: string): EntryOptions[] {
- const parsed: unknown = yaml.load(readFileSync(new URL(configPath, parentURL), 'utf8'), { schema: entryListSchema })
- if (!Array.isArray(parsed)) throw new Error(`${binName}: config ${configPath} must be a top-level array of entries`)
- return parsed as EntryOptions[]
-}
-
-function prepareRootPatches(
- ctx: Context, patches: ProfilePatch[], configPath: string, parentURL: string, binName: string, initial?: EntryOptions[],
-): ProfilePatch[] {
- if (ctx.get('profileContext') !== undefined) return prepareProfilePatches(ctx, patches, parentURL, binName)
- if (!patches.some(patch => profilePatchPreset(patch) !== undefined)) return patches
- return compileProfilePatches(initial ?? readRootEntries(configPath, parentURL, binName), patches,
- (message, ...args) => { ctx.logger.warn(message, ...args) })
-}
-
/**
* Mount and remember the exact root Include entry used by app boot and user patch-layer HMR.
* @param ctx - context carrying an initialized Loader service.
@@ -593,7 +540,7 @@ function prepareRootPatches(
export async function mountRootInclude(
ctx: Context,
absoluteConfigPath: string,
- patches: readonly ProfilePatch[] = [],
+ patches: readonly PatchOptions[] = [],
bareModuleBaseUrl?: string,
binName = 'dsh',
): Promise {
@@ -621,7 +568,7 @@ export async function mountRootInclude(
// diagnostics unstable across runs (and snapshot fixtures).
// The launcher's own copy is prepared here: compatibility decisions must be made before the root
// Include imports anything, and they change no profile patch layer, manifest, or bundle list.
- const prepared = prepareRootPatches(ctx, [...patches], pathToFileURL(absoluteConfigPath).href, pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName)
+ const prepared = prepareProfilePatches(ctx, [...patches], pathToFileURL(dirname(absoluteConfigPath)).href + '/', binName)
const includeConfig: Include.Config = {
path: pathToFileURL(absoluteConfigPath).href,
...prepared.length > 0 ? { patches: prepared } : {},
@@ -801,6 +748,7 @@ const FIBER_DISPOSED = 4 as FiberState.DISPOSED
const requiredStartupEntryIds = new Set([
'agent-loop',
'webserver',
+ 'web-runtime',
'modules',
'connection',
'headless-runner',
@@ -1027,7 +975,7 @@ export async function auditStartupEntries(
export async function boot(
binName: string,
absoluteConfigPath: string,
- patches?: ProfilePatch[],
+ patches?: PatchOptions[],
prepare?: (ctx: Context) => Promise | void,
bareModuleBaseUrl?: string,
): Promise {
diff --git a/packages/boot/app-boot/src/profile-context.ts b/packages/boot/app-boot/src/profile-context.ts
index 4f299ae699..328b543e00 100644
--- a/packages/boot/app-boot/src/profile-context.ts
+++ b/packages/boot/app-boot/src/profile-context.ts
@@ -2,7 +2,7 @@
import { join } from 'node:path'
import { composeEntries, loadProfileDirectory, PROFILE_PATCH_FILENAME, type Profile } from './profile.ts'
import { loadOptionalPatches } from './index.ts'
-import { cloneProfilePatches, type ProfilePatch } from './profile-patches.ts'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
/** Application-owned package manager executable; environment applies only to package operations. */
export interface ProfilePnpmInvocation {
@@ -24,7 +24,7 @@ export interface ProfileContext {
/** Bundle packages used to start this process, before any persisted edits. */
readonly startedBundles: readonly string[]
/** Parsed command-line overlays, applied above profile and home patches. */
- readonly overlays: readonly ProfilePatch[]
+ readonly overlays: readonly PatchOptions[]
/** Launch-time DSH_TELEMETRY_DISABLED value; any non-empty value opts out. */
readonly telemetryDisabledEnv: string | undefined
}
@@ -49,7 +49,7 @@ const TELEMETRY_ROW_ID = 'session-telemetry-otel'
* @param hasRow - whether the composition carries the telemetry row.
* @returns the disable patch, or `undefined` when no hard-disable patch is required.
*/
-export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): ProfilePatch | undefined {
+export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '' || !hasRow) return undefined
return { id: TELEMETRY_ROW_ID, disabled: true }
}
@@ -60,9 +60,9 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b
* @param initialProfile Already loaded startup profile; omitted reads the current files.
* @returns Detached ordered patches; this function does not update the Loader.
*/
-export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): ProfilePatch[] {
+export function readProfilePatches(binName: string, context: ProfileContext, initialProfile?: Profile): PatchOptions[] {
const profile = initialProfile ?? loadProfileDirectory(binName, context.dir, context.installAnchor, { userLayer: false })
- const patches = cloneProfilePatches([
+ const patches = structuredClone([
...profile.layers.flatMap(layer => layer.patches),
...(initialProfile?.patches ?? loadOptionalPatches(binName, context.patchPath) ?? []),
...(loadOptionalPatches(binName, join(context.home, PROFILE_PATCH_FILENAME)) ?? []),
diff --git a/packages/boot/app-boot/src/profile-patches.ts b/packages/boot/app-boot/src/profile-patches.ts
deleted file mode 100644
index d7f34ab539..0000000000
--- a/packages/boot/app-boot/src/profile-patches.ts
+++ /dev/null
@@ -1,162 +0,0 @@
-/** Compile preset-scoped profile operations to the native Include patch language. */
-
-import { createHash } from 'node:crypto'
-import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
-import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { realModuleFile } from './profile-resolution/legacy-links.ts'
-
-/** A native entry patch, optionally applied inside one preset row's `config.plugins`. */
-export interface ProfilePatch extends PatchOptions {
- /** Literal id of an addressable outer preset row; `id` then addresses one of its child rows. */
- preset?: string
-}
-
-interface PatchSource { readonly binName: string; readonly file: string; readonly operation: number }
-const patchSources = new WeakMap()
-
-function sourcedError(patch: ProfilePatch, error: unknown): unknown {
- const source = patchSources.get(patch)
- return source === undefined ? error : new Error(
- `${source.binName}: ${error instanceof Error ? error.message : String(error)} in ${source.file} (patch ${source.operation + 1})`,
- { cause: error },
- )
-}
-
-/** Clone patch data while preserving the file attribution held outside its serialized fields.
- * @param patches Complete ordered patches, possibly loaded from files.
- * @returns Detached patches with the same diagnostic sources and shared-reference relationships.
- */
-export function cloneProfilePatches(patches: readonly ProfilePatch[]): ProfilePatch[] {
- const copied = structuredClone([...patches])
- const sources = patches.map(patch => patchSources.get(patch))
- copied.forEach((patch, index) => {
- const source = sources[index]
- if (source !== undefined) patchSources.set(patch, source)
- })
- return copied
-}
-
-/** Attach source diagnostics and stable internal identities to parsed scoped insertions.
- * @param patches Parsed file patches, modified before they reach any composition consumer.
- * @param file Declaring patch file; its canonical path distinguishes anonymous contributions.
- * @param binName Diagnostic prefix.
- * @returns The same patches, with anonymous scoped rows identified by file and position.
- * @throws When a preset target is malformed, naming its declaring file and operation.
- */
-export function prepareLoadedProfilePatches(patches: ProfilePatch[], file: string, binName: string): ProfilePatch[] {
- const canonical = realModuleFile(file)
- patches.forEach((patch, operation) => {
- patchSources.set(patch, { binName, file, operation })
- try {
- if (profilePatchPreset(patch) === undefined || patch.insert === undefined) return
- const identify = (rows: EntryOptions[], parent: readonly number[]): void => {
- rows.forEach((row, index) => {
- const position = [...parent, index]
- if (!row.id) row.id = `__dsh_profile_${createHash('sha256').update(JSON.stringify([canonical, operation, position])).digest('hex').slice(0, 32)}`
- if (row.group && Array.isArray(row.config)) identify(row.config as EntryOptions[], position)
- })
- }
- identify(patch.insert, [])
- } catch (error) {
- throw sourcedError(patch, error)
- }
- })
- return patches
-}
-
-/** Native Include diagnostic arguments.
- * @param message Diagnostic format; `%C` marks a value rendered as code.
- * @param args Values substituted into the diagnostic format.
- */
-export type ProfilePatchWarning = (message: string, ...args: unknown[]) => void
-
-function visitRows(rows: readonly EntryOptions[], visit: (row: EntryOptions) => void): void {
- for (const row of rows) {
- visit(row)
- if (row.group && Array.isArray(row.config)) visitRows(row.config as EntryOptions[], visit)
- }
-}
-
-/** Validate the profile-only target while keeping native patch fields under Include's existing rules.
- * @param patch Parsed or programmatically supplied profile patch.
- * @returns The literal outer row id, or undefined for an ordinary patch.
- * @throws When a declared preset target is not a nonempty literal string.
- */
-export function profilePatchPreset(patch: ProfilePatch): string | undefined {
- if (!Object.hasOwn(patch, 'preset')) return undefined
- if (typeof patch.preset !== 'string' || patch.preset.trim() === '') {
- throw new Error('profile patch preset must be a nonempty literal row id')
- }
- return patch.preset
-}
-
-function targetOf(initial: EntryOptions[], patches: PatchOptions[], preset: string): EntryOptions {
- // The native index retains replaced-away children and never indexes replacement-created children.
- // A marker override locates its actual reachable target without rebuilding that index from the result.
- const marker = {}
- const rows = applyEntryPatches(initial, [...structuredClone(patches), { id: preset, inject: marker }], () => {})
- let target: EntryOptions | undefined
- visitRows(rows, (row) => { if (row.inject === marker) target = row })
- if (target === undefined) throw new Error(`profile patch preset ${JSON.stringify(preset)} is missing or not addressable`)
- const config: unknown = target.config
- if (config === null || typeof config !== 'object' || Array.isArray(config) || !Array.isArray(Reflect.get(config, 'plugins'))) {
- throw new Error(`profile patch preset ${JSON.stringify(preset)} must have a literal config.plugins entry list`)
- }
- return target
-}
-
-function assertInsertedIds(preset: string, rows: readonly EntryOptions[]): void {
- const ids = new Set()
- visitRows(rows, (row) => {
- if (!row.id) return
- if (ids.has(row.id)) throw new Error(`profile patch preset ${JSON.stringify(preset)} has duplicate entry id ${JSON.stringify(row.id)}`)
- ids.add(row.id)
- })
-}
-
-/** Lower preset operations to native whole-config replacements without changing native root patch indexing.
- * Each scoped operation sees the then-current preset children. Inputs remain unchanged.
- * @param initial Root entries before profile patches.
- * @param patches Complete ordered profile patch list.
- * @param warn Diagnostic sink for scoped child patches; ordinary patches report when Include applies the result.
- * @returns Detached native patches, suitable for the root Include.
- * @throws For malformed or unaddressable preset targets and duplicate inserted child ids.
- */
-export function compileProfilePatches(
- initial: EntryOptions[], patches: readonly ProfilePatch[], warn: ProfilePatchWarning = () => {},
-): PatchOptions[] {
- const lowered: PatchOptions[] = []
- for (const patch of cloneProfilePatches(patches)) {
- try {
- const preset = profilePatchPreset(patch)
- if (preset === undefined) {
- lowered.push(patch)
- continue
- }
- const target = targetOf(initial, lowered, preset)
- const config = target.config as Record & { plugins: EntryOptions[] }
- const { preset: _preset, ...operation } = patch
- const plugins = applyEntryPatches(config.plugins, [operation], (message: string, ...args: unknown[]) => {
- warn(`preset ${JSON.stringify(preset)}: ${message}`, ...args)
- })
- if (operation.insert !== undefined) assertInsertedIds(preset, plugins)
- lowered.push({ id: preset, config: { ...config, plugins } })
- } catch (error) {
- throw sourcedError(patch, error)
- }
- }
- return lowered
-}
-
-/** Compose root entries and profile patches with the same compiler used for startup and reload.
- * @param initial Root entries before profile patches.
- * @param patches Complete ordered profile patch list.
- * @param warn Diagnostic sink for unmatched ordinary and scoped child patches.
- * @returns The effective entry list, with expressions unevaluated.
- * @throws For malformed or unaddressable preset targets and duplicate inserted child ids.
- */
-export function applyProfilePatches(
- initial: EntryOptions[], patches: readonly ProfilePatch[], warn: ProfilePatchWarning = () => {},
-): EntryOptions[] {
- return applyEntryPatches(initial, compileProfilePatches(initial, patches, warn), warn)
-}
diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts
index e8aee3ce21..cbece01be2 100644
--- a/packages/boot/app-boot/src/profile.ts
+++ b/packages/boot/app-boot/src/profile.ts
@@ -25,12 +25,12 @@ import { createRequire } from 'node:module'
import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
+import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import type { DshBundleManifest, DshPackageManifest } from '@deepseek-ai/dsh-package-manifest'
import { evaluatePluginCompatibility, pluginCompatibilityWarning } from './plugin-compatibility.ts'
import { readProfileVersionExemptions } from './profile-compatibility.ts'
import { loadOverlayPatches } from './index.ts'
-import { applyProfilePatches, type ProfilePatch } from './profile-patches.ts'
import { realModuleDirectory } from './profile-resolution/legacy-links.ts'
/** Directory under the Harness home holding every profile. */
@@ -83,7 +83,7 @@ export interface ProfileLayer {
/** Absolute paths of the bundle's patch files, in application order. */
patchPaths: readonly string[]
/** The parsed patch lists of every file, concatenated in application order. */
- patches: ProfilePatch[]
+ patches: PatchOptions[]
}
/** A loaded profile: resolved bundle layers plus the user's own patch layer. */
@@ -97,7 +97,7 @@ export interface Profile {
/** Absolute path of the profile's own patch file. */
patchPath: string
/** The profile's own patches; empty when the file is absent. */
- patches: ProfilePatch[]
+ patches: PatchOptions[]
/** Selected bundles that contributed no layer, in `dsh.profile.bundles` order, with why. */
skippedBundles: SkippedBundle[]
}
@@ -806,16 +806,16 @@ export function loadProfile(
/**
* Compose patch layers into the effective entry list over an empty root —
- * compile preset operations and apply the same native patch list as boot,
- * so flag derivation and config dumps see exactly what mounts.
+ * the same single `applyEntryPatches` call the boot include makes, so flag
+ * derivation and config dumps see exactly what mounts.
* @param layers - patch lists in application order.
* @param warn - sink for skipped-patch diagnostics; defaults to silent (boot repeats them).
* @returns the composed entry list.
*/
export function composeEntries(
- layers: readonly ProfilePatch[][], warn: (message: string) => void = () => {},
+ layers: readonly PatchOptions[][], warn: (message: string) => void = () => {},
): EntryOptions[] {
- return applyProfilePatches([], layers.flat(), (message: string, ...args: unknown[]) => {
+ return applyEntryPatches([], structuredClone(layers.flat()), (message: string, ...args: unknown[]) => {
let index = 0
warn(message.replace(/%C/g, () => JSON.stringify(args[index++])))
})
diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts
index f291e45cc6..c1601313b6 100644
--- a/packages/boot/app-boot/tests/app-boot.spec.ts
+++ b/packages/boot/app-boot/tests/app-boot.spec.ts
@@ -558,6 +558,7 @@ describe('auditStartupEntries', () => {
const requiredIds = [
'agent-loop',
'webserver',
+ 'web-runtime',
'modules',
'connection',
'headless-runner',
@@ -739,7 +740,7 @@ describe('auditStartupEntries', () => {
it('omits an error cause when required plugins are only waiting for services', async () => {
const error = await auditStartupEntries(ctxWith([
- { fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
+ { fiber: fiber(0, undefined, { webStartup: {} }), options: { id: 'connection', name: './connection.mjs' } },
]), NAME, vi.fn()).catch((error: unknown) => error)
expect(error).toBeInstanceOf(StartupError)
expect(Object.hasOwn(error as StartupError, 'cause')).toBe(false)
@@ -750,9 +751,9 @@ describe('auditStartupEntries', () => {
it('keeps diagnostic metadata available without expanding it in ordinary error inspection', () => {
const entries = [{
id: 'connection', module: './connection.mjs', required: true, fiberState: 0,
- outcome: { kind: 'pending' as const, missing: ['webRuntime'] },
+ outcome: { kind: 'pending' as const, missing: ['webStartup'] },
}]
- const error = new StartupError('waiting for webRuntime', entries)
+ const error = new StartupError('waiting for webStartup', entries)
const startup = { configurationPath: '/private/cordis.yml', messages: [
{ ts: 1, name: 'loader', type: 'warn', args: ['raw diagnostic argument'] },
] }
@@ -760,7 +761,7 @@ describe('auditStartupEntries', () => {
expect(error.entries).toBe(entries)
expect(error.startup).toBe(startup)
const output = inspect(error)
- expect(output).toContain('waiting for webRuntime')
+ expect(output).toContain('waiting for webStartup')
expect(output).not.toContain('connection.mjs')
expect(output).not.toContain('/private/cordis.yml')
expect(output).not.toContain('raw diagnostic argument')
@@ -792,12 +793,12 @@ describe('auditStartupEntries', () => {
const error = await auditStartupEntries(ctxWith([
{ fiber: fiber(0, undefined, { webServer: {} }), options: { id: 'web-runtime', name: './web.mjs' } },
{ fiber: fiber(3, original), options: { id: 'webserver', name: '@deepseek-ai/dsh-host-webserver' } },
- { fiber: fiber(0, undefined, { webRuntime: {} }), options: { id: 'connection', name: './connection.mjs' } },
+ { fiber: fiber(0, undefined, { webStartup: {} }), options: { id: 'connection', name: './connection.mjs' } },
{ fiber: fiber(0), options: { id: 'unknown', name: './unknown.mjs' } },
]), NAME, warn).catch((error: unknown) => error)
expect(error).toBeInstanceOf(StartupError)
expect((error as Error).message).toMatchInlineSnapshot(`
- "dsh-test-bin: startup failed: 2 required plugins did not activate
+ "dsh-test-bin: startup failed: 3 required plugins did not activate
Failed plugins (1):
webserver (required)
@@ -806,10 +807,10 @@ describe('auditStartupEntries', () => {
at Server.listen (node:net:1:2)
Plugins waiting for services (3):
- Plugin Missing services
- connection (required) webRuntime
- web-runtime webServer
- unknown unknown"
+ Plugin Missing services
+ web-runtime (required) webServer
+ connection (required) webStartup
+ unknown unknown"
`)
expect(warn).not.toHaveBeenCalled()
})
diff --git a/packages/boot/app-boot/tests/config-schema.spec.ts b/packages/boot/app-boot/tests/config-schema.spec.ts
index aaacfcd68e..debb11641a 100644
--- a/packages/boot/app-boot/tests/config-schema.spec.ts
+++ b/packages/boot/app-boot/tests/config-schema.spec.ts
@@ -500,10 +500,6 @@ describe('collectConfigSchemas', () => {
expect(validates(result, [{ name: 'server', config: { port: 'wrong' } }])).toBe(false)
expect(validates(result, [{ name: 'new-plugin', config: { arbitrary: true } }])).toBe(true)
expect(validates(result, [{ id: 'server', disabled: true }], 'patchList')).toBe(true)
- expect(validates(result, [{ preset: 'preset-standard', id: 'server', config: 'child-owned' }], 'patchList')).toBe(true)
- expect(validates(result, [{ preset: '', insert: [] }], 'patchList')).toBe(false)
- expect(validates(result, [{ preset: ' ', insert: [] }], 'patchList')).toBe(false)
- expect(validates(result, [{ preset: { __jsExpr: 'ctx.preset' }, insert: [] }], 'patchList')).toBe(false)
expect(validates(result, [{ id: 'server', config: { port: 'wrong' } }], 'patchList')).toBe(false)
expect(validates(result, [{ id: 'server', config: {} }], 'patchList')).toBe(false)
expect(validates(result, [{ id: 'server', name: 'not-server', config: 'ignored' }], 'patchList')).toBe(true)
diff --git a/packages/boot/app-boot/tests/profile-patches-carrier.spec.ts b/packages/boot/app-boot/tests/profile-patches-carrier.spec.ts
deleted file mode 100644
index d9d913d11e..0000000000
--- a/packages/boot/app-boot/tests/profile-patches-carrier.spec.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-/** Patch identities work with archive-backed files and preserve physical filesystem aliases. */
-import { lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
-import { loadOverlayPatches, realModuleFile } from '../src/index.ts'
-
-vi.mock('node:fs', async (importOriginal) => {
- const actual = await importOriginal()
- return { ...actual, realpathSync: Object.assign(vi.fn(actual.realpathSync), { native: actual.realpathSync.native }) }
-})
-
-afterEach(() => { vi.mocked(realpathSync).mockReset() })
-
-function packagedRuntime(): void {
- const previous = Object.getOwnPropertyDescriptor(process, 'pkg')
- Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
- onTestFinished(() => {
- if (previous === undefined) Reflect.deleteProperty(process, 'pkg')
- else Object.defineProperty(process, 'pkg', previous)
- })
-}
-
-function fixture(): string {
- const root = mkdtempSync(join(tmpdir(), 'profile-patch-carrier-'))
- onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
- return root
-}
-
-it('loads ordinary and anonymous scoped patches when packaged files support reads and directory realpath', () => {
- packagedRuntime()
- const root = fixture()
- const file = join(root, 'cordis.patch.yml')
- writeFileSync(file, JSON.stringify([
- { insert: [{ id: 'base', name: 'base' }] },
- { preset: 'preset-standard', insert: [{ name: 'anonymous' }] },
- ]))
- vi.mocked(realpathSync).mockImplementation((path, options) => {
- if (lstatSync(path).isFile()) throw Object.assign(new Error('archive file has no physical realpath'), { code: 'ENOENT' })
- return realpathSync.native(path, options)
- })
- expect(() => realpathSync(file)).toThrow('archive file has no physical realpath')
- const first = loadOverlayPatches('test', file)
- expect(first[0]).toEqual({ insert: [{ id: 'base', name: 'base' }] })
- expect(first[1]?.insert?.[0]).toEqual({ id: expect.any(String) as string, name: 'anonymous' })
- expect(loadOverlayPatches('test', file)).toEqual(first)
- expect(realModuleFile(file)).toBe(join(realpathSync(root), 'cordis.patch.yml'))
-})
-
-it.each([false, true])('keeps directory and file symlinks on the same patch identity (packaged: %s)', (packaged) => {
- if (packaged) packagedRuntime()
- const root = fixture()
- const directory = join(root, 'bundle')
- mkdirSync(directory)
- const file = join(directory, 'Patch.yml')
- writeFileSync(file, JSON.stringify([{ preset: 'preset-standard', insert: [{ name: 'anonymous' }] }]))
- const directoryAlias = join(root, 'bundle-alias')
- const fileAlias = join(root, 'patch-alias.yml')
- symlinkSync(directory, directoryAlias, process.platform === 'win32' ? 'junction' : 'dir')
- symlinkSync(file, fileAlias, 'file')
- const first = loadOverlayPatches('test', file)
- expect(loadOverlayPatches('test', join(directoryAlias, 'Patch.yml'))).toEqual(first)
- expect(loadOverlayPatches('test', fileAlias)).toEqual(first)
- expect(realModuleFile(fileAlias)).toBe(realpathSync(file))
-})
-
-it('keeps ordinary physical resource normalization identical to Node realpath', () => {
- const root = fixture()
- const file = join(root, 'MixedCase.yml')
- writeFileSync(file, '[]\n')
- const requested = process.platform === 'win32' ? file.toLowerCase() : file
- expect(realModuleFile(requested)).toBe(realpathSync(requested))
-})
-
-it('reports missing packaged resources instead of assigning them an identity', () => {
- packagedRuntime()
- expect(() => realModuleFile(join(fixture(), 'absent.yml'))).toThrow('ENOENT')
-})
diff --git a/packages/boot/app-boot/tests/profile-patches.spec.ts b/packages/boot/app-boot/tests/profile-patches.spec.ts
deleted file mode 100644
index 7afd826d32..0000000000
--- a/packages/boot/app-boot/tests/profile-patches.spec.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-/** Preset operations preserve native root patch lookup and share startup/dump/reload compilation. */
-import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { describe, expect, it, onTestFinished } from 'vitest'
-import { load } from 'js-yaml'
-import { Context } from '@deepseek-ai/cordis'
-import { EntryGroup, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import Group from '@deepseek-ai/cordis-plugin-group'
-import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
-import {
- applyProfilePatches, boot, compileProfilePatches, composeEntries, loadOverlayPatches,
- prepareProfilePatches, profilePatchPreset, reconcileProfilePatches, renderConfigDump, type ProfilePatch,
-} from '../src/index.ts'
-
-import { cloneProfilePatches } from '../src/profile-patches.ts'
-
-const preset = (plugins: EntryOptions[] = []): Omit & {
- config: { id: string; description: string; plugins: EntryOptions[] }
-} => ({
- id: 'preset-standard', name: 'cordis:test-preset', config: { id: 'standard', description: 'Original', plugins },
-})
-const row = (id: string): EntryOptions => ({ id, name: 'test-plugin', config: { value: 'initial' } })
-const children = (rows: EntryOptions[]): EntryOptions[] => (rows[0]!.config as { plugins: EntryOptions[] }).plugins
-const temp = (): string => {
- const dir = mkdtempSync(join(tmpdir(), 'dsh-preset-patches-'))
- onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
- return dir
-}
-
-describe('preset patch composition', () => {
- it('composes bundle insertions and later scoped user overrides without mutating inputs', () => {
- const initial = [preset([row('base')])]
- const patches: ProfilePatch[] = [
- { preset: 'preset-standard', insert: [row('optional')] },
- { preset: 'preset-standard', id: 'optional', config: { value: { __jsExpr: 'ctx.value' } } },
- { preset: 'preset-standard', id: 'base', disabled: true },
- ]
- const original = structuredClone({ initial, patches })
- const result = applyProfilePatches(initial, patches)
- expect(children(result)).toEqual([
- { ...row('base'), disabled: true },
- { ...row('optional'), config: { value: { __jsExpr: 'ctx.value' } } },
- ])
- expect(result[0]?.config).toMatchObject({ description: 'Original' })
- expect({ initial, patches }).toEqual(original)
- expect(composeEntries([[{ insert: initial }], patches])).toEqual(result)
- expect(compileProfilePatches(initial, patches).every(patch => !Object.hasOwn(patch, 'preset'))).toBe(true)
- })
-
- it('allows a later whole-preset replacement to replace all scoped edits', () => {
- const replacement = { id: 'standard', plugins: [row('user')] }
- expect(applyProfilePatches([preset()], [
- { preset: 'preset-standard', insert: [row('optional')] },
- { id: 'preset-standard', config: replacement },
- ])[0]?.config).toEqual(replacement)
- })
-
- it('applies each scoped operation to the current child list, including replacement children', () => {
- const group = { id: 'group', name: 'cordis:group', group: true, config: [] }
- const result = applyProfilePatches([preset([group])], [
- { preset: 'preset-standard', id: 'group', config: [row('replaced-child')] },
- { preset: 'preset-standard', id: 'replaced-child', disabled: true },
- { preset: 'preset-standard', id: 'group', insert: [row('inserted-child')] },
- ])
- expect(children(result)[0]?.config).toEqual([
- { ...row('replaced-child'), disabled: true }, row('inserted-child'),
- ])
- })
-
- it('retains ordinary root single-index behavior before and after a scoped operation', () => {
- const warn: string[] = []
- const result = applyProfilePatches([
- { id: 'group', name: 'cordis:group', group: true, config: [] }, preset(),
- ], [
- { id: 'group', config: [row('replacement')] },
- { preset: 'preset-standard', insert: [row('optional')] },
- { id: 'replacement', disabled: true },
- ], message => warn.push(message))
- expect(result[0]?.config).toEqual([row('replacement')])
- expect(warn).toEqual(['patch: entry %C not found'])
- })
-
- it.each([
- [{ id: 'group', name: 'cordis:group', group: true, config: [] }, [preset()]],
- [{ id: 'group', name: 'cordis:group', group: true, config: [preset()] }, []],
- ])('rejects an outer target added or removed by a whole-group replacement', (group, replacement) => {
- expect(() => applyProfilePatches([group as EntryOptions], [
- { id: 'group', config: replacement }, { preset: 'preset-standard', insert: [row('optional')] },
- ])).toThrow('is missing or not addressable')
- })
-
- it('accepts an outer target inserted in a patchable group', () => {
- const result = applyProfilePatches([{ id: 'group', name: 'cordis:group', group: true, config: [] }], [
- { id: 'group', insert: [preset()] },
- { preset: 'preset-standard', insert: [row('optional')] },
- ])
- expect(children(result[0]?.config as EntryOptions[])).toEqual([row('optional')])
- })
-
- it('warns and skips a stale user override after its optional bundle is deselected', () => {
- const messages: unknown[][] = []
- expect(applyProfilePatches([preset()], [{ preset: 'preset-standard', id: 'optional', disabled: true }],
- (...args) => messages.push(args))).toEqual([preset()])
- expect(messages).toEqual([['preset "preset-standard": patch: entry %C not found', 'optional']])
- })
-
- it.each([null, '', ' ', 1, { __jsExpr: 'ctx.preset' }])('rejects a nonliteral or empty outer target %j', (target) => {
- const dir = temp()
- const path = join(dir, 'patch.yml')
- writeFileSync(path, JSON.stringify([{ preset: target, insert: [] }]))
- expect(() => loadOverlayPatches('test', path)).toThrow('nonempty literal row id')
- expect(() => loadOverlayPatches('test', path)).toThrow(path)
- })
-
- it('allows callers to omit a warning sink for skipped ordinary and scoped rows', () => {
- expect(applyProfilePatches([], [{ id: 'missing', disabled: true }])).toEqual([])
- expect(compileProfilePatches([preset()], [{ preset: 'preset-standard', id: 'missing', disabled: true }]))
- .toEqual([{ id: 'preset-standard', config: preset().config }])
- })
-
- it('rejects a missing outer row', () => {
- expect(() => applyProfilePatches([], [{ preset: 'missing', insert: [] }])).toThrow('preset "missing" is missing')
- expect(profilePatchPreset({ id: 'ordinary' })).toBeUndefined()
- })
-
- it.each([undefined, null, [], { plugins: { __jsExpr: '[]' } }])('rejects a malformed preset config %j', (config) => {
- expect(() => applyProfilePatches([{ ...preset(), config }], [{ preset: 'preset-standard', insert: [] }]))
- .toThrow('literal config.plugins entry list')
- })
-
- it.each([
- { inserted: [row('same')] },
- { inserted: [{ id: 'nested', name: 'cordis:group', group: true, config: [row('same')] }] },
- ])('rejects inserted ids colliding with existing nested ids', ({ inserted }) => {
- expect(() => applyProfilePatches([preset([
- { id: 'old-group', name: 'cordis:group', group: true, config: [row('same')] },
- ])], [{ preset: 'preset-standard', insert: inserted }])).toThrow('duplicate entry id "same"')
- })
-
- it('rejects duplicate ids inside an insertion and allows anonymous rows', () => {
- expect(() => applyProfilePatches([preset()], [{ preset: 'preset-standard', insert: [row('same'), row('same')] }]))
- .toThrow('duplicate entry id "same"')
- const path = join(temp(), 'anonymous.patch.yml')
- writeFileSync(path, JSON.stringify([{ preset: 'preset-standard', insert: [{ name: 'anonymous' }] }]))
- expect(children(applyProfilePatches([preset()], loadOverlayPatches('test', path))))
- .toEqual([{ id: expect.any(String) as string, name: 'anonymous' }])
- })
-
- it('keeps anonymous scoped identities stable by file and position without naming ordinary anonymous rows', () => {
- const dir = temp()
- const file = join(dir, 'contribution.patch.yml')
- const content = JSON.stringify([
- { insert: [{ name: 'ordinary-anonymous' }] },
- { preset: 'preset-standard', insert: [
- { name: 'anonymous' },
- { id: 'explicit', name: 'cordis:group', group: true, config: [{ name: 'nested-anonymous' }] },
- ] },
- { preset: 'preset-standard', insert: [{ name: 'anonymous' }] },
- ])
- writeFileSync(file, content)
- const first = loadOverlayPatches('test', file)
- const second = loadOverlayPatches('test', file)
- expect(second).toEqual(first)
- expect(first[0]?.insert?.[0]?.id).toBeUndefined()
- expect(first[1]?.insert?.[1]?.id).toBe('explicit')
- const nested = first[1]?.insert?.[1]?.config as EntryOptions[]
- const ids = [first[1]?.insert?.[0]?.id, nested[0]?.id, first[2]?.insert?.[0]?.id]
- expect(ids.every(id => typeof id === 'string' && id.length > 0)).toBe(true)
- expect(new Set(ids).size).toBe(3)
- const other = join(dir, 'other.patch.yml')
- writeFileSync(other, content)
- expect(loadOverlayPatches('test', other)[1]?.insert?.[0]?.id).not.toBe(ids[0])
- expect(children(applyProfilePatches([preset()], first))).toEqual(children(applyProfilePatches([preset()], second)))
- expect(Object.keys(first[1]!)).toEqual(['preset', 'insert'])
- })
-
- it.each([
- { initial: [], insert: [] },
- { initial: [preset()], insert: [{ id: 'group', name: 'cordis:group', group: true, config: [row('duplicate'), row('duplicate')] }] },
- { initial: [{ ...preset(), config: null }], insert: [] },
- ])('attributes scoped errors after cloning and flattening through composition, dumps, and boot', async ({ initial, insert }) => {
- const dir = temp()
- const path = join(dir, 'cordis.yml')
- writeFileSync(path, JSON.stringify(initial))
- const patchFile = join(dir, 'third-party.patch.yml')
- writeFileSync(patchFile, JSON.stringify([{ preset: 'preset-standard', insert }]))
- const patches = cloneProfilePatches(loadOverlayPatches('test', patchFile))
- expect(() => composeEntries([[{ insert: initial }], patches])).toThrow(patchFile)
- expect(() => renderConfigDump('test', path, [{ label: 'third-party', patches }])).toThrow(patchFile)
- await expect(boot('test', path, patches)).rejects.toThrow(patchFile)
- })
-
- it('keeps anonymous rows from the initial preset beside newly inserted rows', () => {
- const initial = load(JSON.stringify([{ ...preset(), config: { id: 'standard', plugins: [{ name: 'anonymous-base' }] } }])) as EntryOptions[]
- expect(children(applyProfilePatches(initial, [{ preset: 'preset-standard', insert: [row('contributed')] }])))
- .toEqual([{ name: 'anonymous-base' }, row('contributed')])
- })
-
- it('attributes errors from a caller warning sink to the loaded scoped operation', () => {
- const path = join(temp(), 'warning.patch.yml')
- writeFileSync(path, JSON.stringify([{ preset: 'preset-standard', id: 'absent', disabled: true }]))
- expect(() => applyProfilePatches([preset()], loadOverlayPatches('test', path), () => { throw 'warning sink unavailable' }))
- .toThrow(`warning sink unavailable in ${path}`)
- })
-
- it('attributes ordinary and scoped warnings to their own dump layers', () => {
- const dir = temp()
- const path = join(dir, 'cordis.yml')
- writeFileSync(path, JSON.stringify([preset()]))
- const warnings: string[] = []
- renderConfigDump('test', path, [
- { label: 'ordinary', patches: [{ id: 'missing', disabled: true }] },
- { label: 'scoped', patches: [{ preset: 'preset-standard', id: 'missing', disabled: true }] },
- ], message => warnings.push(message))
- expect(warnings).toEqual([
- 'test: [ordinary] patch: entry "missing" not found',
- 'test: [scoped] preset "preset-standard": patch: entry "missing" not found',
- ])
- })
-})
-
-const capturePreset = {
- [EntryGroup.key]: true,
- async apply(ctx: Context, config: { plugins: EntryOptions[] }) { await ctx.plugin(Group, config.plugins) },
-}
-
-it('boots, dumps, and reloads a third-party relative module inside a contributed group', async () => {
- const dir = temp()
- const bundleDir = join(dir, 'third-party')
- mkdirSync(bundleDir)
- writeFileSync(join(bundleDir, 'relative.mjs'), [
- 'export function apply(ctx, config) {',
- ' ctx.effect(() => ctx.reflect.provide("presetPatchResult", config.value))',
- '}',
- '',
- ].join('\n'))
- const path = join(dir, 'cordis.yml')
- writeFileSync(path, JSON.stringify([preset()]))
- const patchPath = join(bundleDir, 'cordis.patch.yml')
- writeFileSync(patchPath, [
- '- preset: preset-standard',
- ' insert:',
- ' - id: nested',
- ' name: cordis:group',
- ' group: true',
- ' config:',
- ' - id: contributed',
- ' name: ./relative.mjs',
- ' config:',
- ' value: !!js ctx.get("fixtureValue")',
- '',
- ].join('\n'))
- const patches = loadOverlayPatches('test', patchPath)
- const ctx = await boot('test', path, patches, (context) => {
- context.loader.builtins['test-preset'] = capturePreset
- context.provide('fixtureValue', 'from-child-context')
- })
- onTestFinished(() => ctx.fiber.dispose())
- expect(ctx.get('presetPatchResult')).toBe('from-child-context')
- const declared = [...ctx.loader.entries()].find(entry => entry.options.id === 'preset-standard')!
- const dump = renderConfigDump('test', path, [{ label: 'third-party', patches }])
- expect(load(dump, { schema: entryListSchema })).toEqual([declared.options])
- expect(dump).toContain(pathToFileURL(join(bundleDir, 'relative.mjs')).href)
- expect(dump).toContain('!!js ctx.get("fixtureValue")')
- await reconcileProfilePatches(ctx, [...patches, {
- preset: 'preset-standard', id: 'contributed', config: { value: 'user' },
- }], 'test')
- expect(ctx.get('presetPatchResult')).toBe('user')
- await reconcileProfilePatches(ctx, [], 'test')
- expect(ctx.get('presetPatchResult')).toBeUndefined()
- await reconcileProfilePatches(ctx, [{ preset: 'preset-standard', id: 'contributed', disabled: true }], 'test')
- expect(ctx.get('presetPatchResult')).toBeUndefined()
- await reconcileProfilePatches(ctx, patches, 'test')
- expect(ctx.get('presetPatchResult')).toBe('from-child-context')
-})
-
-it('rejects a malformed root configuration before compiling its scoped patch', async () => {
- const dir = temp()
- const path = join(dir, 'cordis.yml')
- writeFileSync(path, '{}\n')
- await expect(boot('test', path, [{ preset: 'preset-standard', insert: [] }]))
- .rejects.toThrow('must be a top-level array of entries')
-})
-
-it('leaves lower-level nonprofile compatibility callers in control of their native patches', async () => {
- const ctx = new Context()
- onTestFinished(() => ctx.fiber.dispose())
- const patches = [{ id: 'custom', disabled: true }]
- expect(prepareProfilePatches(ctx, patches, pathToFileURL(temp()).href)).toBe(patches)
-})
-
-it('anchors relative modules in an inserted preset definition and its nested groups', () => {
- const dir = temp()
- const path = join(dir, 'cordis.patch.yml')
- writeFileSync(path, JSON.stringify([{ insert: [{
- ...preset([{ id: 'group', name: 'cordis:group', group: true, config: [{ id: 'child', name: './child.mjs' }] }]),
- name: '@deepseek-ai/dsh-agent-preset',
- }] }]))
- const patches = loadOverlayPatches('test', path)
- expect(patches[0]?.insert?.[0]?.config).toMatchObject({ plugins: [{ config: [{
- name: pathToFileURL(join(dir, 'child.mjs')).href,
- }] }] })
-})
diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts
index 6db0154321..465f9d82c6 100644
--- a/packages/boot/app-boot/tests/user-patches.spec.ts
+++ b/packages/boot/app-boot/tests/user-patches.spec.ts
@@ -414,13 +414,11 @@ describe('profile reconciliation settlement', () => {
onTestFinished(async () => { release.resolve(undefined); await ctx.fiber.dispose() })
let settled = false
const operation = reconcileProfilePatches(ctx, [], NAME).then(() => { settled = true })
- onTestFinished(async () => { release.resolve(undefined); await operation })
await started.promise
- expect([...ctx.loader.entries()].find(entry => entry.options.id === 'held')?.fiber?.uid).toBeNull()
+ expect([...ctx.loader.entries()].some(entry => entry.options.id === 'held')).toBe(false)
expect(settled).toBe(false)
release.resolve(undefined)
await operation
- expect([...ctx.loader.entries()].some(entry => entry.options.id === 'held')).toBe(false)
expect(settled).toBe(true)
})
})
diff --git a/packages/boot/config-editor/README.i18n.yaml b/packages/boot/config-editor/README.i18n.yaml
index 265864b3ec..5727bdff08 100644
--- a/packages/boot/config-editor/README.i18n.yaml
+++ b/packages/boot/config-editor/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 1eedaa5d72b48177
zh: 39abc1d927a692d2
/deepseek-ai-dsh-config-editor/understand-the-implementation:
- en: 6c0265c949db473c
- zh: fe1849f32f2d111c
+ en: baa887b5ecfa7001
+ zh: d3e03c2b1d10c67e
/deepseek-ai-dsh-config-editor/further-exploration:
en: 0c9e1d1401a865cc
zh: 6130d52636e84676
diff --git a/packages/boot/config-editor/README.md b/packages/boot/config-editor/README.md
index e67bdad23a..17da6551f9 100644
--- a/packages/boot/config-editor/README.md
+++ b/packages/boot/config-editor/README.md
@@ -42,8 +42,6 @@ The [editor](src/index.ts) reconciles external changes before deriving a candida
Configuration reads share one composition for entries without profile config overrides. Overridden entries are composed separately with only their own override removed; profile inserts and other entries' overrides remain effective. Returned configs are detached, and compositions are not cached between reads.
-Host configuration reads, edits, and resets ignore preset-scoped operations, including those whose child IDs match Host entry IDs.
-
diff --git a/packages/boot/config-editor/README.zh.md b/packages/boot/config-editor/README.zh.md
index 62abcab4bc..d1ff18344f 100644
--- a/packages/boot/config-editor/README.zh.md
+++ b/packages/boot/config-editor/README.zh.md
@@ -42,8 +42,6 @@ kind: "package-reference"
读取配置时,没有 profile config 覆盖项的条目共用一次组合结果。有覆盖项的条目分别组合,仅移除自身的覆盖项;profile 插入的条目和其他条目的覆盖项仍然生效。返回的配置为独立副本,组合结果不跨读取缓存。
-Host 配置读取、编辑和重置均忽略预设作用域操作,包括子项 ID 与 Host 条目 ID 相同的操作。
-
diff --git a/packages/boot/config-editor/src/index.ts b/packages/boot/config-editor/src/index.ts
index 4a3e5be473..ffec879f40 100644
--- a/packages/boot/config-editor/src/index.ts
+++ b/packages/boot/config-editor/src/index.ts
@@ -3,11 +3,11 @@ import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { isDeepStrictEqual } from 'node:util'
import { Context, FiberState, Service, resolveConfig } from '@deepseek-ai/cordis'
-import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import { entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import yaml from 'js-yaml'
import type { Entry, EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-hmr'
-import { composeEntries, loadProfileDirectory, profilePatchPreset, readProfilePatches, reconcileProfilePatches, type ProfilePatch } from '@deepseek-ai/dsh-app-boot'
+import { composeEntries, loadProfileDirectory, readProfilePatches, reconcileProfilePatches } from '@deepseek-ai/dsh-app-boot'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { isMap, isSeq, parseDocument, Scalar, visit } from 'yaml'
@@ -22,7 +22,7 @@ function flatten(rows: EntryOptions[]): EntryOptions[] {
return rows.flatMap(row => [row, ...row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : []])
}
-/** Persist Host entry configs without changing preset-scoped operations, then reconcile through Loader. */
+/** Persist complete raw configs and apply them through the normal Loader path. */
export class ConfigEditor extends Service {
static inject = ['loader', 'profileContext']
@@ -50,9 +50,8 @@ export class ConfigEditor extends Service {
const profile = this.ownerContext.profileContext
const loaded = loadProfileDirectory('dsh', profile.dir, profile.installAnchor)
const entries = this.entries()
- const hostPatches = loaded.patches.filter(patch => profilePatchPreset(patch) === undefined)
// An own config key can replace inherited config even when its value is undefined.
- const overridden = new Set(hostPatches.filter(patch => patch.insert === undefined && Object.hasOwn(patch, 'config')).map(patch => patch.id))
+ const overridden = new Set(loaded.patches.filter(patch => patch.insert === undefined && Object.hasOwn(patch, 'config')).map(patch => patch.id))
const composed = new Map()
if (entries.some(entry => !overridden.has(entry.options.id))) {
for (const row of flatten(composeEntries([...loaded.layers.map(layer => layer.patches), loaded.patches]))) {
@@ -64,7 +63,7 @@ export class ConfigEditor extends Service {
inherited: overridden.has(entry.options.id)
? this.inherited(entry, loaded)
: structuredClone((composed.get(entry.options.id)?.config ?? {}) as Record),
- override: structuredClone((hostPatches.findLast(
+ override: structuredClone((loaded.patches.findLast(
row => row.id === entry.options.id && row.config !== undefined,
)?.config ?? {}) as Record),
}))
@@ -72,7 +71,7 @@ export class ConfigEditor extends Service {
private inherited(entry: Entry, loaded: ReturnType): Record {
const patches = loaded.patches.map((patch) => {
- if (patch.id !== entry.options.id || patch.insert !== undefined || profilePatchPreset(patch) !== undefined) return patch
+ if (patch.id !== entry.options.id || patch.insert !== undefined) return patch
const rest = { ...patch }; Reflect.deleteProperty(rest, 'config')
return rest
})
@@ -116,12 +115,12 @@ export class ConfigEditor extends Service {
if (!isSeq(document.contents)) throw new Error('Profile patch must be a YAML sequence')
document.contents.flow = false
const index = document.contents.items.findLastIndex((item, index) => isMap(item)
- && document.getIn([index, 'id']) === entry.options.id && !item.has('insert') && !item.has('preset')
+ && document.getIn([index, 'id']) === entry.options.id && !item.has('insert')
&& (!item.has('name') || document.getIn([index, 'name']) === entry.options.name))
if (isDeepStrictEqual(next, inherited)) {
for (let index = document.contents.items.length - 1; index >= 0; index--) {
const row = document.contents.items[index]
- if (!isMap(row) || document.getIn([index, 'id']) !== entry.options.id || row.has('insert') || row.has('preset')) continue
+ if (!isMap(row) || document.getIn([index, 'id']) !== entry.options.id || row.has('insert')) continue
row.delete('config')
if (row.items.length === Number(row.has('id')) + Number(row.has('name'))) document.delete(index)
}
@@ -135,7 +134,7 @@ export class ConfigEditor extends Service {
} })
const profile = this.ownerContext.profileContext
const loaded = loadProfileDirectory('dsh', profile.dir, profile.installAnchor)
- const patches = readProfilePatches('dsh', profile, { ...loaded, patches: yaml.load(String(document), { schema: entryListSchema }) as ProfilePatch[] })
+ const patches = readProfilePatches('dsh', profile, { ...loaded, patches: yaml.load(String(document), { schema: entryListSchema }) as PatchOptions[] })
const effective = flatten(composeEntries([patches])).find(row => row.id === entry.options.id)
if (!isDeepStrictEqual(effective?.config ?? {}, next)) {
throw new Error(`Configuration for "${entry.options.id}" is overridden by a home patch or command-line overlay`)
diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json
index 8e40abbccc..9c82b668e8 100644
--- a/packages/boot/hmr/package.json
+++ b/packages/boot/hmr/package.json
@@ -38,7 +38,7 @@
"dependencies": {
"@babel/code-frame": "^7.29.0",
"chokidar": "5.0.0",
- "node-addon-require-builtin": "^0.1.6",
+ "node-addon-require-builtin": "^0.1.9",
"picomatch": "^4.0.7",
"@deepseek-ai/schemastery": "workspace:~"
},
diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml
index 7a2bca2062..033a14fc65 100644
--- a/packages/boot/plugin-manager/README.i18n.yaml
+++ b/packages/boot/plugin-manager/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 87679c51aa3693f5
zh: 11684a48a5806afb
/deepseek-ai-dsh-plugin-manager/use-this-package:
- en: af5bf0a205b5a2a1
- zh: 0114039816454a1a
+ en: 4eef24eeb95ebb30
+ zh: 99fb6a7e4e3f759f
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
en: 920c0acb3b9feaf3
zh: ba6e832a256880a7
@@ -45,8 +45,8 @@
en: c29f60be7f3edc1a
zh: d9b9388b96ef95cc
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work:
- en: 7d7ea29063e358ca
- zh: e3399db77a97f62a
+ en: fde2d9491fe5dd4f
+ zh: ef6e6db15c4f19d2
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
en: 4447e4df8d62b274
zh: c76443e2edb97b1d
diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md
index 6763a7a9f8..bc1550d9aa 100644
--- a/packages/boot/plugin-manager/README.md
+++ b/packages/boot/plugin-manager/README.md
@@ -41,8 +41,6 @@ A plugin toggle updates only `disabled` in the last matching override in the pro
A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable.
-Scoped bundle rows carry both their declared `rowId` and outer `preset` target. The manager reads declarations without composing the bundle alone, so rows remain visible while Off. Their optional `composition` state comes from the current preset inventory; they have no editable root `entryId`, and their `preset-managed` read-only reason directs configuration to the preset or bundle. Enabling audits every affected preset using its registered declaring row, including failures the registry retained instead of throwing; unrelated unchanged preset failures become warnings. Bundle toggles validate the saved composition even without HMR; composition failures keep the saved selection and return a diagnostic immediately. Remove refuses while a current or retained preset generation uses a contributed row, matched by defining preset row, child row ID, and resolved module, or any module belonging to the bundle package itself. This protection lasts after Off until those generations release the modules; unrelated rows using a shared dependency do not prevent removal. Anonymous scoped insertions receive the same protection. Missing module files do not make a different contribution match; identical retained declarations remain protected when their files are unavailable.
-
`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results.
`listBundles` marks shipped optional bundles and on-demand catalog entries as `official`, independently of package installation. `availability` identifies a readable installation-provided or profile package, or `missing`; on-demand catalog entries exclude undeclared transitive copies. `installed` only records whether the profile declares the dependency. An absent on-demand entry carries embedded localized metadata and a host-derived `installTarget` containing the running DSH version and install spec, without registry access. Profile-declared packages with unreadable files retain their declaration and expose the normal error.
@@ -141,7 +139,7 @@ Tool results append to the transcript. Enabling or disabling other tools can cha
- Web approves the entire displayed pending group; it has no per-package selection.
- Package replacements require restarting the process to load a fresh JavaScript module generation.
- Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
-- The manager cannot disable its own management components, change another profile, or directly edit individual rows inside an agent preset.
+- The manager cannot disable its own management components, change another profile, or edit an agent preset's composition.
- A failed removal may leave dependencies partially changed, and a failed or cancelled installation can leave downloaded files under `node_modules` or the pnpm store. Inactive dependencies with missing files remain removable. Diagnostic logs remain under the profile's `.plugin-manager/logs` directory.
- Management results describe Host activation. Browser synchronization failures appear separately in the Settings plugin list.
- Desktop package operations remain owned by the Desktop shell.
diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md
index ba365135f2..8d5d3aabe7 100644
--- a/packages/boot/plugin-manager/README.zh.md
+++ b/packages/boot/plugin-manager/README.zh.md
@@ -41,8 +41,6 @@ kind: "package-reference"
已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。
-限定到 preset 的组合包行同时携带声明的 `rowId` 和外层 `preset` 目标。管理器读取声明,不单独组合该 bundle,因此 Off 状态下仍能列出这些行。可选的 `composition` 状态来自当前 preset 清单;这些行没有可编辑的根条目 `entryId`,其 `preset-managed` 只读原因指引用户通过预设或组合包进行配置。启用根据注册的声明行检查所有受影响的 preset,包括注册表保留而未抛出的故障;无关且未变化的 preset 故障作为警告返回。即使没有 HMR,组合包开关也会校验保存后的组合;组合失败会保留已保存的选择并立即返回诊断。当前或被保留的 preset 代仍使用贡献行(按定义 preset 的行、子行 ID 和解析后的模块匹配),或属于组合包自身的任意模块时,Remove 都会拒绝。这项保护在 Off 后仍持续,直到这些代释放模块;无关行使用共享依赖不会阻止移除。匿名作用域插入行受到相同保护。模块文件缺失不会导致另一贡献项被误认为匹配;文件不可用时,相同的保留声明仍受保护。
-
`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。
`listBundles` 将随附可选组合包和按需目录项标为 `official`,与包的安装状态独立。`availability` 表示可读取的安装随附包、profile 包,或 `missing`;按需目录项不计入未声明的传递依赖副本。`installed` 仅表示 profile 是否声明该依赖。尚未安装的按需条目携带内嵌的本地化元信息,以及 Host 生成、包含当前 DSH 版本与安装 spec 的 `installTarget`,无需访问注册表。profile 已声明但文件不可读的包保留依赖声明,并展示原有错误。
@@ -141,7 +139,7 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,
- Web 一次批准显示出来的整组待决定包,没有逐包选择。
- 替换已有包后需要重启进程,以加载新的 JavaScript 模块版本。
- 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
-- 管理器不能关闭自身所需的管理组件、修改其他 profile 或直接编辑 agent 预设内部的单个条目。
+- 管理器不能关闭自身所需的管理组件、修改其他 profile 或编辑 agent 预设组合。
- 失败的删除可能留下部分依赖改动,失败或被取消的安装可能在 `node_modules` 或 pnpm 缓存中留下已下载文件。文件缺失的未启用依赖仍可删除。诊断日志保留在 profile 的 `.plugin-manager/logs` 目录中。
- 管理结果描述 Host 激活状态。浏览器同步失败会在设置的插件列表中单独显示。
- Desktop 包管理操作仍由 Desktop shell 负责。
diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json
index 9d926c0506..700301e89b 100644
--- a/packages/boot/plugin-manager/package.json
+++ b/packages/boot/plugin-manager/package.json
@@ -75,8 +75,7 @@
"@deepseek-ai/dsh-brand": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
- "@deepseek-ai/dsh-user-approval": "workspace:*",
- "@deepseek-ai/dsh-agent-preset-registry": "workspace:*"
+ "@deepseek-ai/dsh-user-approval": "workspace:*"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-hmr": {
@@ -84,9 +83,6 @@
},
"@deepseek-ai/dsh-user-approval": {
"optional": true
- },
- "@deepseek-ai/dsh-agent-preset-registry": {
- "optional": true
}
},
"devDependencies": {
@@ -107,10 +103,6 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-sandbox": "workspace:*",
- "@deepseek-ai/dsh-user-approval": "workspace:*",
- "@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
- "@deepseek-ai/dsh-agent-preset": "workspace:*",
- "@deepseek-ai/dsh-scope": "workspace:*",
- "@deepseek-ai/dsh-session-title": "workspace:*"
+ "@deepseek-ai/dsh-user-approval": "workspace:*"
}
}
diff --git a/packages/boot/plugin-manager/src/bundle-rows.ts b/packages/boot/plugin-manager/src/bundle-rows.ts
deleted file mode 100644
index 9a150f3bd7..0000000000
--- a/packages/boot/plugin-manager/src/bundle-rows.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-/** Raw bundle declarations remain inspectable while their prerequisite layers are disabled. */
-import { existsSync } from 'node:fs'
-import { dirname, isAbsolute, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import { realModuleFile, profilePatchPreset, type ProfilePatch, type PluginPackages } from '@deepseek-ai/dsh-app-boot'
-
-/** A declared child row and the outer preset operation that contributes it. */
-export interface BundleDeclaration {
- readonly row: EntryOptions
- readonly preset?: string
-}
-
-/** Read inserted rows without applying a bundle against an incomplete profile.
- * @param patches Parsed bundle patches, whose module paths are already anchored.
- * @returns Declarations in patch order, including nested groups.
- */
-export function bundleDeclarations(patches: readonly ProfilePatch[]): BundleDeclaration[] {
- const result: BundleDeclaration[] = []
- const visit = (rows: EntryOptions[], preset?: string): void => {
- for (const row of rows) {
- result.push({ row, ...preset === undefined ? {} : { preset } })
- if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[], preset)
- }
- }
- for (const patch of patches) {
- if (patch.insert !== undefined) visit(patch.insert, profilePatchPreset(patch))
- }
- return result
-}
-
-/** Locate the package owning a bare import or filesystem module without importing its code.
- * @param specifier Configured module name or anchored file URL.
- * @param base Declaring Loader tree's resolution URL.
- * @param packages Optional profile package lookup for bare specifiers.
- * @returns Canonical package manifest path, or undefined for builtins, missing modules, and unowned files.
- */
-export function bundleModuleOwner(
- specifier: string, base: string, packages?: Pick,
-): string | undefined {
- const known = packages?.packageOf(specifier, base)
- if (known !== undefined) return realModuleFile(known.manifestPath)
- if (!specifier.startsWith('file:') && !specifier.startsWith('.') && !isAbsolute(specifier)) return undefined
- const file = isAbsolute(specifier) ? specifier : fileURLToPath(new URL(specifier, base))
- if (!existsSync(file)) return undefined
- let dir = dirname(realModuleFile(file))
- while (true) {
- const manifest = join(dir, 'package.json')
- if (existsSync(manifest)) return realModuleFile(manifest)
- const parent = dirname(dir)
- if (parent === dir) return undefined
- dir = parent
- }
-}
diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts
index 5c77c0a97c..cc4db943eb 100644
--- a/packages/boot/plugin-manager/src/index.ts
+++ b/packages/boot/plugin-manager/src/index.ts
@@ -7,16 +7,15 @@ import { pathToFileURL } from 'node:url'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { Context } from '@deepseek-ai/cordis'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
-import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types'
-import type { AgentPreset } from '@deepseek-ai/dsh-agent-preset-registry'
+import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import z from '@deepseek-ai/schemastery'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
import {
- readPluginMeta, resolvePluginResource, realModuleFile, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
+ readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries,
reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths,
evaluatePluginCompatibility, readProfileCompatibility, readProfileVersionExemptions,
- setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME, profilePatchPreset, type ProfilePatch,
+ setProfileVersionExemption, PROFILE_COMPATIBILITY_FILENAME,
OFFICIAL_ON_DEMAND_CATALOG, getDshRuntimeVersion,
} from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-hmr'
@@ -29,7 +28,6 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan }
import { writePluginEnabled } from './patch.ts'
import { incompatiblePlugin, ManagementFailure } from './failure.ts'
import { approveBuilds, recordPendingBuilds } from './build-approval.ts'
-import { bundleDeclarations, bundleModuleOwner, type BundleDeclaration } from './bundle-rows.ts'
import { checkGithubConnection } from './github-connection.ts'
import type {
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange,
@@ -281,9 +279,7 @@ export class PluginManager extends TypertRemoteService {
* display metadata, activation selections, whether the installation offers the bundle, and removal availability.
*/
@Remote
- async listBundles(): Promise {
- const inventory = await readPluginInventory(this.ctx)
- const definitions = await this.ctx.get('agentPresets')?.list() ?? []
+ listBundles(): Promise {
const manifest = readProfileManifest('dsh', this.profile.dir)
const exemptions = readProfileVersionExemptions(this.profile.dir)
const selected = manifest.dsh?.profile?.bundles ?? []
@@ -341,7 +337,7 @@ export class PluginManager extends TypertRemoteService {
...meta === undefined ? {} : { meta },
...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined,
...(readOnlyReason === undefined ? {} : { readOnlyReason }),
- ...this.declaredRows(name, info, inventory, definitions),
+ ...this.declaredRows(name, info),
...targetError === undefined ? {} : { error: targetError } })
} catch (error) {
if (enabled || installed || optional || offered !== undefined) {
@@ -352,7 +348,7 @@ export class PluginManager extends TypertRemoteService {
}
}
}
- return bundles
+ return Promise.resolve(bundles)
}
/** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
@@ -481,9 +477,7 @@ export class PluginManager extends TypertRemoteService {
return this.change(result => this.configure(async () => {
await this.selectBundle(name, enabled)
if (enabled) await this.refreshPackages()
- const rows = enabled ? this.bundleRows(name) : []
- result.warnings = await this.reload(rows.filter(row => row.preset === undefined).map(({ row }) => row.id),
- enabled ? this.bundlePresets(name) : [])
+ result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : [])
if (!enabled && this.ownerContext.get('hmr') !== undefined) await this.refreshPackages()
}), { stage: 'enable', target: name, enabled }, 'bundle')
}
@@ -608,7 +602,7 @@ export class PluginManager extends TypertRemoteService {
if (options?.enabled !== false) await this.selectBundle(name, true)
if (Object.hasOwn(before, name)) return 'restart-required'
await this.refreshPackages()
- if (options?.enabled !== false) result.warnings = await this.reload([], this.bundlePresets(name))
+ if (options?.enabled !== false) result.warnings = await this.reload()
})
}, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install')
control.result = result
@@ -653,21 +647,18 @@ export class PluginManager extends TypertRemoteService {
const installed = await this.configure(async () => {
const bundle = (await this.listBundles()).find(item => item.name === name)
if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable')
- const contributions = bundle.error === undefined ? this.bundleRows(name) : []
if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
- || contributions.some(({ row, preset }) => preset === undefined && [...this.ctx.loader.entries()]
- .some(entry => entry.options.id === row.id && entry.fiber !== undefined))
- || this.retainedBundleModules(name, contributions))) {
+ || (bundle.error === undefined && this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
+ .some(entry => entry.options.id === row.id && entry.fiber !== undefined))))) {
throw new ManagementFailure('stop-profile')
}
+ const contributions = bundle.error === undefined ? this.bundleRows(name) : []
if (bundle.enabled) {
await this.selectBundle(name, false)
result.warnings = await this.reload()
}
if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null
- && contributions.some(({ row, preset }) => preset === undefined
- && row.id === entry.options.id && row.name === entry.options.name))
- || this.retainedBundleModules(name, contributions)) {
+ && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) {
throw new ManagementFailure('bundle-in-use')
}
return bundle.installed
@@ -681,65 +672,39 @@ export class PluginManager extends TypertRemoteService {
}, { stage: 'remove', target: name }, 'remove')
}
- /** The rows a bundle declares, joined by outer preset and child id where applicable. */
- private declaredRows(name: string, info: ProfileManifest, inventory: PluginInventorySnapshot, definitions: readonly AgentPreset[]): Pick {
- const patches = this.bundlePatches(name, info)
+ /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
+ private declaredRows(name: string, info: ProfileManifest): Pick {
+ const bundle = info.dsh?.bundle
+ /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
+ if (bundle === undefined) return { rows: [], overrides: [] }
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
- const entries = [...this.ctx.loader.entries()]
+ const patches: PatchOptions[] = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file))
+ // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
+ const live = new Map()
+ for (const entry of this.ctx.loader.entries()) {
+ /* v8 ignore next -- the Loader gives every entry an id before it is listed */
+ if (typeof entry.options.id === 'string') live.set(entry.options.id, {
+ entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl,
+ })
+ }
const rows: BundleRowInfo[] = []
const packages = this.ctx.get('pluginPackages')
- for (const { row, preset } of bundleDeclarations(patches)) {
- if (typeof row.id !== 'string' || typeof row.name !== 'string') continue
- const matches = preset === undefined ? entries.filter(entry => entry.options.id === row.id && entry.options.name === row.name) : []
- const active = matches.length === 1 ? matches[0] : undefined
- const base = active?.parent.tree.ctx.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href
+ for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
+ // Group rows are containers without a plugin inventory entry; their children carry the switches.
+ if (typeof row.id !== 'string' || typeof row.name !== 'string' || row.group === true) continue
+ const active = live.get(row.id)
+ const entryId = active?.entryId
+ const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href
const meta = packages?.metaOf(row.name, base)
- const presetId = preset === undefined ? undefined : definitions.find(item => item.definitionEntryId === preset)?.id
- const composition = inventory.agentPresets?.find(item => item.id === presetId)?.rows
- .find(item => item.entryId === row.id && item.moduleName === row.name)
rows.push({ rowId: row.id, moduleName: row.name,
- ...preset === undefined ? {} : { preset, readOnlyReason: 'preset-managed' as const },
- ...composition === undefined ? {} : { composition: { enabled: composition.enabled, fiberPhase: composition.fiberPhase } },
- ...active === undefined ? {} : { entryId: pluginEntryId(active.id) }, ...meta === undefined ? {} : { meta } })
+ ...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } })
}
- const key = (preset: string | undefined, id: string): string => JSON.stringify([preset, id])
- const declared = new Set(rows.map(row => key(row.preset, row.rowId)))
- const overrides = [...new Set(patches.flatMap((item) => {
- const preset = profilePatchPreset(item)
- return item.insert === undefined && typeof item.id === 'string' && !declared.has(key(preset, item.id))
- ? [preset === undefined ? item.id : `${preset}/${item.id}`] : []
- }))]
+ const declared = new Set(rows.map(row => row.rowId))
+ const overrides = [...new Set(patches.flatMap(item =>
+ item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))]
return { rows, overrides }
}
- /** Retained revisions still hold module code even after a bundle has been deselected. */
- private retainedBundleModules(name: string, declarations: readonly BundleDeclaration[]): boolean {
- const presets = this.ctx.get('agentPresets')
- if (presets === undefined || declarations.length === 0) return false
- const manifest = join(resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir), 'package.json')
- const base = pathToFileURL(manifest).href
- const owner = realModuleFile(manifest)
- const moduleKey = (specifier: string, parentURL: string): string | undefined => {
- if (specifier.startsWith('cordis:')) return specifier
- try { return realModuleFile(resolvePluginResource(specifier, parentURL)) }
- catch (error) {
- // An externally removed module cannot match another resolved file; identical declarations remain protected below.
- if (['ENOENT', 'ENOTDIR', 'ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED'].includes(String((error as NodeJS.ErrnoException).code))) return undefined
- throw error
- }
- }
- return presets.inspectCompositions().some(composition => composition.modules.some((module) => {
- const moduleBase = module.baseUrl ?? base
- if (bundleModuleOwner(module.moduleName, moduleBase, this.ctx.get('pluginPackages')) === owner) return true
- return module.useHostBase && declarations.some(({ row, preset }) => {
- if (preset === undefined || preset !== composition.definitionEntryId || row.id !== module.entryId) return false
- const declared = moduleKey(row.name, base)
- const retained = moduleKey(module.moduleName, moduleBase)
- return declared === undefined || retained === undefined ? row.name === module.moduleName : declared === retained
- })
- }))
- }
-
/** Run one pnpm command in the profile, streaming its output as install-log chunks. */
private async runPnpm(
args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId,
@@ -817,31 +782,21 @@ export class PluginManager extends TypertRemoteService {
if (enabled) this.protectsManager(name)
}
- private bundlePatches(name: string, known?: ProfileManifest): ProfilePatch[] {
- const info = known ?? bundleManifest(name, this.profile.dir, this.profile.installAnchor)
+ private bundleRows(name: string): EntryOptions[] {
+ const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
if (info?.dsh?.bundle === undefined) return []
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
- return bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file))
- }
-
- private bundleRows(name: string): BundleDeclaration[] { return bundleDeclarations(this.bundlePatches(name)) }
-
- private bundlePresets(name: string): string[] {
- return [...new Set(this.bundlePatches(name).flatMap((patch) => {
- const preset = profilePatchPreset(patch)
- return preset === undefined ? [] : [preset]
- }))]
+ return flatten(composeEntries([bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file))]))
}
private protectsManager(name: string): boolean {
if (this.managementBundles.has(name)) return true
- let rows: BundleDeclaration[]
+ let rows: EntryOptions[]
try { rows = this.bundleRows(name) } catch (_error) {
// Unreadable bundles contribute no new rows; listBundles reports their diagnostics.
return false
}
- const protectedBundle = rows.some(({ row, preset }) => preset === undefined
- && (protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId))
+ const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
if (protectedBundle) this.managementBundles.add(name)
return protectedBundle
}
@@ -861,33 +816,9 @@ export class PluginManager extends TypertRemoteService {
await this.ownerContext.get('pluginPackages')?.refresh()
}
- /** Configuration identity distinguishes a changed failed preset from an unrelated pre-existing warning. */
- private presetConfiguration(preset: AgentPreset): string {
- return JSON.stringify([...this.ctx.loader.entries()].flatMap((entry) => {
- const config = entry.options.config as { id?: unknown; plugins?: unknown } | undefined
- return entry.options.id === preset.definitionEntryId && config?.id === preset.id && Array.isArray(config.plugins) ? [config] : []
- }))
- }
-
- private async reload(requiredIds: readonly string[] = [], requiredPresets: readonly string[] = []): Promise {
- if (this.ownerContext.get('hmr') === undefined) {
- composeEntries([readProfilePatches('dsh', this.profile)])
- return []
- }
- const presets = this.ctx.get('agentPresets')
- const before = new Map((await presets?.list() ?? []).map(row =>
- [row.id, { broken: row.broken, config: this.presetConfiguration(row) }]))
- const warnings = await reconcileProfilePatches(
- this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', [...requiredIds, ...requiredPresets],
- )
- const after = await this.ctx.get('agentPresets')?.list() ?? []
- const required = new Set(after.filter(row => row.definitionEntryId !== undefined && requiredPresets.includes(row.definitionEntryId))
- .map(row => row.id))
- const broken = after.filter(row => row.broken !== undefined)
- const failed = broken.filter(row => required.has(row.id) || before.get(row.id)?.broken !== row.broken
- || before.get(row.id)?.config !== this.presetConfiguration(row))
- if (failed.length > 0) throw new Error(failed.map(row => `agent preset ${row.id}: ${row.broken}`).join('\n'))
- return [...warnings, ...broken.map(row => `agent preset ${row.id}: ${row.broken}`)]
+ private async reload(requiredIds: readonly string[] = []): Promise {
+ if (this.ownerContext.get('hmr') === undefined) return []
+ return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds)
}
private async change(
diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts
index 8d58402e76..2df35b7efc 100644
--- a/packages/boot/plugin-manager/src/operations.ts
+++ b/packages/boot/plugin-manager/src/operations.ts
@@ -4,17 +4,17 @@ import { existsSync, readFileSync } from 'node:fs'
import { mkdir, mkdtemp, open, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { execa } from 'execa'
+import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import {
DEFAULT_PROFILE_BUNDLES, bundlePatchPaths, initProfile, PROFILE_TEMPLATES, readProfileManifest,
- resolveBundleDir, resolveProfileDir, loadOverlayPatches, readProfileVersionExemptions,
+ resolveBundleDir, resolveProfileDir, loadOverlayPatches, composeEntries, readProfileVersionExemptions,
evaluatePluginCompatibility, pluginCompatibilityWarning, type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { parseInstallSpec } from './install-spec.ts'
import { awaitTreeGone, leadsOwnGroup, treeAlive, type RunTree } from './run-tree.ts'
import { incompatiblePlugin } from './failure.ts'
-import { bundleDeclarations } from './bundle-rows.ts'
import type { IncompatiblePlugin, PackageResult, Registry } from './types.ts'
export { setProfileVersionExemption, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
@@ -251,11 +251,15 @@ function bundleComponentManifests(manifest: ProfileManifest, dir: string, anchor
if (bundle === undefined) return []
const patches = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file))
const names = new Set()
- for (const { row } of bundleDeclarations(patches)) {
- if (typeof row.name !== 'string' || row.name.startsWith('.') || row.name.startsWith('/') || row.name.includes(':')) continue
- const parts = row.name.split('/')
- names.add(parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/'))
+ const visit = (rows: EntryOptions[]) => {
+ for (const row of rows) {
+ if (row.group && Array.isArray(row.config)) visit(row.config as EntryOptions[])
+ if (typeof row.name !== 'string' || row.name.startsWith('.') || row.name.startsWith('/') || row.name.includes(':')) continue
+ const parts = row.name.split('/')
+ names.add(parts.slice(0, row.name.startsWith('@') ? 2 : 1).join('/'))
+ }
}
+ visit(composeEntries([patches.filter(patch => patch.insert !== undefined)]))
return [...names].flatMap((name) => {
let packageDir: string
try { packageDir = resolveBundleDir('dsh', name, anchor, dir) } catch (error) {
diff --git a/packages/boot/plugin-manager/src/patch.ts b/packages/boot/plugin-manager/src/patch.ts
index 3a4aa69f09..5776b586fa 100644
--- a/packages/boot/plugin-manager/src/patch.ts
+++ b/packages/boot/plugin-manager/src/patch.ts
@@ -4,9 +4,9 @@ import { isMap, isSeq, parseDocument } from 'yaml'
import { loadOptionalPatches } from '@deepseek-ai/dsh-app-boot'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
-/** Replace the last matching Host override or append one, preserving preset-scoped operations.
+/** Replace the last matching override or append one after existing insertions.
* @param filename Current profile patch file.
- * @param id Unique Host composition entry id.
+ * @param id Unique composition entry id.
* @param name Module name used to match name-qualified overrides.
* @param enabled Desired entry enablement.
* @returns Whether the file changed.
@@ -28,7 +28,7 @@ export async function writePluginEnabled(filename: string, id: string, name: str
loadOptionalPatches('dsh', filename)
const items = document.contents.items
const target = items.findLast((item, index) => {
- if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert') || item.has('preset')) return false
+ if (!isMap(item) || document.getIn([index, 'id']) !== id || item.has('insert')) return false
const expectedName = document.getIn([index, 'name'])
return !expectedName || expectedName === name
})
diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts
index 725d146c3f..5c780a2575 100644
--- a/packages/boot/plugin-manager/src/types.ts
+++ b/packages/boot/plugin-manager/src/types.ts
@@ -1,12 +1,12 @@
/** Public plugin management records shared with clients. */
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest'
-import type { PluginInventoryEntry, AgentPresetPluginRow } from '@deepseek-ai/dsh-host-plugin-inventory/types'
+import type { PluginInventoryEntry } from '@deepseek-ai/dsh-host-plugin-inventory/types'
export type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
import type { PluginEntryId } from '@deepseek-ai/dsh-host-plugin-inventory/types'
/** Reasons a profile control cannot modify its target. */
-export type ReadOnlyReason = 'management-required' | 'unaddressable' | 'preset-managed'
+export type ReadOnlyReason = 'management-required' | 'unaddressable'
/** A package whose declared DSH peers reject the running DSH version, without an exemption for the exact pair. */
export interface IncompatiblePlugin {
@@ -33,14 +33,8 @@ export type PluginInfo = PluginInventoryEntry & (
/** One row a bundle's patch declares, with its live entry while the bundle contributes it. */
export interface BundleRowInfo {
- /** Declared row id, or an internal identity for an anonymous scoped insertion. */
+ /** The row id as the patch declares it. */
rowId: string
- /** Outer preset row id; together with rowId identifies a scoped declaration. */
- preset?: string
- /** Why direct row controls are unavailable; scoped rows are configured through their preset or bundle. */
- readOnlyReason?: ReadOnlyReason
- /** Current preset composition state; scoped rows have no editable root Loader entry. */
- composition?: Pick
/** The module the row names. */
moduleName: string
/** Local package display metadata, including rows whose bundle is disabled. */
diff --git a/packages/boot/plugin-manager/tests/bundle-rows.spec.ts b/packages/boot/plugin-manager/tests/bundle-rows.spec.ts
deleted file mode 100644
index e65959f8d5..0000000000
--- a/packages/boot/plugin-manager/tests/bundle-rows.spec.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-/** Declaration identity and module ownership before activating a bundle. */
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { expect, it, onTestFinished } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
-import { PluginPackages } from '@deepseek-ai/dsh-app-boot'
-import { bundleDeclarations, bundleModuleOwner } from '../src/bundle-rows.ts'
-
-it('reads nested declarations without requiring their target profile or preset', () => {
- const row = { id: 'child', name: './child.mjs' }
- expect(bundleDeclarations([
- { id: 'absent-group', insert: [row] },
- { preset: 'absent-preset', insert: [{ id: 'group', name: 'cordis:group', group: true, config: [row] }] },
- { preset: 'absent-preset', id: 'other', disabled: true },
- ])).toEqual([
- { row },
- { row: { id: 'group', name: 'cordis:group', group: true, config: [row] }, preset: 'absent-preset' },
- { row, preset: 'absent-preset' },
- ])
-})
-
-it('finds canonical owners for bare, relative, absolute, and file URL modules without evaluating them', async () => {
- const dir = mkdtempSync(join(tmpdir(), 'bundle-owner-'))
- const ctx = new Context()
- onTestFinished(async () => { await ctx.fiber.dispose(); rmSync(dir, { recursive: true, force: true }) })
- const packageDir = join(dir, 'node_modules', 'fixture')
- mkdirSync(join(packageDir, 'lib'), { recursive: true })
- const manifest = join(packageDir, 'package.json')
- writeFileSync(manifest, '{"name":"fixture","version":"1.0.0","exports":"./lib/index.mjs"}\n')
- const module = join(packageDir, 'lib', 'index.mjs')
- writeFileSync(module, 'throw new Error("must not import ownership probes")\n')
- const base = pathToFileURL(join(dir, 'profile.json')).href
- await ctx.plugin(PluginPackages)
- const expected = realpathSync(manifest)
- expect(bundleModuleOwner('fixture', base, ctx.pluginPackages)).toBe(expected)
- expect(bundleModuleOwner('fixture/lib/index.mjs', base, ctx.pluginPackages)).toBe(expected)
- expect(bundleModuleOwner('./node_modules/fixture/lib/index.mjs', base)).toBe(expected)
- expect(bundleModuleOwner(module, base)).toBe(expected)
- expect(bundleModuleOwner(pathToFileURL(module).href, base)).toBe(expected)
- expect(bundleModuleOwner('cordis:group', base)).toBeUndefined()
- expect(bundleModuleOwner('uninstalled', base)).toBeUndefined()
- expect(bundleModuleOwner('./absent.mjs', base)).toBeUndefined()
- const loose = join(dir, 'loose.mjs')
- writeFileSync(loose, '')
- expect(bundleModuleOwner(loose, base)).toBeUndefined()
- const linked = join(dir, 'linked')
- symlinkSync(packageDir, linked, process.platform === 'win32' ? 'junction' : 'dir')
- expect(bundleModuleOwner(pathToFileURL(join(linked, 'lib/index.mjs')).href, base)).toBe(expected)
- const otherPackage = join(dir, 'other-package')
- mkdirSync(otherPackage)
- writeFileSync(join(otherPackage, 'package.json'), '{"name":"other-package"}\n')
- const otherModule = join(otherPackage, 'foreign.mjs')
- writeFileSync(otherModule, 'export function apply() {}\n')
- const fileLink = join(packageDir, 'borrowed.mjs')
- symlinkSync(otherModule, fileLink, 'file')
- expect(bundleModuleOwner(fileLink, base)).toBe(realpathSync(join(otherPackage, 'package.json')))
-
-})
diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts
index 52cd122dce..be914515fd 100644
--- a/packages/boot/plugin-manager/tests/manager.spec.ts
+++ b/packages/boot/plugin-manager/tests/manager.spec.ts
@@ -761,6 +761,7 @@ it('addresses children inside profile groups and marks ambiguous ids read-only',
config: [{ id: 'child', name: './plugin.mjs', config: { service: 'child' } }] }])
expect(await manager.setBundleEnabled('grouped', true)).toMatchObject({ application: 'applied' })
expect((await manager.listPlugins()).find(row => row.patchId === 'child')).toBeDefined()
+ expect((await manager.listBundles()).find(item => item.name === 'grouped')?.rows.map(row => row.rowId)).toEqual(['child'])
const entries = composeEntries([readProfilePatches('test', profile)])
const duplicate = entries.find(row => row.id === 'managed')!
writeFileSync(profile.patchPath, JSON.stringify([{ insert: [duplicate] }]))
diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts
index f9aa34314c..9a062e8af9 100644
--- a/packages/boot/plugin-manager/tests/operations.spec.ts
+++ b/packages/boot/plugin-manager/tests/operations.spec.ts
@@ -471,7 +471,7 @@ it.each(['devDependencies', 'optionalDependencies'])('checks installed direct pl
expect(await runProfilePnpm(context, ['install'], { execution: 'service', outputBytes: 8192 })).toMatchObject({ exitCode: 1 })
})
-it.each([undefined, 'preset-standard'])('rejects an incompatible component contributed to %s by a newly installed bundle', async (preset) => {
+it('rejects an incompatible component declared by a newly installed bundle', async () => {
const { dir, context, pnpm } = fixture()
pnpm.mutate = (target) => {
install(target, 'bundle')
@@ -481,16 +481,7 @@ it.each([undefined, 'preset-standard'])('rejects an incompatible component contr
writeFileSync(join(component, 'package.json'), JSON.stringify({
name: 'component', version: '1.0.0', peerDependencies: { '@deepseek-ai/dsh': '>=999.0.0' },
}))
- const unrelated = join(target, 'node_modules', 'component')
- mkdirSync(unrelated, { recursive: true })
- writeFileSync(join(unrelated, 'package.json'), JSON.stringify({ name: 'component', version: '2.0.0' }))
- mkdirSync(join(packageDir, 'nested'))
- writeFileSync(join(packageDir, 'nested', 'group.mjs'), 'export function apply() {}\n')
- writeFileSync(join(packageDir, 'cordis.patch.yml'), JSON.stringify([{
- ...preset === undefined ? {} : { preset }, insert: [{ id: 'relative-group', name: './nested/group.mjs', group: true,
- config: [{ id: 'component', name: 'component/subpath' }],
- }],
- }]))
+ writeFileSync(join(packageDir, 'cordis.patch.yml'), '- insert:\n - id: component\n name: component/subpath\n')
}
const outcome = await runProfilePnpm(context, ['add', 'bundle'], { execution: 'service', outputBytes: 8192, activateNewBundles: false })
expect(outcome.exitCode).toBe(1)
@@ -512,7 +503,7 @@ it('restores the manifest on malformed installed peer metadata and bounds the wa
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
})
-it('validates nested preset contributions without requiring their target preset to be selected', async () => {
+it('checks scoped components in nested groups while leaving unresolved and local rows to startup', async () => {
const { context, pnpm } = fixture()
pnpm.mutate = (target) => {
install(target, 'bundle')
@@ -520,7 +511,7 @@ it('validates nested preset contributions without requiring their target preset
const component = join(packageDir, 'node_modules', '@example', 'component')
mkdirSync(component, { recursive: true })
writeFileSync(join(component, 'package.json'), JSON.stringify({ name: '@example/component', version: '1.0.0' }))
- writeFileSync(join(packageDir, 'cordis.patch.yml'), JSON.stringify([{ preset: 'preset-standard', insert: [
+ writeFileSync(join(packageDir, 'cordis.patch.yml'), JSON.stringify([{ insert: [
{ id: 'group', group: true, config: [{ id: 'component', name: '@example/component/subpath' }] },
{ id: 'empty-group', group: true, config: {} },
{ id: 'relative', name: './plugin.mjs' },
diff --git a/packages/boot/plugin-manager/tests/patch.spec.ts b/packages/boot/plugin-manager/tests/patch.spec.ts
index a51c544d2f..30bb947c84 100644
--- a/packages/boot/plugin-manager/tests/patch.spec.ts
+++ b/packages/boot/plugin-manager/tests/patch.spec.ts
@@ -41,19 +41,6 @@ it('creates a missing patch file and appends after insertions', async () => {
])
})
-it.each([false, true])('preserves same-ID preset overrides when a Host override exists: %s', async (hostOverride) => {
- const preset = { preset: 'preset-standard', id: 'tool-bash', name: 'package', disabled: true }
- const file = await fixture(JSON.stringify([
- ...hostOverride ? [{ id: 'tool-bash', disabled: false }] : [], preset,
- ]))
- expect(await writePluginEnabled(file, 'tool-bash', 'package', false)).toBe(true)
- const disabled = { id: 'tool-bash', disabled: true }
- expect(loadOptionalPatches('test', file)).toEqual(hostOverride ? [disabled, preset] : [preset, disabled])
- expect(await writePluginEnabled(file, 'tool-bash', 'package', true)).toBe(true)
- const enabled = { id: 'tool-bash', disabled: false }
- expect(loadOptionalPatches('test', file)).toEqual(hostOverride ? [enabled, preset] : [preset, enabled])
-})
-
it.each(['- id: [broken', 'mapping: true\n'])('refuses malformed documents without overwriting %s', async (text) => {
const file = await fixture(text)
await expect(writePluginEnabled(file, 'tool', 'package', true)).rejects.toThrow()
diff --git a/packages/boot/plugin-manager/tests/preset-bundles.spec.ts b/packages/boot/plugin-manager/tests/preset-bundles.spec.ts
deleted file mode 100644
index 08c063b180..0000000000
--- a/packages/boot/plugin-manager/tests/preset-bundles.spec.ts
+++ /dev/null
@@ -1,424 +0,0 @@
-/** Scoped bundles use real preset generations, profile reloads, and package-removal admission. */
-import fs, { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
-import { syncBuiltinESMExports } from 'node:module'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { pathToFileURL } from 'node:url'
-import { expect, it, onTestFinished, vi } from 'vitest'
-import type { Context } from '@deepseek-ai/cordis'
-import Timer from '@deepseek-ai/cordis-plugin-timer'
-import { boot, initProfile, PluginPackages, readProfileManifest, readProfilePatches, createRuntimeResolution, loadProfileDirectory, type ProfileContext, type ProfilePatch } from '@deepseek-ai/dsh-app-boot'
-import Hmr from '@deepseek-ai/dsh-hmr'
-import AgentPreset from '@deepseek-ai/dsh-agent-preset'
-import AgentPresets from '@deepseek-ai/dsh-agent-preset-registry'
-import { createScope } from '@deepseek-ai/dsh-scope'
-import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
-import SessionProjections from '@deepseek-ai/dsh-session-projection'
-import SessionTitle, { SessionTitleProviderId, type SessionTitleProviderRequest, type SessionTitleProviderResult } from '@deepseek-ai/dsh-session-title'
-import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import PluginManager from '../src/index.ts'
-import * as operations from '../src/operations.ts'
-import * as appBoot from '@deepseek-ai/dsh-app-boot'
-
-async function fixture(options: { title?: boolean; hmr?: boolean; presets?: boolean } = {}) {
- const home = mkdtempSync(join(tmpdir(), 'preset-bundle-'))
- let owner: Context | undefined
- onTestFinished(async () => { await owner?.fiber.dispose(); rmSync(home, { recursive: true, force: true }) })
- const dir = join(home, 'profiles', 'test')
- const anchor = join(home, 'package.json')
- writeFileSync(anchor, '{"name":"test-installation","dependencies":{}}\n')
- initProfile(dir, ['core'])
- writeFileSync(join(dir, 'cordis.yml'), '[]\n')
- const bundle = (name: string, patches: ProfilePatch[]): string => {
- const path = join(dir, 'node_modules', name)
- mkdirSync(path, { recursive: true })
- writeFileSync(join(path, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
- writeFileSync(join(path, 'cordis.patch.yml'), JSON.stringify(patches))
- writeFileSync(join(path, 'plugin.mjs'), 'export function apply(ctx, config) { if (config?.fail) throw new Error("scoped plugin failed"); ctx.get("scopedBundleTrace").push("start"); ctx.effect(() => () => { ctx.get("scopedBundleTrace").push("stop") }) }\n')
- return path
- }
- bundle('core', [{ insert: [
- { id: 'manager', name: 'cordis:manager' },
- { id: 'sessions', name: 'cordis:sessions' },
- { id: 'session-projections', name: 'cordis:session-projections' },
- { id: 'presets', name: 'cordis:presets', config: { default: 'standard' } },
- ...(options.presets === false ? [] : ['standard', 'cordis']).map(id => ({ id: `preset-${id}`, name: 'cordis:preset', config: { id, plugins: [] } })),
- ...options.title === true ? [
- { id: 'title', name: 'cordis:title', config: { fallbackMaxWords: 5, fallbackMaxBytes: 100, maxTitleBytes: 100 } },
- { id: 'default-title', name: 'cordis:title-provider', config: { id: 'default' } },
- ] : [],
- ] }])
- const addon = bundle('addon', ['standard', 'cordis'].map(preset => ({ preset: `preset-${preset}`, insert: [{
- id: 'shared-id', name: './plugin.mjs',
- }] })))
- const manifest = readProfileManifest('test', dir)
- manifest.dependencies = { addon: '1.0.0' }
- writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
- const profile: ProfileContext = {
- name: 'test', dir, patchPath: join(dir, 'cordis.patch.yml'), installAnchor: anchor,
- cwd: home, home, startedBundles: ['core'], overlays: [], telemetryDisabledEnv: undefined,
- }
- const resolution = await createRuntimeResolution({ installAnchor: anchor, profile: loadProfileDirectory('test', dir, anchor), home })
- const calls: {
- id: string
- request: SessionTitleProviderRequest
- pending: ReturnType>
- }[] = []
- const entered: { current: ReturnType> } = { current: Promise.withResolvers() }
- const trace: string[] = []
- const ctx = await boot('test', join(dir, 'cordis.yml'), readProfilePatches('test', profile), async (host) => {
- owner = host
- host.provide('profileContext', profile)
- host.provide('scopedBundleTrace', trace)
- host.provide('appReady', { onReady(listener) { listener(); return () => {} } })
- Object.assign(host.loader.builtins, {
- manager: PluginManager, sessions: SessionStore, 'session-projections': SessionProjections,
- presets: AgentPresets, preset: AgentPreset, title: SessionTitle,
- 'title-provider': {
- inject: ['sessionTitle'],
- apply(ctx: Context, config: { id: string }) {
- ctx.sessionTitle.register({
- id: SessionTitleProviderId(config.id), automatic: 'all-prompts',
- generate(request) {
- const pending = Promise.withResolvers()
- calls.push({ id: config.id, request, pending })
- entered.current.resolve(undefined)
- return pending.promise
- },
- })
- },
- },
- })
- await host.plugin(PluginPackages, { resolution })
- })
- await ctx.plugin(Timer)
- if (options.hmr !== false) {
- await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
- await ctx.hmr.runExclusive(async () => {})
- }
- onTestFinished(() => {
- for (const call of calls) call.pending.resolve({ title: 'teardown', messageSeqs: call.request.messages.map(message => message.seq) })
- })
- return { ctx, manager: ctx.pluginManager, profile, dir, addon, bundle, trace, calls, entered }
-}
-
-it('lists scoped declarations while Off and keeps equal inner ids distinct across presets', async () => {
- const { manager, addon } = await fixture()
- const patchFile = join(addon, 'cordis.patch.yml')
- const patches = JSON.parse(readFileSync(patchFile, 'utf8')) as ProfilePatch[]
- writeFileSync(patchFile, JSON.stringify([...patches,
- { id: 'shared-id', disabled: true },
- { preset: 'preset-standard', id: 'absent', disabled: true },
- ]))
- const moduleName = pathToFileURL(join(addon, 'plugin.mjs')).href
- expect((await manager.listBundles()).find(row => row.name === 'addon')?.rows).toEqual([
- { rowId: 'shared-id', preset: 'preset-standard', readOnlyReason: 'preset-managed', moduleName },
- { rowId: 'shared-id', preset: 'preset-cordis', readOnlyReason: 'preset-managed', moduleName },
- ])
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied', changed: true })
- const enabled = (await manager.listBundles()).find(row => row.name === 'addon')!
- expect(enabled.rows).toEqual([
- { rowId: 'shared-id', preset: 'preset-standard', readOnlyReason: 'preset-managed', moduleName, composition: { enabled: true, fiberPhase: 'active' } },
- { rowId: 'shared-id', preset: 'preset-cordis', readOnlyReason: 'preset-managed', moduleName, composition: { enabled: true, fiberPhase: 'active' } },
- ])
- expect(enabled.rows.every(row => row.entryId === undefined)).toBe(true)
- expect(enabled.overrides).toEqual(['shared-id', 'preset-standard/absent'])
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
- expect((await manager.listBundles()).find(row => row.name === 'addon')?.rows.every(row => row.composition === undefined)).toBe(true)
-})
-
-it('loads a third-party relative child inside a real preset group and retains its package until release', async () => {
- const { manager, ctx, dir, addon, bundle, trace } = await fixture()
- bundle('addon', [{ preset: 'preset-standard', insert: [{
- id: 'relative-group', name: 'cordis:group', group: true, config: [{
- id: 'relative-child', name: './nested/relative.mjs', config: { value: 'nested-preset' },
- }],
- }] }])
- mkdirSync(join(addon, 'nested'))
- writeFileSync(join(addon, 'nested/relative.mjs'), [
- 'export function apply(ctx, config) {',
- ' ctx.get("scopedBundleTrace").push(`relative:start:${config.value}`)',
- ' ctx.effect(() => () => { ctx.get("scopedBundleTrace").push("relative:stop") })',
- '}',
- '',
- ].join('\n'))
- const moduleName = pathToFileURL(join(addon, 'nested/relative.mjs')).href
- const declared = (await manager.listBundles()).find(row => row.name === 'addon')?.rows
- .find(row => row.rowId === 'relative-child')
- expect(declared).toEqual({ rowId: 'relative-child', preset: 'preset-standard', readOnlyReason: 'preset-managed', moduleName })
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
- expect(trace).toEqual(['relative:start:nested-preset'])
- expect((await ctx.agentPresets.compositionInventory()).find(preset => preset.id === 'standard')?.rows)
- .toMatchObject([{ entryId: 'relative-child', moduleName, enabled: true }])
-
- const owner = createScope(ctx, {})
- onTestFinished(() => owner.dispose())
- await ctx.agentPresets.mount(owner.ctx, 'standard')
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
- expect(ctx.agentPresets.inspectCompositions(owner.ctx)[0]?.modules).toMatchObject([{ moduleName }])
- expect(trace).toEqual(['relative:start:nested-preset'])
- const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
- const manifest = readProfileManifest('test', dir)
- delete manifest.dependencies?.addon
- writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
- return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log') }
- })
- onTestFinished(() => { remove.mockRestore() })
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } })
- expect(remove).not.toHaveBeenCalled()
- await owner.dispose()
- expect(trace).toEqual(['relative:start:nested-preset', 'relative:stop'])
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'applied', changed: true })
- expect(remove).toHaveBeenCalledOnce()
-})
-
-it('retains a failed selection, reports affected preset failures again, and keeps unrelated failures as warnings', async () => {
- const { manager, dir, bundle, ctx } = await fixture()
- bundle('broken', [{ preset: 'preset-standard', insert: [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }] }])
- const result = await manager.setBundleEnabled('broken', true)
- expect(result).toMatchObject({ application: 'failed', changed: true, error: { diagnostic: expect.stringContaining('agent preset standard') as string } })
- expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('broken')
- expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed', changed: false })
- expect((await ctx.agentPresets.list()).find(row => row.id === 'standard')?.broken).toContain('scoped plugin failed')
- bundle('unrelated', [{ insert: [{ id: 'unrelated', name: './plugin.mjs' }] }])
- expect(await manager.setBundleEnabled('unrelated', true)).toMatchObject({ application: 'applied', warnings: [expect.stringContaining('agent preset standard')] })
- expect(await manager.setBundleEnabled('broken', false)).toMatchObject({ application: 'applied' })
- expect((await ctx.agentPresets.list()).every(row => row.broken === undefined)).toBe(true)
-})
-
-it('refuses a no-op scoped enable when its Host preset row already failed before registering', async () => {
- const { manager, ctx, bundle, dir } = await fixture()
- bundle('invalid-definition', [{ id: 'preset-standard', config: { id: '', plugins: [] } }])
- expect(await manager.setBundleEnabled('invalid-definition', true)).toMatchObject({ application: 'failed' })
- expect((await ctx.agentPresets.list()).map(row => row.id)).not.toContain('standard')
- bundle('scoped-noop', [{ preset: 'preset-standard', insert: [] }])
- expect(await manager.setBundleEnabled('scoped-noop', true)).toMatchObject({
- application: 'failed', changed: true, error: { diagnostic: expect.stringContaining('Preset id must not be empty') as string },
- })
- expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('scoped-noop')
-})
-
-it.each([{ id: 'standard' }, { id: 'standard', plugins: [] }])(
- 'keeps existing preset failures as warnings when an unrelated Host config is %j', async (config) => {
- const { manager, bundle, ctx } = await fixture()
- expect(await ctx.agentPresets.list()).toMatchObject([{ id: 'cordis', definitionEntryId: 'preset-cordis' }, { id: 'standard', definitionEntryId: 'preset-standard' }])
- expect((await ctx.agentPresets.remoteExportList()).presets.every(row => !Object.hasOwn(row, 'definitionEntryId'))).toBe(true)
- bundle('broken', [{ preset: 'preset-standard', insert: [{ id: 'broken', name: './plugin.mjs', config: { fail: true } }] }])
- expect(await manager.setBundleEnabled('broken', true)).toMatchObject({ application: 'failed' })
- bundle('ordinary', [{ insert: [{ id: 'ordinary', name: './plugin.mjs', config }] }])
- expect(await manager.setBundleEnabled('ordinary', true)).toMatchObject({
- application: 'applied', warnings: [expect.stringContaining('agent preset standard')],
- })
- },
-)
-
-it('reports invalid composition immediately without HMR while retaining the saved selection', async () => {
- const { manager, dir } = await fixture({ hmr: false, presets: false })
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({
- application: 'failed', changed: true, error: { diagnostic: expect.stringContaining(join('addon', 'cordis.patch.yml')) as string },
- })
- expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('addon')
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'restart-required', changed: true })
- expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon')
-})
-
-it('refuses Remove after Off while an old Agent scope retains the bundle, then removes after release', async () => {
- const { manager, ctx, dir, trace } = await fixture()
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
- const agent = createScope(ctx, {})
- onTestFinished(() => agent.dispose())
- await ctx.agentPresets.mount(agent.ctx, 'standard')
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
- expect(ctx.agentPresets.inspectCompositions(agent.ctx)[0]?.modules).toHaveLength(1)
- const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
- const manifest = readProfileManifest('test', dir)
- delete manifest.dependencies?.addon
- writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
- return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log') }
- })
- onTestFinished(() => { remove.mockRestore() })
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' }, changed: false })
- expect(remove).not.toHaveBeenCalled()
- await agent.dispose()
- expect(trace.filter(item => item === 'stop')).toHaveLength(2)
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'applied', changed: true })
- expect(remove).toHaveBeenCalledOnce()
-})
-
-it.each([false, true].flatMap(anonymous => [false, true].map(archive => ({ anonymous, archive }))))
-('removes a released contribution while base presets keep using the same shared module (anonymous=$anonymous, archive=$archive)', async ({ anonymous, archive }) => {
- // Resolver identities must be created under the same carrier that later refreshes them.
- if (archive) {
- const previousPkg = Object.getOwnPropertyDescriptor(process, 'pkg')
- onTestFinished(() => {
- if (previousPkg === undefined) Reflect.deleteProperty(process, 'pkg')
- else Object.defineProperty(process, 'pkg', previousPkg)
- })
- Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
- }
- const { manager, ctx, dir, addon } = await fixture()
- const shared = join(dir, 'node_modules', 'shared-fixture')
- mkdirSync(shared)
- writeFileSync(join(shared, 'package.json'), JSON.stringify({ name: 'shared-fixture', version: '1.0.0', type: 'module', exports: './index.mjs' }))
- writeFileSync(join(shared, 'index.mjs'), 'export function apply() {}\n')
- const core = join(dir, 'node_modules', 'core')
- const corePatches = JSON.parse(readFileSync(join(core, 'cordis.patch.yml'), 'utf8')) as ProfilePatch[]
- for (const row of corePatches[0]!.insert!) {
- if (row.id === 'preset-standard') row.config = { id: 'standard', plugins: [{ id: 'base-shared', name: 'shared-fixture' }] }
- if (row.id === 'preset-cordis') row.config = { id: 'cordis', plugins: [{ id: 'addon-shared', name: 'shared-fixture' }] }
- }
- writeFileSync(join(core, 'cordis.patch.yml'), JSON.stringify(corePatches))
- writeFileSync(join(addon, 'cordis.patch.yml'), JSON.stringify([{
- preset: 'preset-standard', insert: [{ ...anonymous ? {} : { id: 'addon-shared' }, name: 'shared-fixture' }],
- }]))
- for (const packageDir of [core, addon]) {
- const manifest = readProfileManifest('test', packageDir)
- manifest.dependencies = { 'shared-fixture': '1.0.0' }
- writeFileSync(join(packageDir, 'package.json'), JSON.stringify(manifest))
- }
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
- const owner = createScope(ctx, {})
- onTestFinished(() => owner.dispose())
- await ctx.agentPresets.mount(owner.ctx, 'standard')
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
- expect(ctx.agentPresets.inspectCompositions(owner.ctx)).toMatchObject([{
- id: 'standard', definitionEntryId: 'preset-standard', modules: [
- { entryId: 'base-shared', moduleName: 'shared-fixture' },
- { entryId: anonymous ? expect.any(String) as string : 'addon-shared', moduleName: 'shared-fixture' },
- ],
- }])
- if (archive) {
- const original = fs.realpathSync
- onTestFinished(() => { fs.realpathSync = original; syncBuiltinESMExports() })
- fs.realpathSync = new Proxy(original, {
- apply(target, receiver: unknown, args: unknown[]): unknown {
- if (['package.json', 'index.mjs'].some(file => String(args[0]).endsWith(join('shared-fixture', file)))) {
- throw Object.assign(new Error('archive-backed files have no realpath entry'), { code: 'ENOENT' })
- }
- return Reflect.apply(target, receiver, args)
- },
- })
- syncBuiltinESMExports()
- expect(() => fs.realpathSync(join(shared, 'package.json'))).toThrow('archive-backed')
- expect(readFileSync(join(shared, 'index.mjs'), 'utf8')).toContain('export function apply')
- }
- const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
- const manifest = readProfileManifest('test', dir)
- delete manifest.dependencies?.addon
- writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
- return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log') }
- })
- onTestFinished(() => { remove.mockRestore() })
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } })
- expect(remove).not.toHaveBeenCalled()
- await owner.dispose()
- expect(ctx.agentPresets.inspectCompositions().flatMap(preset => preset.modules).map(module => module.moduleName))
- .toEqual(['shared-fixture', 'shared-fixture'])
- const removed = await manager.removeBundle('addon')
- expect(removed, JSON.stringify(removed.error)).toMatchObject({ application: 'applied', changed: true })
- expect(remove).toHaveBeenCalledOnce()
-})
-
-it('removes an unrelated bundle when another retained row with the same id has lost its module file', async () => {
- const { manager, ctx, dir, addon, bundle } = await fixture()
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
- const owner = createScope(ctx, {})
- onTestFinished(() => owner.dispose())
- await ctx.agentPresets.mount(owner.ctx, 'standard')
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
- bundle('unrelated', [{ preset: 'preset-standard', insert: [{ id: 'shared-id', name: './plugin.mjs' }] }])
- const manifest = readProfileManifest('test', dir)
- manifest.dependencies = { ...manifest.dependencies, unrelated: '1.0.0' }
- writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
- rmSync(join(addon, 'plugin.mjs'))
- const remove = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({
- exitCode: 0, output: '', truncated: false, logPath: join(dir, 'remove.log'),
- })
- onTestFinished(() => { remove.mockRestore() })
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } })
- expect(remove).not.toHaveBeenCalled()
- const resolution = vi.spyOn(appBoot, 'resolvePluginResource').mockImplementationOnce(() => { throw new Error('resolver unavailable') })
- onTestFinished(() => { resolution.mockRestore() })
- expect(await manager.removeBundle('unrelated')).toMatchObject({ application: 'failed', error: { diagnostic: 'resolver unavailable' } })
- expect(remove).not.toHaveBeenCalled()
- resolution.mockRestore()
- expect(await manager.removeBundle('unrelated')).toMatchObject({ application: 'applied' })
- expect(remove).toHaveBeenCalledOnce()
- expect(ctx.agentPresets.inspectCompositions(owner.ctx)[0]?.modules).toHaveLength(1)
-})
-
-it('retains contributed builtin rows even when no package owns their module names', async () => {
- const { manager, ctx, bundle } = await fixture()
- ctx.loader.builtins['retained-fixture'] = { apply() {} }
- bundle('addon', [{ preset: 'preset-standard', insert: [{ id: 'builtin', name: 'cordis:retained-fixture' }] }])
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
- const agent = createScope(ctx, {})
- onTestFinished(() => agent.dispose())
- await ctx.agentPresets.mount(agent.ctx, 'standard')
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
- const inspect = ctx.agentPresets.inspectCompositions.bind(ctx.agentPresets)
- const projection = vi.spyOn(ctx.agentPresets, 'inspectCompositions').mockImplementation(() => inspect().map(composition => ({
- ...composition, modules: composition.modules.map(({ baseUrl: _baseUrl, ...module }) => module),
- })))
- onTestFinished(() => { projection.mockRestore() })
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } })
-})
-
-it('refuses removal when a different module from the same package remains in a preset', async () => {
- const { manager, ctx, dir, addon } = await fixture()
- const source = readFileSync(join(addon, 'plugin.mjs'), 'utf8')
- writeFileSync(join(addon, 'other.mjs'), source)
- expect(await manager.setBundleEnabled('addon', true)).toMatchObject({ application: 'applied' })
- writeFileSync(join(dir, 'cordis.patch.yml'), JSON.stringify([{
- preset: 'preset-standard', insert: [{ id: 'independent', name: pathToFileURL(join(addon, 'other.mjs')).href }],
- }]))
- expect(await manager.setBundleEnabled('addon', false)).toMatchObject({ application: 'applied' })
- expect(ctx.agentPresets.inspectCompositions().some(row => row.modules.some(module => module.moduleName.endsWith('/other.mjs')))).toBe(true)
- const remove = vi.spyOn(operations, 'runProfilePnpm')
- onTestFinished(() => { remove.mockRestore() })
- expect(await manager.removeBundle('addon')).toMatchObject({ application: 'failed', error: { code: 'bundle-in-use' } })
- expect(remove).not.toHaveBeenCalled()
-})
-
-async function requestTitle(session: Session): Promise {
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Title for provider switch' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
- await new Promise((resolve) => { queueMicrotask(resolve) })
- session.append('request/header', { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' })
-}
-
-it('drains and rejects stale title completions before activating each replacement in both directions', async () => {
- const { manager, ctx, bundle, calls, entered } = await fixture({ title: true })
- bundle('titles', [
- { id: 'default-title', disabled: true },
- { insert: [{ id: 'optional-title', name: 'cordis:title-provider', config: { id: 'optional' } }] },
- ])
- for (const [enabled, expected] of [[true, 'default'], [false, 'optional']] as const) {
- entered.current = Promise.withResolvers()
- const session = ctx.sessions.create(SessionId(`title-switch-${String(enabled)}`))
- await requestTitle(session)
- await entered.current.promise
- const call = calls.at(-1)!
- expect(call.id).toBe(expected)
- const aborted = Promise.withResolvers()
- call.request.signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
- let complete = false
- const switching = manager.setBundleEnabled('titles', enabled).then((result) => { complete = true; return result })
- onTestFinished(async () => {
- call.pending.resolve({ title: 'teardown', messageSeqs: call.request.messages.map(message => message.seq) })
- await switching
- })
- await aborted.promise
- expect(complete).toBe(false)
- expect(calls).toHaveLength(enabled ? 1 : 2)
- call.pending.resolve({ title: 'stale result', messageSeqs: call.request.messages.map(message => message.seq) })
- expect(await switching).toMatchObject({ application: 'applied' })
- expect(ctx.sessionTitle.get(session)?.source.kind).toBe('fallback')
- }
- entered.current = Promise.withResolvers()
- const final = ctx.sessions.create(SessionId('title-switch-final'))
- await requestTitle(final)
- await entered.current.promise
- expect(calls.at(-1)?.id).toBe('default')
- calls.at(-1)?.pending.resolve({ title: 'final title', messageSeqs: calls.at(-1)!.request.messages.map(message => message.seq) })
-})
diff --git a/packages/boot/plugin-manager/tsconfig.json b/packages/boot/plugin-manager/tsconfig.json
index 643f870407..ba181795a1 100644
--- a/packages/boot/plugin-manager/tsconfig.json
+++ b/packages/boot/plugin-manager/tsconfig.json
@@ -64,9 +64,6 @@
},
{
"path": "../../interaction/user-approval"
- },
- {
- "path": "../../preset/agent-preset-registry"
}
]
}
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml
index 5219c2444f..9e3f03eefc 100644
--- a/packages/bundle/base/cordis.patch.yml
+++ b/packages/bundle/base/cordis.patch.yml
@@ -328,7 +328,7 @@
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
- You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml
index b8b29a0453..ca0a12fc89 100644
--- a/packages/bundle/sdk-app/README.i18n.yaml
+++ b/packages/bundle/sdk-app/README.i18n.yaml
@@ -9,8 +9,8 @@
en: 1393fc0c2c6cf60c
zh: 61e2f2c46463101b
/deepseek-ai-dsh-sdk-app/summary:
- en: f96841d96dc95a30
- zh: 88aa3e92ae1deb9c
+ en: 5583f814d74d685f
+ zh: 7d52ecc2cdcd78d4
/deepseek-ai-dsh-sdk-app/table-of-contents:
en: 4e53164159b5ce41
zh: 89582dc0294d0776
@@ -20,16 +20,16 @@
/deepseek-ai-dsh-sdk-app/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
-/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona:
- en: af149b1e7ed0a5fc
- zh: fe483822d03deddb
-/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona/what-the-model-sees:
- en: afe0af085d77833e
- zh: f41ef2a2219f1248
-/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona/token-effect:
- en: 371d28831239e0a8
- zh: 57595359ff30a259
-/deepseek-ai-dsh-sdk-app/model-experience/sdk-coding-agent-persona/kv-cache-effect:
+/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context:
+ en: 4f88e647c6df4f42
+ zh: 2bd3e4b0bdfe9412
+/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/what-the-model-sees:
+ en: 40f68e7d3f181ed6
+ zh: 64b0d7c8bf63dfa1
+/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/token-effect:
+ en: 92d8ecb761e3ca7b
+ zh: e14c71ee2c31e818
+/deepseek-ai-dsh-sdk-app/model-experience/sdk-model-context/kv-cache-effect:
en: 5b0e33295fb90e19
zh: 4d48ca75bb677202
/deepseek-ai-dsh-sdk-app/known-limitations-and-deferred-work:
diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md
index df3d0aa353..e2615fc435 100644
--- a/packages/bundle/sdk-app/README.md
+++ b/packages/bundle/sdk-app/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider with its own profile name.
+The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider with its own profile name.
## Table of Contents
@@ -40,15 +40,15 @@ The packaged Python runtime enables the [Office runtime query and skills](../../
## Model Experience
-### SDK coding-agent persona
+### SDK model context
#### What the model sees
-The profile supplies `You are a coding agent powered by the {{model}} model.` before first-party guidance; the SDK initialization route resolves `{{model}}`. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`.
+The profile inherits the base system prompt, including the `You are an AI agent powered by DeepSeek Harness.` identity, without adding a task-specific persona. Each Session receives its current directory in required user-role context from [`dsh-working-directory`](../../session/working-directory/README.md). Default file tool schemas include `read`, `write`, and `edit`; they omit `str_replace_editor`.
#### Token effect
-One short stable persona plus the data-dependent base prompt sections and selected tool schemas.
+The base prompt sections and selected tool schemas determine token usage; this profile adds no persona text.
#### KV Cache effect
diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md
index 026d442b1a..610f4bef5c 100644
--- a/packages/bundle/sdk-app/README.zh.md
+++ b/packages/bundle/sdk-app/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-bundle"
## 概述
-以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) bundle 复用同一个启动提供方,并提供自己的 profile 名称。
+以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) bundle 复用同一个启动提供方,并提供自己的 profile 名称。
## 目录
@@ -40,15 +40,15 @@ SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_rep
## 模型体验
-### SDK coding agent persona
+### SDK 模型上下文
#### 模型看到什么
-profile 在第一方指导之前提供 `You are a coding agent powered by the {{model}} model.`;SDK 初始化路由解析 `{{model}}`。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。
+profile 继承 base 系统提示词,包括 `You are an AI agent powered by DeepSeek Harness.` 身份说明,不额外添加特定任务的 persona。每个 Session 通过 [`dsh-working-directory`](../../session/working-directory/README.zh.md) 提供的必需 user-role 上下文接收当前目录。默认文件工具 schema 包含 `read`、`write` 和 `edit`,不包含 `str_replace_editor`。
#### Token 影响
-一段简短稳定的 persona,加上随数据变化的 base 提示词段落与所选工具 schema。
+base 提示词段落与所选工具 schema 决定 token 用量;此 profile 不额外添加 persona 文本。
#### KV Cache 影响
diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml
index d852a0a41d..1c0beaae95 100644
--- a/packages/bundle/sdk-app/cordis.patch.yml
+++ b/packages/bundle/sdk-app/cordis.patch.yml
@@ -1,10 +1,5 @@
# The SDK application over dsh-base. Stdout belongs exclusively to JSON-RPC.
-- id: system-prompt
- config:
- personaPrefix: >-
- You are a coding agent powered by the {{model}} model.
-
- id: session-title-llm
disabled: true
diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml
index 0c5a6c4a2e..1c6e763915 100644
--- a/packages/bundle/web-app/README.i18n.yaml
+++ b/packages/bundle/web-app/README.i18n.yaml
@@ -9,8 +9,8 @@
en: a85684740ecd278a
zh: 7c54099b9edccf01
/deepseek-ai-dsh-web-app/summary:
- en: fd97ee0c14fb5378
- zh: 0e1ccba47bc408d9
+ en: 0a2a9876194543e5
+ zh: ff472776a44ae260
/deepseek-ai-dsh-web-app/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
@@ -18,38 +18,35 @@
en: 3bd37b85139d2fa7
zh: ba86c807eb83866e
/deepseek-ai-dsh-web-app/use-this-package/starting-the-web-gui:
- en: b7ab01cfe9fed40f
- zh: 23703ab7727c3ab5
+ en: e3cac911f080d8ef
+ zh: 60749e9f5c39c1bc
/deepseek-ai-dsh-web-app/use-this-package/configuration:
- en: a9ee366c5af68a6c
- zh: 2711d7a3926e1101
+ en: 2b09528c8fc8d370
+ zh: 7b374ebda8841172
/deepseek-ai-dsh-web-app/use-this-package/listening-trust-and-public-deployments:
- en: a79add86cb73d966
- zh: 78b58259f98ed063
+ en: 177400de33509826
+ zh: dd88bc096accffc8
/deepseek-ai-dsh-web-app/use-this-package/running-over-ssh:
- en: 45db677dcc91c5c9
- zh: 75b765f7f1e15035
+ en: df205a26b6d49168
+ zh: e46ed7cf9e06248a
/deepseek-ai-dsh-web-app/use-this-package/per-session-agent-setup:
en: ccd6fc264b51e44b
zh: 3265c68477a89ec9
/deepseek-ai-dsh-web-app/understand-the-implementation:
- en: fb819d10930089a8
- zh: f2dee0ff170a4d41
+ en: 2ea88398028ebff8
+ zh: 0e589c297aeb9d43
/deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics:
- en: 23ddb4d33e79c0a1
- zh: dc9a458b9492b49b
+ en: 8a2554b3d17daa1f
+ zh: 66f18946791cabb6
/deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url:
en: ca977d2b2bcf72c5
zh: 2b81b377c133c1a8
/deepseek-ai-dsh-web-app/understand-the-implementation/readiness:
en: 973128fd346869df
zh: 54e6792f9f82a192
-/deepseek-ai-dsh-web-app/understand-the-implementation/lan-trust-sampling:
- en: b5ed8ff0056ca9da
- zh: fdc1fa4f14cb00b3
/deepseek-ai-dsh-web-app/understand-the-implementation/source-map:
- en: e41444bed021a121
- zh: 44088a90df50d5b4
+ en: d4c7f39a9fd90683
+ zh: ac2dbf75b2ceb255
/deepseek-ai-dsh-web-app/further-exploration:
en: f81694f7424e2988
zh: 6643f9227039e228
@@ -69,8 +66,8 @@
en: 1d7d87f54b20b563
zh: d150a51615d278e8
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work:
- en: d6de1d07249ef3b0
- zh: 49d01329256e2eb3
+ en: 635c1336e2f4e821
+ zh: caabdecb02e2df14
/deepseek-ai-dsh-web-app/known-limitations-and-deferred-work/dev-note:
en: 3f81b4f3beb74a0d
zh: 2e377ca40822d093
diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md
index fc4584fbad..b1bce71984 100644
--- a/packages/bundle/web-app/README.md
+++ b/packages/bundle/web-app/README.md
@@ -13,7 +13,7 @@ Desktop analytics schedules partial batches every 30 seconds, with a 15-second e
## Summary
-Run `dsh --profile web` for browser chat, model and settings management, and session history, using the same model access, tools, and safety defaults as other dsh surfaces. Startup prints a tokenized URL and normally opens the default browser; SSH sessions and `--no-open` require manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Remote access supports an advertised public HTTP(S) URL behind a prefix-stripping proxy. For one-shot command-line tasks, use `dsh-headless`.
+Run `dsh --profile web` for browser chat, model and settings management, and session history, with the same model access, tools, and safety defaults as other dsh surfaces. Startup prints a tokenized URL and normally opens the default browser; SSH sessions and `--no-open` require manual opening. You can change the port, allow extra authorities, bind one concrete local IP, and serve HTTPS from a certificate you supply; wildcard addresses are rejected. Remote access uses an advertised HTTP(S) URL behind a prefix-stripping proxy, listener TLS, or plain HTTP on a non-loopback bind. Use the headless profile for one-shot command-line tasks.
## Table of Contents
@@ -40,36 +40,49 @@ dsh --profile web --no-open --port 8080
After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the same directory without the token. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself.
-**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V41-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API.
+**Settings → Models** displays **DeepSeek**, using `DEEPSEEK_API_KEY`. The default is `deepseek-official` / `deepseek-flash` (DeepSeek-V4.1-Flash). The [DeepSeek plugin](../../llm/llm-deepseek/README.md#endpoint-and-wire-format) uses the Messages API.
Saved model selections override the composition default. The settings card accepts a Messages-compatible API address and a credential reference.
### Configuration
-`--host` and `--port` configure the listener; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments):
+`--host` and `--port` configure the listener; `--tls-cert` and `--tls-key` make it serve HTTPS; `--public-url` names the advertised public HTTP(S) root the GUI is reached at behind a prefix-stripping proxy, and `--trusted-host` adds further accepted authorities. All are described under [Listening, trust, and public deployments](#public-deployments), and the `tls` field those flags set is documented with the [carrier's config](../../../docs/subsystems/web-server.md#config):
| Field | Default | Meaning |
|---|---|---|
| `openBrowser` | `true` | Open the default browser after startup; SSH launches suppress it |
| `printUrl` | `true` | Print the `dsh web:` URL line at startup |
| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands |
-| `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the listener's loopback URL |
-| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network |
+| `publicUrl` | Unset | Advertised HTTP(S) application root; otherwise announce the bind-address URL |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) lists this runtime plugin's accepted fields and their JSDoc. The shipped composition inserts the `schedule` service row and the `ui-schedule` task page row, while the clock reading and the four reminder tools belong to the `standard`, `cordis`, and `ptc` presets. The `tool-subagent` and `tool-subagent-fork` rows in those presets deny the four tools, so a delegated child's scope never lists them.
### Listening, trust, and public deployments
-By default the GUI listens on loopback and accepts connections from this machine only; repeatable `--trusted-host` adds the authorities its Host/Origin fence accepts, so a remote browser reaches it behind a prefix-stripping proxy or through a port-forwarding client that presents a trusted hostname.
+By default the GUI listens on loopback and accepts connections from this machine only. Only `--host` changes the listener: it accepts one concrete local IPv4 or IPv6 address, never a wildcard. A non-loopback listener without TLS serves plain HTTP and warns at startup, even when an HTTPS `--public-url` fronts it. The Host/Origin fence accepts the bind IP itself; other non-loopback authorities require `--trusted-host`, so a remote browser reaches the GUI behind a prefix-stripping proxy or through a port-forwarding client that presents a trusted hostname. The launch-token exchange and signed session cookie authenticate every API method and WebSocket stream.
+
+Pass `--tls-cert` and `--tls-key` together to serve HTTPS from the listener instead of plain HTTP. Each names a file resolved against the process working directory: the certificate file holds the full chain and the key file an unencrypted PEM private key. The flags are one setting — supplying only one is a usage error, and supplying neither keeps the plain-HTTP default. Startup fails when a file is missing, unreadable, empty, or not a valid certificate-and-key pair; it never falls back to HTTP. Nothing re-reads the material while the listener runs, so serving a different certificate needs a listener reload or a process restart. DSH issues, renews, and watches nothing: there is no ACME client, no self-signed fallback, and no redirect listener. The browser applies its own trust store and host-name matching, so a certificate the browser does not trust, or one whose subject alternative names miss the authority you open, fails before the page loads. The default port stays 3080, and `--tls-cert`/`--tls-key` do not follow `--public-url`: the listener's certificate covers the authority browsers dial. TLS changes nothing about authority: Connection still authenticates every request and still checks Host/Origin, and the session cookie it mints is `Secure` whenever the receiving listener serves HTTPS.
+
+A container can bind its Pod address and advertise the ingress that fronts it:
+
+```sh
+dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
+```
+
+`hostname -i` may list several addresses; `--host` takes one, so the command selects the first. A zone ID such as `fe80::1%eth0` cannot appear in a URL, so the advertised root drops a redundant loopback zone while a non-loopback zone requires an explicit `--public-url`.
+
+Without `--public-url`, mapped IPv6 loopback binds such as `::ffff:127.0.0.1` advertise their IPv4 form, `127.0.0.1`, so the browser recognizes a local, trustworthy origin. The listener keeps the configured bind address. A dotted-quad IPv6 tail names the address's own low 32 bits, exactly as `listen` reads it, so `::0.0.0.1` is IPv6 loopback and advertises `[::1]`, while `::127.0.0.1` is a non-loopback address, not IPv4 loopback.
`--public-url` advertises the HTTP(S) root browsers use — the printed and opened startup URL, `DSH_WEB_URL`, and the web-surface orientation. Advertisement grants no trust: the browser-visible authority must also be named with `--trusted-host`. The flag configures no listener, routing, or cookie scope, because the proxy owns the external leg: [Publish the Web UI behind a reverse proxy](../../../docs/user/guide/public-deployments.md) lists what such a deployment must provide.
-The printed URL contains a process credential; share it only with intended users, and `printUrl: false` suppresses the line with or without `--public-url`.
+Neither the advertised URL nor the browser-trust fence protects the port itself, so restrict the port to the trusted proxy or network.
+
+The printed URL contains a process credential; share it only with intended users. `printUrl: false` suppresses the line with or without `--public-url`, and the credential never appears in the model context or in `DSH_WEB_URL`.
### Running over SSH
-When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's loopback endpoint, which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen.
+When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Without an advertised root the printed URL names the remote host's bind-address endpoint — loopback for a loopback bind — which you reach through your forwarding address. With `--public-url` the printed URL is the authenticated advertised root; the browser handoff stays suppressed, because opening a browser on the remote host cannot reach your screen.
### Per-session agent setup
@@ -83,11 +96,11 @@ Each browser session selects a shipped preset (`standard` by default). The Agent
Implementation internals — click to expand
-The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, trust sampling, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
+The bundle is one patch layer of five files plus one runtime glue plugin: `cordis.patch.yml` carries the host rows and the preset registry, and each `presets/.patch.yml` inserts one shipped preset declaration, applied in the order `dsh.bundle.patch` lists them. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, the advertised application URL, the plain-HTTP exposure warning, prompt sections, the bash variable, and the readiness announcements. The `office-to-pdf` row mounts one lazy [Office conversion provider](../../document/office-to-pdf/README.md) for Host consumers, including Desktop compositions using this bundle. The conversion service's Remote methods authorize preview reads, while Document Preview owns the Office viewer and Client cache.
### Patch semantics
-A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver` and `web-runtime` rows inject the `webStartup` provider and read their invocation values directly; the `connection` row instead reads the bind-dependent `webRuntime` values the web-runtime row publishes, which are that provider's authorities plus the LAN literals of an all-interfaces bind. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch.
+A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch.
### Advertised application URL
@@ -97,23 +110,18 @@ Startup display and browser handoff receive the advertised root with its launch
The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles, the required-startup audit passes, and Connection authentication is available — or immediately in a hand-built tree without a Loader. Client combo JavaScript and source maps remain unmaterialized at this point. Optional plugin failures do not suppress readiness; a required startup failure or a tree disposed mid-boot announces nothing.
-### LAN trust sampling
-
-`resolveLanTrust` samples the network once at boot: a loopback bind (`127.0.0.1`) derives no LAN addresses, while an all-interfaces bind adds every non-internal IPv4 literal. The derived literals plus the explicit `--trusted-host` authorities form the `/api` browser-trust fence, and the printed LAN URL always matches that fence.
-
### Source map
| File | Role |
|---|---|
-| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff |
+| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, advertised application URL, prompt sections, bash variable, URL line, browser handoff |
| [`src/public-url.ts`](src/public-url.ts) | Advertised-root validation and trailing-slash normalization; a leaf module for local imports, not package API |
-| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`, `--help` |
+| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--tls-cert`, `--tls-key`, `--public-url`, `--trusted-host`, `--no-open`, `--help` |
| [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, preset registry |
| [`presets/`](presets) | One `@deepseek-ai/dsh-agent-preset` declaration per shipped preset (`standard`, `ptc`, `minimal`, `cordis`), each its own patch file |
-| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist resolution, fallback seat, prompt sections, readiness, advertised URL publication |
+| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist index anchoring, advertised-URL publication and warnings, prompt sections, readiness publication |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree |
| [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | Advertised-root parsing and normalization |
-| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN-trust sampling |
| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | Default-browser handoff after the page is reachable |
@@ -158,12 +166,12 @@ Source and Web sections follow first-party reusable instructions. Different chec
These limits tell you what to expect in unusual setups — a source checkout, SSH sessions, or strict networks. They are current package constraints, not a general browser comparison or a task backlog.
- **The frontend must be built** — a source checkout needs `pnpm run build` first; startup stops with a build hint when the dist is missing, and there is no source-serving fallback.
-- **The listener has no TLS** — protect the external leg with a TLS-terminating proxy; an HTTP advertised root sends credentials without encryption.
-- **LAN addresses are sampled once at startup** — interface changes after boot are not re-advertised; the printed LAN URL always matches what was sampled.
+- **TLS is opt-in and unmanaged** — the listener serves HTTPS only when `--tls-cert` and `--tls-key` name a full chain and its unencrypted key; DSH never obtains, renews, or watches certificates, and a changed file needs a listener reload or a process restart. The browser must trust that certificate, its names must cover the authority you open, and the certificate is independent of `--public-url`.
+- **Without TLS the external leg is plain HTTP** — protect it with a TLS-terminating proxy, and do not send the launch URL over a network you do not trust.
- **Only the handoff start is observable** — the GUI reports that the browser was asked to open, not that it actually opened; a later browser exit is never reported, and the printed URL is your manual fallback.
-- **SSH sessions keep the URL but skip the browser handoff** — without an advertised root the printed URL names the remote host's loopback endpoint; the SSH client or editor must expose and open the local forwarded address.
+- **SSH sessions keep the URL but skip the browser handoff** — without an advertised root the printed URL names the remote host's bind-address endpoint (loopback for a loopback bind); the SSH client or editor must expose and open the local forwarded address.
- **`BROWSER` overrides only come from the environment** — a discovered `.env` cannot set `BROWSER`; only an inherited value can choose the executable for the automatic handoff.
-- **Binding all network interfaces is not supported** — `--host 0.0.0.0` is rejected at startup for safety; use the default loopback host.
+- **Binding all network interfaces is not supported** — a wildcard `--host`, including the IPv4-mapped forms Linux reads as IPv4 any, is rejected at startup for safety; bind one concrete IPv4 or IPv6 address of a local interface instead.
### Dev Note
diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md
index 3b7774bfd2..c74fd1ccc0 100644
--- a/packages/bundle/web-app/README.zh.md
+++ b/packages/bundle/web-app/README.zh.md
@@ -13,7 +13,7 @@ kind: "package-bundle"
## 概述
-运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。跨机访问支持在剥离前缀的代理之后公告一个公开的 HTTP(S) URL。一次性的命令行任务应使用 `dsh-headless`。
+运行 `dsh --profile web`,获得浏览器内的聊天、模型与设置管理以及会话历史,并与其他 dsh 表层共用同一套模型访问、工具与安全默认值。启动时会打印带 token 的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 需要手动打开。你可以更改端口、允许额外 authority、绑定一个具体的本机 IP,并用自己提供的证书直接提供 HTTPS;通配地址会被拒绝。跨机访问使用剥离前缀代理后公告的 HTTP(S) URL、监听器 TLS,或非 loopback 绑定上的明文 HTTP。一次性的命令行任务应使用 headless 配置。
## 目录
@@ -40,36 +40,49 @@ dsh --profile web --no-open --port 8080
启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到不含认证参数的同一目录。页面加载且你可以与 agent 对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。
-**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V41-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。
+**设置 → 模型**显示 **DeepSeek**,使用 `DEEPSEEK_API_KEY`。默认模型为 `deepseek-official` / `deepseek-flash`(DeepSeek-V4.1-Flash)。[DeepSeek 插件](../../llm/llm-deepseek/README.zh.md#endpoint-and-wire-format)使用 Messages API。
已保存的模型选择覆盖组合默认值。设置卡接受兼容 Messages 的 API 地址与凭据引用。
### 配置
-`--host` 与 `--port` 配置监听器;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。两者都在[监听、信任与公开部署](#public-deployments)中说明:
+`--host` 与 `--port` 配置监听器;`--tls-cert` 与 `--tls-key` 让它直接提供 HTTPS;`--public-url` 指定 GUI 在剥离前缀的代理之后对外公告的公开 HTTP(S) 根,`--trusted-host` 则添加更多被接受的 authority。这些都在[监听、信任与公开部署](#public-deployments)中说明,而这些 flag 设置的 `tls` 字段记录在[载体的配置](../../../docs/subsystems/web-server.zh.md#config)中:
| 字段 | 默认值 | 含义 |
|---|---|---|
| `openBrowser` | `true` | 启动后用默认浏览器打开;SSH 启动会抑制它 |
| `printUrl` | `true` | 启动时打印 `dsh web:` URL 行 |
| `surfaceContext` | `true` | 给 agent 提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 |
-| `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告监听器的 loopback URL |
-| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 |
+| `publicUrl` | 未设置 | 对外公告的 HTTP(S) 应用根;否则公告绑定地址 URL |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)列出了本运行时插件接受的字段及其 JSDoc。随发行版交付的组合插入 `schedule` 服务行与 `ui-schedule` 任务页面行,时钟读数与四个提醒工具则属于 `standard`、`cordis` 与 `ptc` 三个 preset。这三个 preset 的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,因此被委派子 agent 的作用域不会列出它们。
### 监听、信任与公开部署
-默认情况下 GUI 只在 loopback 上监听,只接受本机的连接;可重复的 `--trusted-host` 会添加其 Host/Origin 栅栏接受的 authority,因此远端浏览器要么经由剥离前缀的代理访问,要么通过以可信主机名呈现的端口转发客户端访问。
+默认情况下 GUI 只监听 loopback,只接受本机的连接。只有 `--host` 会改变监听器:它接受一个具体的本机 IPv4 或 IPv6 地址,不接受通配地址。未启用 TLS 的非 loopback 监听器提供明文 HTTP,并在启动时警告,即使前置 HTTPS 的 `--public-url` 也不例外。Host/Origin 栅栏接受绑定 IP 本身;其他非 loopback authority 需要 `--trusted-host`,因此远端浏览器要么经由剥离前缀的代理访问 GUI,要么通过以可信主机名呈现的端口转发客户端访问。启动 token 交换与签名会话 cookie 认证每个 API 方法与 WebSocket 流。
+
+同时传入 `--tls-cert` 与 `--tls-key`,监听器便以 HTTPS 而非明文 HTTP 提供服务。两者各指定一个相对进程工作目录解析的文件:证书文件包含完整证书链,密钥文件为不带口令的 PEM 私钥。它们属于同一个设置——只提供一个会报用法错误,两个都不提供则保持明文 HTTP 默认值。文件缺失、不可读、为空,或不是有效的证书与密钥对时启动失败;绝不回退到 HTTP。监听器运行期间不会重新读取这些材料,因此更换证书需要重新加载监听器或重启进程。DSH 不签发、不续期也不监视任何证书:没有 ACME 客户端、没有自签名兜底,也没有重定向监听器。浏览器使用自己的信任库与主机名匹配,因此浏览器不信任的证书,或其 subject alternative name 未覆盖你所访问 authority 的证书,会在页面加载前失败。默认端口仍为 3080,且 `--tls-cert`/`--tls-key` 不跟随 `--public-url`:监听器的证书覆盖浏览器实际访问的 authority。TLS 不改变授权模型:Connection 仍认证每个请求并仍校验 Host/Origin,且只要接收请求的监听器提供 HTTPS,它所签发的会话 cookie 就带 `Secure`。
+
+容器可以绑定其 Pod 地址,并公告前置的 ingress:
+
+```sh
+dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public-url https://app.example/ --trusted-host app.example
+```
+
+`hostname -i` 可能列出多个地址;`--host` 只接受一个,因此命令取第一个。`fe80::1%eth0` 这样的 zone ID 不能出现在 URL 中,因此公告根会剥离多余的 loopback zone,而非 loopback zone 则要求显式提供 `--public-url`。
+
+未设置 `--public-url` 时,`::ffff:127.0.0.1` 这样的 mapped IPv6 回环绑定会公告其 IPv4 形式 `127.0.0.1`,让浏览器将其识别为本地可信来源。监听器仍使用配置的绑定地址。点分四段式的 IPv6 尾部表示该地址自身的低 32 位,与 `listen` 的读法完全一致:`::0.0.0.1` 属于 IPv6 回环并公告 `[::1]`,而 `::127.0.0.1` 是非回环地址,并非 IPv4 回环。
`--public-url` 公告浏览器使用的 HTTP(S) 根——打印与打开的启动 URL、`DSH_WEB_URL` 与 web 表层定位。公告不授予任何信任:浏览器可见的 authority 还必须用 `--trusted-host` 点名。该 flag 不配置监听器、路由或 cookie 作用域,因为外部链路归代理所有:[在反向代理之后发布 Web UI](../../../docs/user/guide/public-deployments.zh.md)列出了这样的部署必须提供什么。
-打印的 URL 包含进程凭据,只应与预期用户分享;`printUrl: false` 无论是否配置 `--public-url` 都会抑制该行。
+无论是公告 URL 还是浏览器信任栅栏都不保护端口本身,因此请把端口限制在可信代理或网络内。
+
+打印的 URL 包含进程凭据,只应与预期用户分享。`printUrl: false` 无论是否配置 `--public-url` 都会抑制该行,凭据也绝不会出现在模型上下文或 `DSH_WEB_URL` 中。
### 通过 SSH 运行
-通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机 loopback 端点,你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。
+通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。没有公告根时,打印的 URL 指向远端宿主机的绑定地址端点——绑定 loopback 时即 loopback——你通过自己的转发地址访问它。配置了 `--public-url` 时,打印的 URL 是带认证的公告根;浏览器交接仍被抑制,因为在远端宿主机上打开浏览器无法到达你的屏幕。
### 按会话的 agent 设置
@@ -83,11 +96,11 @@ dsh --profile web --no-open --port 8080
实现细节——点击展开
-此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、信任采样、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
+此 bundle 由一层五个文件的补丁和一个运行时胶水插件组成:`cordis.patch.yml` 承载宿主行和 preset 注册表,每个 `presets/.patch.yml` 插入一条随发行版交付的 preset 声明,按 `dsh.bundle.patch` 列出的顺序应用。存储栈与投影缓存来自 `dsh-base`;Web 叠加层的工作区和消息反馈条目消费共享的 `storageDomain` 服务。补丁重述 base 有意省略的界面专用值,插入 Web 专用宿主条目和浏览器插件列表,再将 Agent 层移到预设后面。胶水插件负责 dist 服务、公告应用 URL、明文 HTTP 暴露警告、提示词段落、bash 变量和就绪通知。`office-to-pdf` 条目为宿主消费者挂载一个延迟创建引擎的 [Office 转换提供方](../../document/office-to-pdf/README.zh.md),使用此 bundle 的 Desktop 组合也共享该提供方。 转换服务的 Remote 方法负责预览读取授权,Document Preview 负责 Office 查看器和客户端缓存。
### patch 语义
-patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver` 与 `web-runtime` 行注入 `webStartup` 提供方并直接读取本次调用的取值;`connection` 行则改为读取 web-runtime 行发布的、与绑定相关的 `webRuntime` 值,即该提供方的 authority 加上全接口绑定的 LAN 字面量。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。
+patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。
### 公告应用 URL
@@ -97,23 +110,18 @@ patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己
URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算、通过 required 启动检查且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。此时 client combo JavaScript 和 source map 仍未物化。可选插件失败不会阻止就绪宣告;required 启动失败或启动中途被释放的树不会宣告任何内容。
-### LAN 信任采样
-
-`resolveLanTrust` 在启动时只采样一次网络:loopback 绑定(`127.0.0.1`)不派生任何 LAN 地址,绑定所有网卡则会加入每个非 internal IPv4 字面量。派生字面量加上显式的 `--trusted-host` 权威标识组成 `/api` 浏览器信任栅栏,打印的 LAN URL 始终与该栅栏一致。
-
### 源码地图
| 文件 | 职责 |
|---|---|
-| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、LAN 信任采样、提示词段落、bash 变量、URL 行、浏览器交接 |
+| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、公告应用 URL、提示词段落、bash 变量、URL 行、浏览器交接 |
| [`src/public-url.ts`](src/public-url.ts) | 公告根的校验与尾斜杠归一化;供本地导入的叶子模块,不属于包 API |
-| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--public-url`、`--trusted-host`、`--no-open`、`--help` |
+| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--tls-cert`、`--tls-key`、`--public-url`、`--trusted-host`、`--no-open`、`--help` |
| [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 注册表 |
| [`presets/`](presets) | 每个随发行版交付的 preset(`standard`、`ptc`、`minimal`、`cordis`)各一条 `@deepseek-ai/dsh-agent-preset` 声明,各自一个补丁文件 |
-| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist 解析、回退席位、提示词段落、就绪宣告、公告 URL 发布 |
+| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist index 锚定、公告 URL 发布与警告、提示词段落、就绪宣告发布 |
| [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 |
| [`tests/public-url.spec.ts`](tests/public-url.spec.ts) | 公告根的解析与归一化 |
-| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN 信任采样 |
| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | 页面可达后的默认浏览器交接 |
@@ -158,12 +166,12 @@ URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发
这些限制告诉你在不常见的环境下会遇到什么——源码 checkout、SSH 会话或严格网络。它们是当前包约束,不是通用的浏览器对比或任务积压。
- **前端必须已构建**——源码 checkout 需要先运行 `pnpm run build`;dist 缺失时启动会以构建提示停止,且没有从源码直接服务的回退路径。
-- **监听器不提供 TLS**——请用终止 TLS 的代理保护外部链路;HTTP 公告根会以明文发送凭据。
-- **LAN 地址只在启动时采样一次**——启动后的网卡变化不会重新公告;打印的 LAN URL 始终与采样结果一致。
+- **TLS 需自行启用且不代管**——只有传入 `--tls-cert` 与 `--tls-key`(完整证书链与不带口令的密钥)时监听器才提供 HTTPS;DSH 从不获取、续期或监视证书,更换文件需要重新加载监听器或重启进程。浏览器必须信任该证书,其名称必须覆盖你所访问的 authority,且证书与 `--public-url` 相互独立。
+- **未启用 TLS 时外部链路是明文 HTTP**——请用终止 TLS 的代理保护它,不要在不受信任的网络上发送启动 URL。
- **只能观察到交接的启动**——GUI 只报告浏览器被请求打开,而不是它确实打开了;之后的浏览器退出永远不会上报,打印的 URL 是你的手动回退路径。
-- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机 loopback 端点;SSH 客户端或编辑器必须暴露并打开本地转发地址。
+- **SSH 会话保留 URL 但跳过浏览器交接**——没有公告根时,打印的 URL 指向远端宿主机的绑定地址;SSH 客户端或编辑器必须暴露并打开本地转发地址。
- **`BROWSER` 覆盖只能来自环境**——被发现的 `.env` 不能设置 `BROWSER`;只有继承值能为自动交接选择可执行文件。
-- **不支持绑定所有网络接口**——出于安全考虑,`--host 0.0.0.0` 会在启动时被拒绝;请使用默认 loopback 主机。
+- **不支持绑定所有网络接口**——出于安全考虑,通配 `--host`(含 Linux 视为 IPv4 any 的 IPv4-mapped 形式)会在启动时被拒绝;请改为绑定本机某个接口的一个具体 IPv4 或 IPv6 地址。
### 开发备注
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index ee5201b9c7..987df7c3f7 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -7,8 +7,9 @@
#
# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an
# ordinary Cordis service. Rows configured from flags inject that service, so
-# Loader resolves their expressions only after it exists. The web runtime then
-# provides bind-dependent `webRuntime` values to the trust fence.
+# Loader resolves their expressions only after it exists. The web-runtime row
+# validates the advertised public URL; Connection reads invocation authorities
+# directly from `webStartup`.
# `dsh --profile web --help` provides no `webStartup`, so no server binds.
# ── surface-specific values the base deliberately omits ─────────────────────
@@ -173,13 +174,15 @@
# webStartup provider, with these deployment fallbacks. The dist is served
# by the web-runtime row below through the fallback seat. Routing and
# browser-facing asset paths do not depend on the advertised URL, which the
- # web-runtime row owns.
+ # web-runtime row owns; Connection reads trust authorities from webStartup.
+ # This row rejects wildcard binds.
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
inject: [webStartup]
config:
host: !!js ctx.webStartup.host ?? '127.0.0.1'
port: !!js ctx.webStartup.port ?? 3080
+ tls: !!js ctx.webStartup.tls
compression: gzip
compressionLevel: 1
compressionThresholdBytes: 1024
@@ -190,9 +193,8 @@
# section and the bash runtime variable, prints the URL line, and opens the
# canonical URL after the full Loader tree settles and the required-
# entry audit passes. Invocation-only values come from the webStartup
- # provider; the advertised application URL is this row's `publicUrl` (the
- # loopback URL when unset). After the server binds, this row samples LAN
- # trust once and provides `webRuntime`. A complete agent-preset persona
+ # provider; the advertised application URL is this row's `publicUrl`,
+ # otherwise the bind-address URL. A complete agent-preset persona
# suppresses the prompt section for that agent while retaining the
# host-owned shell variable.
- id: web-runtime
@@ -203,7 +205,6 @@
printUrl: true
publicUrl: !!js ctx.webStartup.publicUrl
surfaceContext: true
- trustedHosts: !!js ctx.webStartup.trustedHosts
# The client-plugin reload chain, always mounted: it is idle until a
# rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It
@@ -223,16 +224,16 @@
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client. Every API
- # request and upgrade passes the loopback plus trustedHosts policy; an
- # advertised public URL contributes nothing to it.
+ # request and upgrade passes the loopback, listener-bind, or trustedHosts
+ # policy; an advertised public URL contributes nothing to it.
- id: connection
name: '@deepseek-ai/dsh-client-connection'
- inject: [webRuntime]
+ inject: [webStartup]
config:
- # LAN literals derived from the active bind plus --trusted-host extras.
- # A deployment adding authorities keeps this expression and concatenates
- # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts].
- trustedHosts: !!js ctx.webRuntime.trustedHosts
+ # Keep invocation authorities (each --trusted-host value) when adding
+ # deployment values, for example: ['app.internal', ...ctx.webStartup.trustedHosts].
+ # The listener's bind IP is accepted separately from this list.
+ trustedHosts: !!js ctx.webStartup.trustedHosts
# Raw Blob and ReadableStream uploads run independently of Connection's
# RPC and generation service.
diff --git a/packages/bundle/web-app/presets/cordis.patch.yml b/packages/bundle/web-app/presets/cordis.patch.yml
index 277176f1a0..8c8d567ae3 100644
--- a/packages/bundle/web-app/presets/cordis.patch.yml
+++ b/packages/bundle/web-app/presets/cordis.patch.yml
@@ -51,7 +51,7 @@
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
- You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml
index 1aed4cb9e9..8e1ec1fb70 100644
--- a/packages/bundle/web-app/presets/ptc.patch.yml
+++ b/packages/bundle/web-app/presets/ptc.patch.yml
@@ -52,7 +52,7 @@
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
- You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
diff --git a/packages/bundle/web-app/presets/standard.patch.yml b/packages/bundle/web-app/presets/standard.patch.yml
index f2a9e080fe..6a42d0a351 100644
--- a/packages/bundle/web-app/presets/standard.patch.yml
+++ b/packages/bundle/web-app/presets/standard.patch.yml
@@ -52,7 +52,7 @@
name: '@deepseek-ai/dsh-plan-mode'
config:
section: |
- You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
+ You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts
index bf1aec78c1..28e078bd31 100644
--- a/packages/bundle/web-app/src/index.ts
+++ b/packages/bundle/web-app/src/index.ts
@@ -6,26 +6,26 @@
* config), mounts the `frontend-static` fallback owner over it, registers the
* harness-source and web-surface prompt sections, the bash-visible web runtime
* variable, the process-token URL line, and the default-browser handoff. An
- * advertised `publicUrl` replaces the published root — the loopback URL
- * otherwise. App command-line values arrive through the `webStartup` service
- * expressions in the bundle patch.
+ * advertised `publicUrl` replaces the published bind-address URL. Non-loopback
+ * HTTP binds trigger an exposure warning. App command-line values arrive
+ * through the `webStartup` service expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app
*/
import { spawn, type ChildProcess } from 'node:child_process'
import { createRequire } from 'node:module'
+import { isIP } from 'node:net'
import { dirname, join } from 'node:path'
-import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { addHarnessSourceSection, auditStartupEntries } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-client-connection'
import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static'
+import { isLoopbackHost, normalizeBindAddress } from '@deepseek-ai/dsh-host-webserver'
import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/cordis-plugin-loader'
-import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-shell-env'
import { parsePublicUrl } from './public-url.ts'
@@ -36,9 +36,6 @@ export const name = 'web-app'
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
const ANNOUNCED_ROOTS = new WeakSet()
-/** Runtime service that releases Web rows after bind-dependent values resolve. */
-const WEB_RUNTIME_SERVICE = 'webRuntime'
-
/** Services required before the web runtime can mount. */
export const inject = ['webServer']
@@ -58,13 +55,11 @@ export interface Config {
/**
* Canonical HTTP(S) root to advertise in the printed and opened URL,
* `DSH_WEB_URL`, and the web-surface orientation, e.g.
- * `https://app.example/ui/`, normalized to end in `/`. Advertisement only;
- * see [public deployments](../README.md#public-deployments). Absent or YAML
- * `null` advertises the loopback URL.
+ * `https://app.example/ui/`, normalized to end in `/`. Configures no routing
+ * or authentication; see [public deployments](../README.md#public-deployments).
+ * Absent or YAML `null` advertises the bind-address URL.
*/
publicUrl?: string
- /** Explicit `--trusted-host` authorities from this invocation. */
- trustedHosts: string[]
}
export const Config: z = z.object({
@@ -72,26 +67,11 @@ export const Config: z = z.object({
printUrl: z.boolean().default(true),
surfaceContext: z.boolean().default(true),
publicUrl: z.transform(z.string(), value => parsePublicUrl(value).href),
- trustedHosts: z.array(String).default([]),
})
-/** Bind-dependent Web values shared by the trust fence and URL display. */
-export interface WebRuntimeValues {
- /** LAN IPv4 literals sampled once when the server binds all interfaces. */
- lanAddresses: string[]
- /** LAN literals followed by explicit invocation authorities. */
- trustedHosts: string[]
-}
-
/** Environment variable naming the advertised URL of this Web GUI. */
const DSH_WEB_URL = 'DSH_WEB_URL' as const
-// Display-only mirror of the webserver schema's loopback host: the address the
-// local URL always prints. Not a source of truth — the schema is.
-const LOOPBACK_HOST = '127.0.0.1'
-/** The webserver schema's all-interfaces bind literal. */
-const ALL_INTERFACES_HOST = '0.0.0.0'
-
const BROWSER_OPENER_MODULE = import.meta.resolve('open')
const BROWSER_OPENER_PROGRAM = `
@@ -124,22 +104,48 @@ try {
`
/**
- * Resolve one LAN-trust snapshot from the active server bind.
- *
- * Derived entries are port-less IP literals: DNS rebinding needs an
- * attacker-controlled name, while an IP-literal Host is safe on any port and
- * an OS-assigned port is unknowable before bind.
- * @param bindHost - the active webserver bind host.
- * @param extra - explicit `--trusted-host` values, in argument order.
- * @returns the LAN display addresses and invocation-derived fence authorities.
+ * Local root URL of the active Web server, using the browser-compatible form
+ * of its bind address and brackets for IPv6 literals. Non-loopback binds such
+ * as a container's Pod address advertise that address.
+ * @throws when the runtime resolves without a bound webServer, or when the bind
+ * address has no URL form (see {@link advertisedBindHost}).
*/
-export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues {
- const lanAddresses = bindHost === ALL_INTERFACES_HOST
- ? Object.values(networkInterfaces()).flat()
- .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
- .map(iface => iface.address)
- : []
- return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
+function localWebUrl(ctx: Context): string {
+ const webServer = ctx.get('webServer')
+ const port = webServer?.port
+ if (webServer === undefined || port === undefined) {
+ throw new Error('web-app: webServer service missing while resolving Web runtime')
+ }
+ const host = advertisedBindHost(webServer.host)
+ return `${webServer.protocol}//${isIP(host) === 6 ? `[${host}]` : host}:${String(port)}`
+}
+
+/**
+ * Bind address in a URL. Loopback addresses use their canonical address text,
+ * so browsers treat them as local, trustworthy origins: a genuinely mapped
+ * literal such as `::ffff:127.0.0.1` reads as `127.0.0.1`, and a dotted-quad
+ * tail reads as the address it names, so `::0.0.0.1` reads as `::1` rather
+ * than as `0.0.0.1`. Interface zone IDs cannot appear in a URL; only a
+ * redundant loopback zone can be dropped.
+ * @param host - webserver bind address.
+ * @returns the zone-free URL host.
+ * @throws when a non-loopback zone requires an explicit public URL.
+ */
+function advertisedBindHost(host: string): string {
+ const zoneAt = host.indexOf('%')
+ const bare = zoneAt === -1 ? host : host.slice(0, zoneAt)
+ if (isLoopbackHost(bare)) return normalizeBindAddress(bare)
+ if (zoneAt === -1) return host
+ throw new Error(
+ `web-app: bind address ${JSON.stringify(host)} carries an interface zone id, which no URL can express;`
+ + ' pass --public-url with the root browsers actually reach it through',
+ )
+}
+
+/** Resolve the advertised root: this plugin's canonical `publicUrl`, or the bind-address URL (loopback when bound to loopback). */
+function appRootUrl(ctx: Context, publicUrl: string | undefined): string {
+ if (publicUrl !== undefined) return publicUrl
+ return localWebUrl(ctx)
}
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
@@ -156,18 +162,6 @@ function webSurfacePrompt(webUrl: string): string {
+ 'Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.'
}
-/** Resolve the canonical loopback URL from the active Web server. */
-function localWebUrl(ctx: Context): string {
- const port = ctx.get('webServer')?.port
- if (port === undefined) throw new Error('web-app: webServer service missing while resolving Web runtime')
- return `http://${LOOPBACK_HOST}:${String(port)}`
-}
-
-function appRootUrl(ctx: Context, publicUrl: string | undefined): string {
- if (publicUrl !== undefined) return publicUrl
- return localWebUrl(ctx)
-}
-
/**
* Dist location is workspace knowledge of this bundle: anchored on the
* frontend package manifest, not configured. Existence is a request-time
@@ -239,15 +233,17 @@ export const internals: {
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
- const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts)
// The schema validates a present string; an explicit YAML `null` bypasses
// the string transform and reaches here, meaning unset.
const publicUrl = config.publicUrl ?? undefined
- // The loopback URL belongs to this host. Under SSH, the operator reaches it
+ const boundHost = ctx.webServer.host
+ if (publicUrl === undefined) advertisedBindHost(boundHost)
+ if (ctx.webServer.protocol === 'http:' && !isLoopbackHost(boundHost)) {
+ console.warn(`dsh web: listening on ${boundHost} over plain HTTP; restrict this port to a trusted proxy or network`)
+ }
+ // The bind URL belongs to this host. Under SSH, the operator reaches it
// through a local forwarding address that this process cannot derive.
const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx))
- // Release dependent rows only after bind-dependent trust has been sampled once.
- ctx.provide(WEB_RUNTIME_SERVICE, runtime)
ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
if (config.surfaceContext) {
ctx.inject(['systemPrompt'], (promptCtx) => {
@@ -279,15 +275,9 @@ export function apply(ctx: Context, config: Config): void {
if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return
const webUrl = appRootUrl(connectionCtx, publicUrl)
const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl)
- // Reuse the exact LAN snapshot provided to the /api trust fence.
- const lanCandidate = runtime.lanAddresses[0]
- const port = connectionCtx.webServer.port
- const lanUrl = lanCandidate === undefined
- ? undefined
- : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`)
ANNOUNCED_ROOTS.add(connectionCtx.root)
if (config.printUrl) {
- console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`)
+ console.log(`dsh web: ${authenticatedUrl}`)
}
if (handoffBrowser) {
console.log('dsh web: opening the default browser; pass --no-open to disable')
diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts
index 749dd8ea5d..f24e4ea386 100644
--- a/packages/bundle/web-app/src/startup.ts
+++ b/packages/bundle/web-app/src/startup.ts
@@ -1,6 +1,6 @@
/**
* The web app's command-line provider: it parses the `dsh --profile web` flag
- * family (`--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`)
+ * family (`--host`, `--port`, `--public-url`, `--trusted-host`, `--no-open`, TLS)
* and its `--help` text, then provides the immutable values as
* {@link WEB_STARTUP_SERVICE}. Ordinary rows inject that service before
* reading it from lazy config.
@@ -10,6 +10,7 @@
import { Command } from 'commander'
import type { Context } from '@deepseek-ai/cordis'
import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
+import { isWildcardHost, type TlsConfig } from '@deepseek-ai/dsh-host-webserver'
import { parsePublicUrl } from './public-url.ts'
/** Stable Cordis plugin name. */
@@ -36,6 +37,8 @@ export interface WebStartupValues {
publicUrl?: string
/** Explicit `--trusted-host` authorities, in argument order. */
trustedHosts: string[]
+ /** Server certificate chain and unencrypted private key supplied together. */
+ tls?: TlsConfig
}
/** The web flag family, as commander parsed it. */
@@ -45,6 +48,8 @@ interface WebOptions {
port?: string
publicUrl?: string
trustedHost?: string[]
+ tlsCert?: string
+ tlsKey?: string
}
/**
@@ -56,38 +61,49 @@ function webCommand(): Command {
.name('dsh --profile web')
.description('Serve the DeepSeek Harness browser UI.')
.helpOption('-h, --help', 'show this help')
- .option('--host ', 'bind host')
+ .option('--host ', 'bind address: one concrete IPv4 or IPv6 literal of a local interface; wildcard addresses are rejected')
.option('--no-open', 'do not open the Web UI in the default browser')
.option('--port ', 'listen port; pass 0 to let the OS pick a free one')
.option('--public-url ', 'advertise this HTTP(S) root in the printed, opened, web-surface, and DSH_WEB_URL forms; grants no trust')
.option('--trusted-host