merge: update persistent PTC to the current release base

This commit is contained in:
creatixchu committed 2026-09-29 19:45:45 +08:00
commit fcecd8e37b
1202 files changed
+39302 -7700

No files matched your search

@@ -21,8 +21,8 @@
en: 774c3bf80a48ffbe
zh: c513245789aa8efd
/agent-note-right-sidebar-tab-types-and-navigation/decision/navigation-ctx-sidebarright:
en: b32bad4c3b8c2cc8
zh: 57ba7b8d3451e478
en: c52363f57001b7ac
zh: bbcbdbc038cbf316
/agent-note-right-sidebar-tab-types-and-navigation/decision/addresses:
en: eeb1e7e7478a2f2c
zh: bddbc5bc78245f63
@@ -30,8 +30,8 @@
en: 6386bde2654f9a0a
zh: 700439be6a01e162
/agent-note-right-sidebar-tab-types-and-navigation/alternatives-considered:
en: 91fbca006e8de5bc
zh: f48d3ce1dd7b63bf
en: c47eb4e2f3c307f9
zh: fd4515b8be88506e
/agent-note-right-sidebar-tab-types-and-navigation/consequences:
en: 3cdb1159ee55d851
zh: dcd068005e678a89
@@ -39,5 +39,5 @@
en: 49449a6c8465c098
zh: 1f476cc1226aa9cd
/agent-note-right-sidebar-tab-types-and-navigation/deferred:
en: fa27b7684e38329b
zh: d270e0cdcab73732
en: 5b2416692028bf89
zh: 65930102aa253bf2
@@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
`openResource` accepts the union of every declared resource shape and `openTab<K>` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone.
Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted.
Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only what it renders with: the room its docking kit measured and whether its frame width presents the panel fullscreen automatically. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted.
### Addresses
@@ -100,6 +100,8 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
**Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates.
**A binding the seat publishes from its own effects.** The first form had the mounted seat publish its session, actions, and surface after each commit and release them when it left. React runs a commit's passive effects in tree order, and the frame renders the Conversation column before the right column, so in a commit that switched Sessions or returned from a global panel a Conversation component opening content from its effect ran while no seat was bound — after the departing seat's release and before the arriving seat's bind — and the open threw. The Session selection and the main panel change before React renders, so deriving the on-screen Session from them removes that ordering dependency. The seat still measures pane room, because only its rendered kit can.
## Consequences
- A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package.
@@ -116,5 +118,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
## Deferred
- A navigation protocol beyond `sidebar://<kind>`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session.
- Opening into a session other than the one on screen from the public face, which acts on the on-screen Session only; a tab's own actions already act on their tab's session.
- A localized message when an open fails from the conversation; the failure is currently the thrown error's text.
@@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
`openResource` 接受所有已声明资源形状的联合,`openTab<K>` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。
除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。
除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报它渲染时依据的事实:停靠套件测得的空间,以及框架宽度是否让面板自动全屏。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。
### 地址
@@ -100,6 +100,8 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
**用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。
**由座位在自己的 effect 里发布绑定。** 最初的形式由已挂载的座位在每次提交后发布自己的会话、action 与面,离开时释放。React 按树序运行一次提交的被动 effect,而框架先渲染 Conversation 列、后渲染右栏,于是在切换会话或从全局面板返回的那次提交里,从自己 effect 打开内容的 Conversation 组件运行时没有任何座位已绑定——在离开的座位释放之后、到来的座位绑定之前——打开随之抛错。会话选择与主面板都在 React 渲染之前变化,从它们推导屏上会话便消除了这一顺序依赖。分栏空间仍由座位测量,因为只有它渲染出的套件能测。
## Consequences
- 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。
@@ -116,5 +118,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
## Deferred
- `sidebar://<kind>` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
- 从公开面往屏上会话之外的会话里打开;公开面只作用于屏上会话,而 tab 自己的动作已作用于其所在会话。
- 从会话区打开失败时的本地化提示;目前是抛错文本本身。
@@ -9,11 +9,11 @@
en: 67e580ac6d9298d9
zh: bb2bd7486090bde4
/agent-note-use-electron-as-the-desktop-node-runtime/decision:
en: 3cac5d877b18179d
zh: 0376bce7b099e0a6
en: 625ed4349cc70849
zh: b0aa30ab993e858d
/agent-note-use-electron-as-the-desktop-node-runtime/consequences:
en: b407d0ce648beea5
zh: 7ab329aa1d7a237c
en: 3de90a3734d0f405
zh: 4d26d953e05e0337
/agent-note-use-electron-as-the-desktop-node-runtime/alternatives-considered:
en: 880e255d56b45345
zh: 5952d9685c564284
en: 402978396810dca3
zh: 5146ec7ad3cabdc1
@@ -10,18 +10,18 @@ Shipping an upstream Node executable alongside Electron duplicates the JavaScrip
## Decision
Desktop runs the shared Web Host and bundled pnpm through its own Electron executable with `ELECTRON_RUN_AS_NODE=1`. It ships no separate upstream Node executable. The target Electron distribution supplies both the packaging input and the runtime used to prepare and verify production dependencies; release metadata records its actual Node version. Development uses the installed Electron distribution.
Desktop runs the shared Web Host and bundled pnpm through its own Electron executable with `ELECTRON_RUN_AS_NODE=1`. The [primary runtime](../feature/2026-09-14-desktop-primary-runtime.md) separately supplies Node for Office authoring. The target Electron distribution supplies both the packaging input and the runtime used to prepare and verify production dependencies; release metadata records its actual Node version. Development uses the installed Electron distribution. The [installed CLI](../feature/2026-09-27-desktop-cli-runtime.md) reuses this execution environment.
This supersedes the separate-Node choice in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Their independent plugin storage and ordinary resource-directory layout remain applicable. The [Web wrapper](2026-09-10-desktop-web-wrapper.md) retains the shared profile runner and HTTP transport.
This supersedes the separate-Node choice in the [packaging decision](2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md). Their independent plugin storage remains applicable. The [Web wrapper](2026-09-10-desktop-web-wrapper.md) retains the shared profile runner and HTTP transport.
## Consequences
Host and pnpm launches pass `--expose-internals`: the bundled Cordis loader uses Node's internal ESM loader, while its native builtin accessor cannot locate the required symbol in Electron 44. The explicit flag makes that loader available without modifying Cordis. The RunAsNode fuse remains enabled.
The Host inherits the caller PATH without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
The Host inherits Desktop’s PATH, merged with the [login-shell environment](../feature/2026-09-28-desktop-login-shell-environment.md) on macOS and Linux, without Desktop’s private `bin` directory, so PTC and agent shells cannot resolve internal launchers through that directory. `DSH_DESKTOP_NODE_EXECUTABLE` is injected only for package installation. Package-script environments prepend a small `node` shell launcher that forwards arguments to the current Electron executable. This supports shell lifecycle scripts without a system Node installation. On Windows it is `node.cmd`, not a replacement `node.exe`; third-party code that directly spawns the literal `node` executable without a shell must use `process.execPath` or provide its own runtime. Child processes inherit RunAsNode; worker threads inherit the Host's arguments. Desktop does not emulate upstream OpenSSL behavior or rebuild arbitrary third-party native addons automatically.
Electron's Node patches and native ABI are release compatibility obligations. The packaged native smoke exercises pnpm shell scripts without system Node on PATH, terminal output through the Windows shell, Koffi, Sharp, and HTML conversion. The Host smoke loads an external plugin sharing Cordis and serves its route through the real Web application. Platform signing and installed-application qualification remain required; Windows results do not establish macOS compatibility. Windows token signing runs serially and retains the first failure, preventing queued tasks from repeating a rejected PIN.
## Alternatives considered
A separate Node executable decouples the Host from Electron's runtime but adds another binary, download, signature, and version selection. Electron RunAsNode removes that duplication. Moving production packages into ASAR is a separate change involving native modules, package resolution, and subprocess paths; the Host continues to load ordinary resource files.
A separate Node executable decouples the Host from Electron's runtime but adds another binary, download, signature, and version selection for that role. Electron RunAsNode reuses the GUI executable. Production packages reside in ASAR with native resources unpacked; packaging and physical-resource resolution remain separate from the execution-engine choice.
@@ -10,18 +10,18 @@ Status: implemented
## 决策
Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置 pnpm,并设置 `ELECTRON_RUN_AS_NODE=1`。应用不携带独立的上游 Node 可执行文件。目标 Electron 分发包同时作为打包输入,以及准备和验证生产依赖的运行时;发布元数据记录其实际 Node 版本。开发模式使用已安装的 Electron 分发包。
Desktop 通过自己的 Electron 可执行文件运行共享 Web Host 和内置 pnpm,并设置 `ELECTRON_RUN_AS_NODE=1`。[主运行时](../feature/2026-09-14-desktop-primary-runtime.zh.md)另行提供 Office 创作使用的 Node。目标 Electron 分发包同时作为打包输入,以及准备和验证生产依赖的运行时;发布元数据记录其实际 Node 版本。开发模式使用已安装的 Electron 分发包。[安装包中的 CLI](../feature/2026-09-27-desktop-cli-runtime.zh.md)复用此执行环境。
此决策替代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的独立 Node 选择。独立插件存储和普通资源目录布局仍然适用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)保留共享 profile runner 和 HTTP 传输。
此决策替代[打包决策](2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)中的独立 Node 选择。独立插件存储仍然适用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)保留共享 profile runner 和 HTTP 传输。
## 后果
Host 和 pnpm 启动时传入 `--expose-internals`:内置 Cordis 加载器使用 Node 内部 ESM 加载器,而其原生 builtin 访问器无法在 Electron 44 中找到所需符号。显式参数使加载器可用,无需修改 Cordis。RunAsNode fuse 保持启用。
Host 继承调用者的 PATH,不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
Host 继承 Desktop 的 PATH(macOS 和 Linux 上合并了[登录 shell 环境](../feature/2026-09-28-desktop-login-shell-environment.zh.md)),不加入 Desktop 私有的 `bin` 目录,因此 PTC 和 agent shell 不会通过该目录解析内部启动器。`DSH_DESKTOP_NODE_EXECUTABLE` 仅为包安装注入。包脚本环境在 PATH 前添加一个小型 `node` shell 启动器,把参数转发给当前 Electron 可执行文件。这支持没有系统 Node 的 shell 生命周期脚本。Windows 上它是 `node.cmd`,并非替代的 `node.exe`;第三方代码若绕过 shell 直接启动名为 `node` 的可执行文件,必须使用 `process.execPath` 或提供自己的运行时。子进程继承 RunAsNode;worker 线程继承 Host 参数。Desktop 不模拟上游 OpenSSL 行为,也不自动重编译任意第三方原生扩展。
Electron 的 Node 补丁和原生 ABI 属于发布兼容性责任。打包原生 smoke 在 PATH 不含系统 Node 的情况下验证 pnpm shell 脚本,并验证 Windows shell 终端输出、Koffi、Sharp 和 HTML 转换。Host smoke 加载共享 Cordis 的外部插件,通过真实 Web 应用提供其路由。各平台仍需完成签名和已安装应用验收;Windows 结果不能证明 macOS 兼容性。Windows Token 签名串行执行并保留首次失败,阻止排队任务重复提交被拒绝的 PIN。
## 考虑过的替代方案
独立 Node 可执行文件可以让 Host 与 Electron 运行时分离,但会增加另一份二进制文件、下载、签名和版本选择。Electron RunAsNode 消除这一重复。将生产包移入 ASAR 是另一项涉及原生模块、包解析和子进程路径的改动;Host 继续加载普通资源文件。
独立 Node 可执行文件可以让 Host 与 Electron 运行时分离,但会为该职责增加另一份二进制文件、下载、签名和版本选择。Electron RunAsNode 复用 GUI 可执行文件。生产包位于 ASAR 中,原生资源解包存放;打包与物理资源解析仍独立于执行引擎选择。
@@ -9,8 +9,8 @@
en: f9721b730c7e70c7
zh: 367e7839912cb726
/agent-note-default-deepseek-session-log-upload/decision:
en: ec3b05fb70ae490f
zh: 59b0691e327855b4
en: b72ef475a41b1a23
zh: b17a68367679730b
/agent-note-default-deepseek-session-log-upload/alternatives-considered:
en: 4cc4b61d76f84ff3
zh: bdd29ea66037f744
@@ -9,7 +9,9 @@ Ordinary DeepSeek requests do not contain the complete canonical Session traject
## Decision
`session-log-deepseek.Config.enabled` defaults to `true` in every process. An explicit `enabled: false` disables the contribution. The plugin does not inspect test-runner or snapshot environment variables.
`session-log-deepseek.Config.enabled` defaults to `true` in every process. A resolved `enabled: false` disables the contribution; configuration precedence determines which value takes effect. The plugin does not inspect test-runner or snapshot environment variables.
The `enabled` value is read for each request through a volatile reference so a saved preference takes effect without restarting the plugin. Profile writes can override bundle defaults under the [profile-owned configuration rules](2026-09-19-profile-owned-live-configuration.md); home patches and command-line overlays still reject conflicting writes. Disabling upload leaves the acceptance watermark unchanged because it records provider acceptance, not user eligibility. Re-enabling therefore sends the unaccepted suffix, including events recorded while disabled. Already prepared requests retain their payload.
This supersedes only the opt-in default in the [request-extension decision](2026-08-21-deepseek-llm-api-request-extensions.md); that note still owns field serialization, destinations, acceptance, and retry semantics. The headless and ACP corpus base patches and Web scaffold explicitly disable upload. Later scenario patches can enable it. The SDK text-turn recording omits the setting and exercises the shipped default, including durable acceptance events.
@@ -9,7 +9,9 @@ Status: implemented
## 决策
`session-log-deepseek.Config.enabled` 在所有进程中均默认为 `true`。显式设置 `enabled: false` 可关闭贡献。插件不读取测试运行器或快照环境变量。
`session-log-deepseek.Config.enabled` 在所有进程中均默认为 `true`。最终生效的 `enabled: false` 会关闭贡献;配置优先级决定采用哪个值。插件不读取测试运行器或快照环境变量。
每次请求通过 volatile 引用读取 `enabled`,因此保存的偏好无需重启插件即可生效。按照 [profile 所有的配置规则](2026-09-19-profile-owned-live-configuration.zh.md),profile 写入可覆盖组合包默认值;home patch 和命令行 overlay 仍会拒绝冲突写入。关闭上传不改变接受水位,因为它记录提供方的接受结果,而不是用户是否允许上传。重新开启后会发送尚未接受的后缀,包括关闭期间记录的事件。已准备的请求保留原有载荷。
本决策仅取代[请求扩展决策](2026-08-21-deepseek-llm-api-request-extensions.zh.md)中的主动启用默认策略;该记录仍负责字段序列化、目的地址、接受与重试语义。headless 和 ACP 语料的基础 patch 以及 Web scaffold 显式关闭上传。后续场景 patch 可以启用上传。SDK text-turn 录制省略该设置,验证产品默认值及持久接受事件。
@@ -9,8 +9,8 @@
en: 2c774d05ae75784f
zh: 619ba7f281b13233
/agent-note-host-owned-scheduled-messages/decision:
en: 4a38d9bd933241b0
zh: b246c74f68e92a25
en: de81925284f13c12
zh: d0ef35c8b58813f2
/agent-note-host-owned-scheduled-messages/alternatives-considered:
en: 21d7ec9a9ff3cbf2
zh: 93d03b8b3012aa7e
@@ -20,7 +20,7 @@ A plugin-sourced `followup()` synchronously appends the message to the Session i
New tasks initialize an empty `deliveryHistory`. A task without that optional field exposes its existing latest receipt, if any, and marks earlier records unavailable. Reading never rewrites the old task or invents the lost history or instruction snapshot. Its next acknowledged delivery preserves that available receipt and appends the new one. Strict decoding rejects duplicate message ids and disagreement between the latest receipt and the last history entry. History follows append order, not wall-clock sorting, so clock rollback does not reverse acknowledged deliveries.
A recurring task contributes its latest due occurrence once, including after Host restart. Every remains creation-aligned until an interval edit establishes a new anchor; Daily retains a local time and explicit IANA zone, and Weekly adds a non-empty ISO weekday set stored as unique ascending numbers. Every, Daily, Weekly, and Cron use `renderRecurringReminderBatchFraming` and share one follow-up message and its real message id when due for the same Session. A recurring task with no representable next target remains inactive with its last receipt. A stopped Host does not send messages.
A recurring task contributes its latest due occurrence once, including after Host restart. Every remains creation-aligned until an interval edit establishes a new anchor; Daily retains a local time and explicit IANA zone, and Weekly adds a non-empty ISO weekday set stored as unique ascending numbers. Every, Daily, Weekly, and Cron use `renderRecurringReminderBatchFraming` and share one follow-up message and its real message id when due for the same Session. The fixed text both renderers emit is the [delivered-reminder origin line](2026-09-24-scheduled-message-framing.md) decision. A recurring task with no representable next target remains inactive with its last receipt. A stopped Host does not send messages.
Daily is a fourth mutually exclusive creation selector, Weekly a fifth, and Cron a sixth, none a conversion of `every_seconds` or `at`. Weekly selects every local date whose ISO weekday belongs to its stored set; the decoder rejects an unnormalized stored set, and the selector rejects an empty, repeated, out-of-range, or non-integer one. Their first targets are strictly future. Missing local times and dates are skipped; overlaps select the earlier instant once per date. The next target is after the delivery decision and on a local date later than the selected occurrence's date. Only UTC targets are restricted to years 0001–9999; local dates may cross into year 0 or 10000 at the edges. After Session restoration, the runtime rechecks due members against the current clock so a rollback does not admit future tasks.
@@ -20,7 +20,7 @@ Status: implemented
新任务初始化空的 `deliveryHistory`。缺少该可选字段的任务会展示已有的最近一次回执(如果存在),并标明更早记录不可用。读取不会重写旧任务,也不会虚构已丢失的历史或指令快照。下一次确认投递时,保留已有回执并追加新记录。严格解码拒绝重复消息 id,以及最近一次回执与历史末项不一致的情况。历史按追加顺序排列,不按墙钟排序,因此时钟回拨不会颠倒已确认投递的顺序。
重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。
重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。两个渲染器输出的固定文本见[投递提醒的来源行](2026-09-24-scheduled-message-framing.zh.md)决策。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。
Daily 是互斥的第四个创建选择器,Weekly 是第五个,Cron 是第六个,三者都不是对 `every_seconds` 或 `at` 的转换。Weekly 选择本地日期 ISO 星期属于其所存集合的每个日期;解码器拒绝未规范化的已存集合,选择器拒绝空、重复、超出范围或非整数的集合。它们的首个目标严格晚于当前时间。缺失的本地时间或日期被跳过;重叠时间选择较早时点,每个日期一次。下一目标晚于投递判断时刻,且其本地日期晚于所选发生时点的日期。仅 UTC 目标限制在 0001–9999 年;边缘处的本地日期可以进入第 0 年或第 10000 年。Session 恢复后,运行时依据当前时钟重新核对到期成员,避免时钟回拨时接纳未来任务。
@@ -0,0 +1,43 @@
# Bilingual-pair consistency record for 2026-09-19-timed-user-question-two-settlements.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-timed-user-question-two-settlements.md
/agent-note-timed-user-questions-as-two-settlements:
en: 5b6fa73158a11213
zh: b55c32342d6e6692
/agent-note-timed-user-questions-as-two-settlements/problem:
en: f665127a9b5b8225
zh: b19845f62310f3a2
/agent-note-timed-user-questions-as-two-settlements/decision:
en: ce869bf2eed04e81
zh: 5b1fcf44c44c6137
/agent-note-timed-user-questions-as-two-settlements/decision/modes-and-scope:
en: adccc5dc758ad8f6
zh: 50304d68db42e8b1
/agent-note-timed-user-questions-as-two-settlements/decision/ownership:
en: 0543678ad9d26fc5
zh: aa6ba207e661c567
/agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime:
en: 447713bbdf1ab4d8
zh: 2757678ae47822aa
/agent-note-timed-user-questions-as-two-settlements/decision/durable-state-and-late-answers:
en: 93f3def953d10a2e
zh: e1106340e0043254
/agent-note-timed-user-questions-as-two-settlements/decision/card-state-and-delivery-races:
en: 3dd7321bd37c51bf
zh: 2d1f058732bd6d0f
/agent-note-timed-user-questions-as-two-settlements/decision/local-countdown-and-panel-actions:
en: 3a33f2583e30e32a
zh: 0ad7e8d1e47dc510
/agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping:
en: 10323b0e0340a40b
zh: a17fc3a4d4d820a7
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
en: bf61d0ec80c63bb4
zh: d4ce4d4438ffb013
/agent-note-timed-user-questions-as-two-settlements/verification:
en: d912a73a5ff409eb
zh: 7b626794773e2301
/agent-note-timed-user-questions-as-two-settlements/consequences:
en: 2bc99c99be8ca9da
zh: 8f262ec8b9b0945e
@@ -1,6 +1,6 @@
# Agent Note: Timed user questions as two settlements
Status: proposed
Status: implemented
English | [中文](2026-09-19-timed-user-question-two-settlements.zh.md)
@@ -10,14 +10,14 @@ A question can block an agent even when useful work does not depend on the answe
The foreground tool result and the eventual answer have different lifetimes. A `user-questions/request` waterfall settles once; keeping it alive after returning pending creates a second answer path that outlives its caller. Countdown, focus, and editing also have different owners from durable question state.
## Proposal
## Decision
Keep foreground answers on the existing Remote Event waterfall. A timeout ends that request and returns pending; a later answer uses a business RPC that steers a durable user message. The claim stream controls the foreground wait only. It neither transports answers nor replaces the Remote Event.
Foreground answers use the existing Remote Event waterfall. A timeout ends that request and returns pending; a later answer uses a business RPC that steers a durable user message. The claim stream controls the foreground wait only. It neither transports answers nor replaces the Remote Event.
### Modes and scope
- The default `mode: legacy` retains the blocking schema and `ask()` path. Explicit `mode: timed` adds `timeout`, using the configured default of 120 seconds when omitted.
- A positive integer timeout selects `askTimed()`; `-1` selects blocking `ask()`. A blocking call made with the timed schema still participates in timed-question projection and review; it is not identical to a legacy call.
- A positive integer timeout selects `askTimed()`; `-1` selects blocking `ask()`. A native blocking call made with the timed schema still participates in timed-question projection and review; it is not identical to a legacy call.
- Plan review retains `ask()`, its `plan-review` intent, and `BAD_INTENT` validation. This decision does not move plan review to another package.
- Human interaction requires the exact live runtime root when an Agent is supplied. A stale instance fails with `CALLER_NOT_LIVE`; an owned child fails with `DELEGATED_CALLER`. Durable Session lineage does not prevent a resumed runtime root from asking.
@@ -46,27 +46,27 @@ Client countdown expiry rejects the waterfall with `ASK_TIMED_OUT`. Unattended H
### Durable state and late answers
The `userQuestions` projection reconstructs question state from existing Session events. It recognises the timed schema by the `timeout` parameter recorded in `request/header`; legacy calls are not tracked. A valid `tool/call` opens a question. Its `tool/result` makes it continued for pending or `TOOL_OUTCOME_UNKNOWN`, settles it with a valid answer batch, or removes it for another result or failure.
The `userQuestions` projection reconstructs question state from existing Session events. For native calls, it recognises the timed schema by the `timeout` parameter recorded in `request/header`; legacy calls are not tracked. A valid `tool/call` opens a question. Its `tool/result` makes it continued for pending or `TOOL_OUTCOME_UNKNOWN`, settles it with a valid answer batch, or removes it for another result or failure. A PTC sub-call enters the projection when its `tool/ptc-dispatch` result is pending, since only `run_code` appears in the model request header.
A continued question accepts `answer(agent, callId, answer)`. The method returns `false` for an unknown or non-continued call and throws `BAD_ANSWER` unless the batch names each question exactly once. Accepted answers use `agent.steer(createUserMessage(...))`, source `{ kind: 'user-question-reply', callId, outcome: 'answered' }`, and JSON text containing `answer_to_pending_question`, the call id, original questions, and answers. Remote Agent resolution from the Session id resumes a root when needed; the `answer()` body does not own that resume operation.
A continued question accepts `answer(agent, callId, answer)`. The method returns `false` for an unknown or non-continued call, throws `REPLY_QUEUED` while another reply awaits admission, and throws `BAD_ANSWER` unless the batch names each question exactly once. Accepted answers use `agent.steer(createUserMessage(...))`, source `{ kind: 'user-question-reply', callId, outcome: 'answered' }`, and JSON text containing `answer_to_pending_question`, the call id, original questions, and answers. Remote Agent resolution from the Session id resumes a root when needed; the `answer()` body does not own that resume operation.
The inbox message is the durable reply. `agent/inbox/spliced` settles the question when the message is queued; the later admitted `user/message` is idempotent for that settlement. Settled answer batches remain in the projection so the original tool row can show late answers even though its own result remains pending. Unusable recorded reply text settles with an empty batch.
The inbox message is the durable queued reply. A per-Session reservation covers the interval from queuing through claim and admission; claiming a restored inbox reply creates the reservation again. Admission as `user/message` settles the question, while discard or a Turn that ends without admission releases the reservation for another answer. Settled answer batches remain in the projection so the original tool row can show late answers even though its own result remains pending. Unusable recorded reply text settles with an empty batch.
No new Session event type or wait-state log is needed. The message-source extension requires the repository's persistence-type acknowledgement. `dismissed` remains readable for historical records but has no producer: hiding a panel does not manufacture a reply.
No new Session event type or wait-state log is needed. The message-source extension requires the repository's persistence-type acknowledgement. Hiding a panel does not manufacture a reply.
### Card state and delivery races
`QuestionCards` shares one `PendingQuestion` between the live request and the continued projection, keyed by Session and `callId`. A blocking request without a call id has its own per-request card. An open card submits through its waterfall; without that channel it disables submission. A continued card uses the late-answer RPC.
`QuestionCards` shares one `PendingQuestion` between the live request and the continued projection, keyed by Session and `callId`. A forwarded request keeps its card through the claim's first frame even before the projection lists it. A blocking request without a call id has its own per-request card with a reload-unique key, so an old persisted draft cannot attach to another request. An open card submits through its waterfall; without that channel it disables submission. A continued card uses the late-answer RPC.
A waterfall submission has no delivery acknowledgement. The Gateway can discard an outcome that loses a settlement race. The card therefore retains its draft and stays busy until projection reconciliation. If the question becomes continued during submission, the controls re-arm with a resubmit hint; the user can submit the retained draft through RPC. This is not automatic retry or a guarantee that every local submission reaches the model.
A waterfall submission has no delivery acknowledgement. The Gateway can discard an outcome that loses a settlement race. The card therefore retains its draft and stays busy until projection reconciliation. If the question becomes continued during submission, the controls re-arm with a resubmit hint; the user can submit the retained draft through RPC. An accepted RPC submission steers a reply toward the nearest step without settling the question, and the panel closes. A current tool wait must finish before that step can read the reply; if the steer is discarded, the question can be reopened and answered again. This is not automatic retry or a guarantee that every local submission reaches the model.
A tool-call-keyed card is removed when its call is absent from the active projection and no waterfall remains. Removal closes the card and clears its draft. Neither a local submission nor a transport cancel frame alone decides that the question is answered. An unkeyed blocking card ends with its waterfall; closing that blocking panel rejects with `ASK_CANCELLED`.
A tool-call-keyed card is removed when its call is absent from the active projection and no forwarded request remains. Removal closes the card and clears its draft. Neither a local submission nor a transport cancel frame alone decides that the question is answered. An unkeyed blocking card ends with its waterfall; closing that blocking panel rejects with `ASK_CANCELLED`.
### Local countdown and panel actions
The Client computes `Date.now() + remainingMs`. Manual focus on a pristine answer surface pauses its countdown, and blur resumes the preserved remainder. Focus before the claim handshake remains effective when the deadline arrives; blur before the handshake does not create a timer. First edit or Take time freezes the countdown, with that disposition saved beside the browser-local draft.
Autofocus applies only when the answer channel is ready, no countdown is active, and the card is not locked. Hiding a timed-call panel leaves its claim and countdown running; its tool row can reopen it. A settled call opens a read-only panel from recorded questions and answers, not another answer channel.
Autofocus applies only when the answer channel is ready, no countdown is active, and the card is not locked. Hiding a timed-call panel leaves its claim and countdown running; its tool row can reopen it. Once a late reply enters the Inbox, the tool row displays its submitted answers and “View answers” expands the read-only transcript while admission is pending. A settled call opens a read-only panel with recorded answers. Discarding the queued reply restores the answer action.
### Reply node and grouping
@@ -86,7 +86,7 @@ This decision adds no process-role field, independent-layout flag, user-message
**Delegate every timed request immediately.** Dispatching to an intentionally empty answerer set wastes the existing blocking-answer path and requires special handling of `NO_PROVIDER`. Delegation is for an unavailable answerer, not the timed mode itself.
**Persist a whole-value wait/focus/edit stream or use Host focus leases.** Durable question status is derivable from tool and inbox events. Focus and editing are local UI state; logging them or synchronising focus leases adds Host state without making multiple Clients share one editing session.
**Persist a whole-value wait/focus/edit stream or use Host focus leases.** Durable question status is derivable from tool and admitted user events. Focus and editing are local UI state; logging them or synchronising focus leases adds Host state without making multiple Clients share one editing session.
**Reclassify JSON inside generic `MessageItem`, borrow the relay form, or use the default context row.** Parsing text to identify the business message confuses its source with its content; the relay form describes another agent's message, and a generic context row exposes JSON. A dedicated source, Definition, and renderer own the question presentation.
@@ -94,20 +94,24 @@ This decision adds no process-role field, independent-layout flag, user-message
**A log-only close event or a manufactured dismissal message on panel close.** A log-only close hides the user's decision from the model and adds another durable mechanism. Manufacturing a reply treats hiding the panel as an answer. Panel close therefore leaves a timed question answerable.
**A dedicated late-reply Session event beside the inbox message.** The inbox splice and admitted user message already record the reply. A third event duplicates the same fact and adds another persistence type.
**A dedicated late-reply Session event beside the inbox message.** The inbox splice records the queued reply and the admitted user message records its delivery. A third event duplicates those facts and adds another persistence type.
## Acceptance criteria
## Verification
- Service tests cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
- Projection tests distinguish legacy and timed schemas and cover pending, answered, other failures, resume repair, and duplicate inbox/message settlement.
- Client tests cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel hiding/reopening.
- Grouping and browser replay cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
- Legacy question round trips, cancellation, plan-review intent, and Session remounts retain their existing behavior. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
- [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
- [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover native and PTC pending calls, other failures, resume repair, queued reply cancellation, and admitted settlement.
- [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions.
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
- Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
## Risks
## Consequences
The model can continue independent work while the question remains answerable. Business-owned timing and reply rendering reuse the existing transport and conversation APIs, at the cost of local rather than globally coordinated waiting and ordinary folded visibility.
- Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold.
- A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure.
- Host and Client clocks need not align, but Client system-clock changes can move expiry and transport latency can delay the local countdown's start.
- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim.
- Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee.
- A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer.
- A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery.
@@ -1,6 +1,6 @@
# Agent Note: 计时用户提问的两次结算
Status: proposed
Status: implemented
[English](2026-09-19-timed-user-question-two-settlements.md) | 中文
@@ -10,14 +10,14 @@ Status: proposed
前台工具结果与最终回答的生命周期不同。`user-questions/request` waterfall(瀑布式事件)只结算一次;返回 pending 后仍保留它,会产生一条活过调用方的第二回答路径。倒计时、聚焦和编辑的归属也不同于持久问题状态。
## Proposal
## Decision
前台回答继续使用现有 Remote Event waterfall。超时结束该请求并返回 pending;迟到回答通过业务 RPC steer 一条持久用户消息。接手 stream 只控制前台等待,既不传递回答,也不替代 Remote Event。
### 模式与范围
- 默认 `mode: legacy` 保留阻塞式 schema 和 `ask()` 路径。显式 `mode: timed` 增加 `timeout`,省略时使用配置默认值 120 秒。
- 正整数超时选择 `askTimed()`;`-1` 选择阻塞式 `ask()`。timed schema 发出的阻塞调用仍参与计时问题投影和答案查看,并不等同于 legacy 调用。
- 正整数超时选择 `askTimed()`;`-1` 选择阻塞式 `ask()`。timed schema 发出的原生阻塞调用仍参与计时问题投影和答案查看,并不等同于 legacy 调用。
- 计划评审保留 `ask()`、`plan-review` 意图及 `BAD_INTENT` 校验。本决策不把计划评审迁移到其他包。
- 提供 Agent 时,人机交互要求它是运行时中确切的存活根实例。陈旧实例以 `CALLER_NOT_LIVE` 失败;被其他 agent 拥有的子实例以 `DELEGATED_CALLER` 失败。持久 Session 的血缘关系不妨碍恢复后的运行时根实例提问。
@@ -46,27 +46,27 @@ Client 倒计时到期以 `ASK_TIMED_OUT` 拒绝 waterfall。无人接手时,H
### 持久状态与迟到回答
`userQuestions` 投影从现有 Session 事件重建问题状态。它通过 `request/header` 记录的 `timeout` 参数识别 timed schema,不跟踪 legacy 调用。有效的 `tool/call` 打开问题。其 `tool/result` 在 pending 或 `TOOL_OUTCOME_UNKNOWN` 时将问题标为已继续,在有效回答批次时结算并保存答案,其他结果或失败则移除问题。
`userQuestions` 投影从现有 Session 事件重建问题状态。对原生调用,它通过 `request/header` 记录的 `timeout` 参数识别 timed schema,不跟踪 legacy 调用。有效的 `tool/call` 打开问题。其 `tool/result` 在 pending 或 `TOOL_OUTCOME_UNKNOWN` 时将问题标为已继续,在有效回答批次时结算并保存答案,其他结果或失败则移除问题。PTC 子调用在 `tool/ptc-dispatch` 结果为 pending 时进入投影,因为模型请求头只列出 `run_code`。
已继续的问题接受 `answer(agent, callId, answer)`。未知或非已继续调用返回 `false`;回答批次没有恰好包含每个问题一次时抛出 `BAD_ANSWER`。接受的回答通过 `agent.steer(createUserMessage(...))` 送入 agent,来源为 `{ kind: 'user-question-reply', callId, outcome: 'answered' }`,JSON 文本包含 `answer_to_pending_question`、调用 id、原问题及答案。Remote 根据 Session id 解析 Agent 时按需恢复根实例;恢复操作不属于 `answer()` 方法体。
已继续的问题接受 `answer(agent, callId, answer)`。未知或非已继续调用返回 `false`;另一条回复等待准入时抛出 `REPLY_QUEUED`;回答批次没有恰好包含每个问题一次时抛出 `BAD_ANSWER`。接受的回答通过 `agent.steer(createUserMessage(...))` 送入 agent,来源为 `{ kind: 'user-question-reply', callId, outcome: 'answered' }`,JSON 文本包含 `answer_to_pending_question`、调用 id、原问题及答案。Remote 根据 Session id 解析 Agent 时按需恢复根实例;恢复操作不属于 `answer()` 方法体。
inbox 消息就是持久回复。`agent/inbox/spliced` 在消息入队时结算问题;随后接纳的 `user/message` 对该结算是幂等的。已结算答案批次保留在投影中,让原工具行即使自身结果仍是 pending,也能显示迟到回答。无法使用的已记录回复文本以空答案批次结算。
inbox 消息是持久的排队回复。逐 Session 预约覆盖从排队、领取到准入的期间;领取恢复后的 inbox 回复时会重建预约。回复作为 `user/message` 准入时问题结算;丢弃回复或 Turn 结束而未准入时释放预约,允许再次回答。已结算答案批次保留在投影中,让原工具行即使自身结果仍是 pending,也能显示迟到回答。无法使用的已记录回复文本以空答案批次结算。
无需新增 Session 事件类型或等待状态日志。消息来源扩展需要仓库的持久化类型变更确认。`dismissed` 为历史记录保留可读性,但没有生产者:隐藏面板不会伪造回复。
无需新增 Session 事件类型或等待状态日志。消息来源扩展需要仓库的持久化类型变更确认。隐藏面板不会伪造回复。
### 卡片状态与投递竞争
`QuestionCards` 按 Session 和 `callId`,让实时请求与已继续投影共享一个 `PendingQuestion`。没有调用 id 的阻塞请求拥有独立的逐请求卡片。开放卡片通过 waterfall 提交;缺少该通道时禁用提交。已继续卡片使用迟到回答 RPC。
`QuestionCards` 按 Session 和 `callId`,让实时请求与已继续投影共享一个 `PendingQuestion`。转发请求在投影列出问题之前,仍会让卡片存活至接手 stream 的首帧。没有调用 id 的阻塞请求拥有独立的逐请求卡片,并使用跨重载唯一的键,避免旧草稿附着到另一请求。开放卡片通过 waterfall 提交;缺少该通道时禁用提交。已继续卡片使用迟到回答 RPC。
waterfall 提交没有送达确认。Gateway 可能丢弃输掉结算竞争的结果,因此卡片保留草稿并保持忙碌,等待投影同步。若提交期间问题变为已继续,控件带重提提示重新可用,用户可以通过 RPC 提交保留的草稿。这不是自动重试,也不保证每次本地提交都能到达模型。
waterfall 提交没有送达确认。Gateway 可能丢弃输掉结算竞争的结果,因此卡片保留草稿并保持忙碌,等待投影同步。若提交期间问题变为已继续,控件带重提提示重新可用,用户可以通过 RPC 提交保留的草稿。RPC 接受提交后会将回复作为 steer 送往最近一步,但不会立即结算问题,同时关闭面板。当前工具等待须先结束,该步才能读取回复;若 steer 被丢弃,可以重新打开问题并再次回答。这不是自动重试,也不保证每次本地提交都能到达模型。
按工具调用标识的卡片在调用不属于活动投影且没有 waterfall 时移除。移除会关闭卡片并清理草稿。本地提交和传输取消帧都不能单独决定问题已经回答。无调用标识的阻塞卡片随 waterfall 结束;关闭这种阻塞面板以 `ASK_CANCELLED` 拒绝请求。
按工具调用标识的卡片在调用不属于活动投影且没有转发中的请求时移除。移除会关闭卡片并清理草稿。本地提交和传输取消帧都不能单独决定问题已经回答。无调用标识的阻塞卡片随 waterfall 结束;关闭这种阻塞面板以 `ASK_CANCELLED` 拒绝请求。
### 本地倒计时与面板操作
Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会暂停倒计时,失焦后从保留的剩余时间继续。接手握手前的聚焦在 deadline 到达时仍生效;握手前失焦不会制造计时器。首次编辑或「慢慢回答」冻结倒计时,该状态与浏览器本地草稿一起保存。
只有回答通道就绪、没有活动倒计时且卡片未锁定时才自动聚焦。隐藏计时调用的面板会保留接手和倒计时,工具行可以重新打开面板。已结算调用依据记录的问题与答案打开只读面板,不产生另一条回答通道。
只有回答通道就绪、没有活动倒计时且卡片未锁定时才自动聚焦。隐藏计时调用的面板会保留接手和倒计时,工具行可以重新打开面板。迟到回复进入 Inbox 后,工具行显示已提交的答案;等待准入期间,「查看回答」展开只读的行内记录。已结算调用打开只读面板,显示记录的答案。排队回复被丢弃后,回答操作恢复可用。
### 回复节点与分组
@@ -86,7 +86,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
**立即委托所有计时请求。** 向故意留空的回答方集合派发请求,浪费现有阻塞回答路径,还要求特殊处理 `NO_PROVIDER`。委托表示回答方不可用,不代表计时模式本身。
**持久化整值等待/聚焦/编辑流,或使用 Host 聚焦租约。** 持久问题状态可从工具与 inbox 事件推导。聚焦和编辑属于本地 UI 状态;记录它们或同步聚焦租约会增加 Host 状态,却不能让多个 Client 共享同一编辑会话。
**持久化整值等待/聚焦/编辑流,或使用 Host 聚焦租约。** 持久问题状态可从工具事件与已准入的用户事件推导。聚焦和编辑属于本地 UI 状态;记录它们或同步聚焦租约会增加 Host 状态,却不能让多个 Client 共享同一编辑会话。
**在通用 `MessageItem` 内重新分类 JSON、借用 relay form,或使用默认 context 行。** 解析文本来识别业务消息混淆了来源与内容;relay form 表示其他 agent 的消息,通用 context 行则会暴露 JSON。专用来源、Definition 和渲染器拥有问题呈现。
@@ -94,20 +94,24 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
**面板关闭时写仅入日志的关闭事件,或伪造放弃消息。** 仅入日志会让模型看不到用户决定,还增加另一套持久机制。伪造回复则把隐藏面板当成回答。因此关闭面板仍让计时问题保持可回答。
**在 inbox 消息之外增加专用迟到回复 Session 事件。** inbox 插入与接纳的用户消息已经记录回复。第三个事件重复同一事实,并增加另一种持久化类型。
**在 inbox 消息之外增加专用迟到回复 Session 事件。** inbox 插入记录排队回复,准入的用户消息记录其交付。第三个事件重复这些事实,并增加另一种持久化类型。
## Acceptance criteria
## 验证
- 服务测试覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
- 投影测试区分 legacy 与 timed schema,并覆盖 pending、已回答、其他失败、恢复修复及 inbox/消息重复结算。
- Client 测试覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板隐藏/重开。
- 分组与浏览器回放覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载保留既有行为。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
- [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
- [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖原生及 PTC pending 调用、其他失败、恢复修复、排队回复取消和准入后结算。
- [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
## Risks
## Consequences
模型可以继续独立工作,问题同时保持可回答。业务层拥有的计时与回复渲染复用现有传输和对话 API,代价是等待由本地而非全局协调,可见性遵循普通折叠。
- 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。
- 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。
- Host 与 Client 时钟不必对齐,但 Client 系统时钟变化可能移动到期时间,传输延迟可能推迟本地倒计时的开始。
- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。
- 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。
- 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。
- PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-24-scheduled-message-framing.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-scheduled-message-framing.md
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user:
en: f524c3b0ee74c586
zh: ec4e85f0fd853532
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/problem:
en: c4156e2a8a3ad379
zh: 992b76509f83d61f
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/decision:
en: b9b203da394f58e5
zh: ac511458d60dab49
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/alternatives-considered:
en: 8074a2b9b9f5d782
zh: 1604b7ee5d34375e
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/consequences:
en: 85e03b541bfce57c
zh: 77decbb804f6a0b9
@@ -0,0 +1,29 @@
# Agent Note: Delivered reminders are framed as scheduled messages from the user
Status: implemented
English | [中文](2026-09-24-scheduled-message-framing.zh.md)
## Problem
Due reminders reach their original Session as user-role messages with producer kind `schedule`. Before this decision, both renderers opened that message with an instruction about how to treat its payload: one-shot delivery asked the model to present `reminder_prompt_json` as untrusted reminder content and not as new user instructions, and recurring batch delivery asked the same for every `reminder_prompt` in `reminders_json`. The message therefore asserted that its own content was untrusted instead of naming where it came from.
## Decision
`renderReminderFraming` and `renderRecurringReminderBatchFraming` in [`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) both emit one shared fixed line, `SCHEDULED_MESSAGE_FRAMING`: `This is a scheduled message from the user`. It is the block's second line, after the bracketed marker (`[SCHEDULE REMINDER]`, `[SCHEDULE REMINDER BATCH]`), and the dynamic field lines are unchanged: a one-shot message still appends `schedule_id_json`, `occurrence_at`, and `reminder_prompt_json`, and a recurring batch still appends `reminders_json`.
The fixed line states the origin of the message: a schedule bound to that Session. A model reading a user-role message whose fixed block carries it reads the following reminder payload (`reminder_prompt_json` in a one-shot message, `reminders_json` in a batch) as content the user scheduled, and the Host cannot attest that the user typed it: `schedule_create` and `schedule_update` let the model write the prompt, and a model-written prompt can carry text the model read from the web, files, or command output. The previous line marked that payload untrusted, and this decision drops that instruction, so the change accepts the residual risk that model-authored text arrives under a user-origin line. Reintroduction condition: a demonstrated injection through a model-written prompt restores a clause that scopes the payload as reminder text rather than a new instruction, and the five pinned framing assertions in the package specs plus `expectReminderFraming` in the Web e2e are the check that such a change is deliberate. Framing-line forgery is closed by encoding: the schedule id and prompt are JSON strings, the occurrence instant is a validated canonical RFC 3339 value, and each occupies one line, so a prompt carrying embedded newlines or an `occurrence_at:`-style line cannot add framing lines to the block. The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision, including delivery through the Session controller and the saved delivery records.
## Alternatives considered
**Keep the untrusted-content instruction.** It asserted that the prompt was untrusted input, which the delivered message does not need: the prompt is written in the Session the schedule is bound to — by the model through `schedule_create` and `schedule_update`, or by the user in the Web task detail — and the delivery is an ordinary user-role message there.
**Change only the recurring batch line.** One-shot and recurring delivery would then disagree about the origin of the same kind of message, and each later wording change would need both renderers edited separately. Both paths now read one constant.
## Consequences
Model-visible reminder text changes for both delivery paths, so a Session log recorded before this decision reconstructs the previous framing, and the Web notice that renders a delivered message shows the new line when expanded. Nothing else moves: no session event, storage field, client code, protocol, or durable format changes, and the delivered text remains reconstructable from the log.
The prompt text carries no authorship guarantee. It is written in the Session the schedule is bound to, by the model through `schedule_create` and `schedule_update` or by the user in the Web task detail, and a model-written reminder can quote text the model read from the web, files, or command output. The task's Session binding scopes which Session's model reaches the task; it is not caller authorization and says nothing about where the prompt content came from. The Decision holds the accepted risk and its reintroduction condition.
No recorded-session snapshot covers a delivered reminder, and this change adds none: no committed fixture contains a `schedule` message, and `snapshots/web/schedule-catalog` records catalog queries only. Two harness capabilities are missing for such a fixture: no scenario patch controls the wall clock that `packages/schedule/schedule/src/runtime.ts` reads for delivery, and the snapshot harness seeds Sessions rather than the schedule storage domain that holds Host tasks, so those rows exist only where a Web test writes them by hand. This change records the missing harness support instead of a fixture. The delivered text is pinned by the five full-string assertions in `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` and by `expectReminderFraming` in `apps/web/tests/schedule-after.e2e.ts`, which asserts the line in the assembled request for the one-shot and recurring paths.
@@ -0,0 +1,29 @@
# Agent Note: 投递的提醒以「用户安排的定时消息」为固定文本
Status: implemented
[English](2026-09-24-scheduled-message-framing.md) | 中文
## Problem
到期的提醒以生产者 kind 为 `schedule` 的 user-role 消息进入其原 Session。在本决策之前,两个渲染器都用一句关于如何处理其载荷的固定指令开篇:单次投递要求模型把 `reminder_prompt_json` 作为不可信的提醒内容呈现、而不是新的用户指令;周期批次投递对 `reminders_json` 中的每个 `reminder_prompt` 提出同样的要求。于是这条消息断言自身内容不可信,而没有说明它来自哪里。
## Decision
[`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) 中的 `renderReminderFraming` 与 `renderRecurringReminderBatchFraming` 都输出同一条固定行,即模块常量 `SCHEDULED_MESSAGE_FRAMING`:`This is a scheduled message from the user`;它是该块的第二行,位于方括号标记(`[SCHEDULE REMINDER]`、`[SCHEDULE REMINDER BATCH]`)之后。动态字段行不变:单次消息仍追加 `schedule_id_json`、`occurrence_at`、`reminder_prompt_json`,周期批次仍追加 `reminders_json`。
这条固定行说明消息的来源:绑定到该 Session 的一条定时任务。模型读到固定块中带有它的 user-role 消息时,会把随后的提醒载荷(单次消息的 `reminder_prompt_json`、批次的 `reminders_json`)当作用户安排的内容,而 Host 无法证明该文本由用户键入:`schedule_create` 与 `schedule_update` 允许模型写入提示词,模型写入的提示词可以带上模型从网页、文件或命令输出中读到的文本。旧固定行把该载荷标记为不可信,本决策有意去掉这一指令,因此接受「模型生成的文本以用户来源行投递」这一残留风险。再引入条件:一旦出现经由模型写入提示词完成注入的实例,就恢复一句把载荷限定为提醒文本、而非新指令的说明;包内五处固定断言与 Web e2e 的 `expectReminderFraming` 是「此类改动必须是有意为之」的检查。固定行无法被动态字段伪造,这由编码保证:schedule id 与提示词是 JSON 字符串,发生时点是经校验的规范 RFC 3339 值,且各自只占一行,因此带有换行或形如 `occurrence_at:` 的提示词无法向该块增加固定行。[Host 拥有的定时消息](2026-09-16-host-schedule-storage.zh.md) 记下的其余决策不变,包括通过 Session controller 投递以及保存的投递记录。
## Alternatives considered
**保留「不可信内容」指令。** 它断言提示词是不可信输入,而投递的消息并不需要这一断言:提示词写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——投递本身就是该 Session 中的普通 user-role 消息。
**只改周期批次那一行。** 单次与周期投递会对同类消息的来源给出不同说法,此后每次改措辞都要分别改两个渲染器。现在两条路径读同一个常量。
## Consequences
两个投递路径的模型可见提醒文本都改变,因此在该决策之前记录的 Session 日志重建出的是旧的固定文本;Web 中渲染该投递消息的通知在展开时显示新的一行。其余不动:没有 session 事件、存储字段、客户端代码、协议或持久化格式变化,投递文本仍可从日志重建。
提示词文本不携带作者保证。它写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——模型创建的提醒可以把模型从网页、文件或命令输出中读到的文本原样写入。任务的 Session 绑定限定的是哪个 Session 的模型能访问该任务;它不是调用方鉴权,也不说明提示词内容的来源。本决策中记下该残留风险与再引入条件。
没有录制会话快照覆盖提醒投递,本次也没有新增:已提交夹具中没有 `schedule` 消息,`snapshots/web/schedule-catalog` 只记录任务查询。构成该夹具还缺两项 harness 能力:没有场景补丁能控制 `packages/schedule/schedule/src/runtime.ts` 读取的墙上时钟;快照 harness 播种的是 Session,而不是承载 Host 任务的 schedule storage domain,那些任务行只在 Web 测试里手工写入。因此本次记录缺失的 harness 支持,而不新增夹具。投递文本改由 `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` 中五处整串断言,以及 `apps/web/tests/schedule-after.e2e.ts` 中在装配后请求上断言该行、覆盖单次与周期两条路径的 `expectReminderFraming` 固定。
@@ -6,11 +6,11 @@
en: 5e8c5673a0669d9c
zh: c8442a6186288a2a
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem:
en: ac4fb08e3203cee9
zh: 69fcfcb4f8fc0319
en: 906f8707987ff36c
zh: 40430ec1e03ee340
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision:
en: 653c74442decb65a
zh: 46f01c45d48b846b
en: a998f5c6abdd68eb
zh: a7cdd13e1e4de6c6
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered:
en: 21a9a19b76f42758
zh: 30a8f5425b59900d
@@ -18,5 +18,5 @@
en: b58904921286a1db
zh: a696c2954d86c04f
/agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing:
en: 78253191eea198fa
zh: 32bdd6807ddc13a0
en: c741ea002d239ae6
zh: d68846526e63ce51
@@ -6,11 +6,11 @@ English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md)
## Problem
Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds.
Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders under Windows PowerShell 5.1, the same two-chunk render under pwsh 7.6.6), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds. The lane's own failures are not that state: the first master run carrying this field (run 36326153388, 2026-09-27 14:30Z) took the same 20.8 s as the run before it (36309006133), so its degraded sends settled at the plain bound with the tolerance inert; the [Windows-lane observation note](../testing/2026-09-27-observation-waits-on-observed-state.md) records the open investigation.
## Decision
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case keeps the default: the self-hosted lane's degraded sends settle at the plain bound with the tolerance present or absent, so a nonzero value there would only lengthen a never-arriving-tail fallback from 3.3 s to 8.3 s per send while reporting a genuinely late tail as a slow pass instead of a named failure. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values.
## Alternatives considered
@@ -28,4 +28,4 @@ A deployment whose console renderer stalls keeps those sends on the exact path b
## Testing
`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms.
`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. The end-to-end evidence on native Windows came from a guest-patched copy of `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` (Windows PowerShell 5.1, 2026-09-27): with every session's prompt tail withheld for four seconds and the tolerance at 5000 ms the case passed — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and with the tolerance at zero two sends settled `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms. The committed case runs with the default and prints a per-send readiness timeline on failure, so that state is named when it occurs rather than inferred from wall time.
@@ -6,11 +6,11 @@ Status: implemented
## Problem
自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。
自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(Windows PowerShell 5.1 下 56 次渲染,pwsh 7.6.6 下同样是两分块渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。该 lane 自身的失败不是这种状态:首个带着该字段的 master run(run 36326153388,2026-09-27 14:30Z)与其前一个 run(36309006133)同样耗时 20.8 秒,因此其退化的 send 是在宽限不起作用的情况下按普通上界结算的;[Windows lane 观测笔记](../testing/2026-09-27-observation-waits-on-observed-state.zh.md)记录了这项仍未关闭的调查。
## Decision
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例保持默认值:自托管 lane 上退化的 send 无论有没有该宽限都按普通上界结算,因此在那里设置非零值只会把尾部永不到达时的回退从每次 send 3.3 秒拉长到 8.3 秒,并把真正迟到的尾部报告成一次缓慢的通过,而不是一次点名状态的失败。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。
## Alternatives considered
@@ -28,4 +28,4 @@ Status: implemented
## Testing
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。
`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。原生 Windows 上的端到端证据来自 guest 内打过补丁的 `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 副本(Windows PowerShell 5.1,2026-09-27):把每个 session 的提示符尾部扣留四秒且宽限设为 5000 毫秒时用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;宽限为零时两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。提交的用例以默认值运行,并在失败时打印逐 send 的就绪时间线,因此该状态一旦出现就会被点名,而不是从墙钟时间推断。
@@ -10,7 +10,10 @@
zh: 91844972d41dd6f9
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
en: 125dea7427047966
zh: 5d0bfd3921b38714
zh: 1e459b1106597695
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
en: a5bcdf2a6133abfe
zh: 477d4915d61945e3
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
en: ac9b3d800889ea39
zh: 9db1065b6027f65f
@@ -12,6 +12,14 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win
Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the Line truncated
## ACL diagnosis and recovery
The bundled diagnosis skill runs its preserving ACL repairs outside the confined token because that token cannot inspect the conflicting objects. External PowerShell requires physical script files, so registration extracts resources from ASAR or SEA instead of exposing archive paths. The fiber owns that temporary copy. Recovery files live in the caller's persistent output directory so disposal of the skill does not remove the means to restore a DACL.
A repair backs up before mutation and verifies effective access or the resulting ACEs. One invocation performs every repair it can prove, so a single sandbox failure needs one approval instead of a diagnosis followed by a repair: it adds the caller's full-control ACE to the chain directories that lack effective `WRITE_DAC` or `WRITE_OWNER`, and removes explicit package allow ACEs at their sources. The authorized root itself may be modified and restored, so a workspace root can repair and roll itself back, and recovery artifacts are one DACL record plus one copy of the restoring script per modified object. When the requested directory is one the caller cannot provision — the object a provisioning error names — that same invocation also examines its subtree for explicit package allow ACEs through a bounded walk that reports truncation, because the conflicting entry often sits below the reported root. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed change would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy.
Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. Because tool output keeps only its tail, every record is also written to a report file under the output directory and the run ends with a recap line, so the decisions survive truncation. The operator keeps those artifacts until verification of the original confined operation succeeds.
## How the restriction works (why no new identity)
The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement.
@@ -12,6 +12,15 @@ Status: implemented
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
<a id="acl-diagnosis-and-recovery"></a>
## ACL 诊断与恢复
内置诊断技能在受限令牌之外执行保留原有权限的 ACL 修复,因为受限令牌无法检查发生冲突的对象。外部 PowerShell 需要实际脚本文件,因此注册时从 ASAR 或 SEA 提取资源,而不提供归档内路径。fiber 拥有该临时副本。恢复文件保存在调用者指定的持久输出目录中,确保技能释放后仍能恢复 DACL。
修复在修改前备份,并验证有效权限或生成的 ACE。一次调用会完成它能证明的全部修复,因此单个沙箱失败只需一次批准,而不必先诊断再修复:为链路上缺少有效 `WRITE_DAC` 或 `WRITE_OWNER` 的目录添加调用者自身的完全控制 ACE,并在来源处移除显式的包允许 ACE。授权根目录本身也可以被修改和恢复,因此工作区根目录能自我修复并回滚;恢复产物为每个被修改对象一份 DACL 记录和一份恢复脚本副本。当请求的目录正是调用者无法授权的那一个——即授权失败错误所指向的对象——同一次调用还会通过受上限约束、并在截断时如实报告的遍历,检查其子树中的显式包允许 ACE,因为冲突条目常常位于被报告的根目录之下。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:某次修改失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。
提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。由于工具输出只保留末尾部分,全部记录还会写入输出目录下的报告文件,运行结束时给出一行摘要,使判定在截断后仍然可见。操作者应保留这些文件,直到原始受限操作验证成功。
## How the restriction works (why no new identity)
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
@@ -9,8 +9,8 @@
en: 36bf235f0821ef6a
zh: a86bf07af03d54a3
/agent-note-desktop-primary-runtime/decision:
en: b3b104e314762322
zh: 77478c007893201f
en: 3d2241842f5a2a83
zh: 8617717f0337d76c
/agent-note-desktop-primary-runtime/alternatives-considered:
en: 566fd91c30cb0a0a
zh: 70758ae2617f6ebb
@@ -16,7 +16,7 @@ The application version, top-level interpreter/package-manager versions, Python
The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload.
macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
macOS grants `com.apple.security.cs.allow-jit` to the standalone Node executable and LibreOffice helpers. The x64 Node executable additionally requires `com.apple.security.cs.allow-unsigned-executable-memory`: with JIT-only signing, the bundled x64 Node fails during startup under Rosetta in V8 `OS::SetPermissions`. ARM64 Node and LibreOffice helpers retain JIT-only permissions; the additional permission is confined to the interpreter that requires it. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion.
@@ -16,7 +16,7 @@ Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为
共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。
macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
macOS 向独立 Node 可执行文件和 LibreOffice 辅助程序授予 `com.apple.security.cs.allow-jit`。x64 Node 可执行文件还需要 `com.apple.security.cs.allow-unsigned-executable-memory`:仅授予 JIT 权限时,内置 x64 Node 在 Rosetta 下启动会在 V8 `OS::SetPermissions` 中失败。ARM64 Node 和 LibreOffice 辅助程序保持仅有 JIT 权限;额外权限仅授予需要它的解释器。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。
@@ -9,8 +9,8 @@
en: e22ee243b1503319
zh: b53c1a932d547f55
/agent-note-shared-developer-tool-settings/decision:
en: 62f71b49af7b9971
zh: f0f12da4cb0c7fc0
en: 6b94e4c856dbd92a
zh: e923b1118dfad7b6
/agent-note-shared-developer-tool-settings/alternatives-considered:
en: df1b80e5b8d2a178
zh: 0136d16fc44d57bf
@@ -12,9 +12,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to
The settings-owned `ctx.configForms.developerTools` preference uses a shared entry form and exposes one accepted `ui-settings.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and refuses the Agent-preset cards' selection, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization.
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the Agent-preset surfaces' `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, the new-session preset chip's `developerTools`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access.
## Alternatives considered
@@ -12,9 +12,9 @@ Status: implemented
设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口并拒绝 Agent 预设卡片的选择操作,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、Agent 预设各界面读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、新会话预设入口读取的 `developerTools`,以及 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
## Alternatives considered
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-27-desktop-cli-runtime.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-27-desktop-cli-runtime.md
/agent-note-desktop-installed-cli-runtime:
en: 0527f32e8a3c13eb
zh: 77dde6e91fc1a636
/agent-note-desktop-installed-cli-runtime/problem:
en: fb07307392d14fd9
zh: a334f905b26eef98
/agent-note-desktop-installed-cli-runtime/decision:
en: 163c098af7ce6b7b
zh: 0c142db0f173f761
/agent-note-desktop-installed-cli-runtime/consequences:
en: 1dfe287934c9f4bc
zh: 138f226ec7409a1f
/agent-note-desktop-installed-cli-runtime/command-registration:
en: 75ff35db64138e9a
zh: a8f799c2d568fa8b
/agent-note-desktop-installed-cli-runtime/alternatives-considered:
en: 91974c10416cf0fd
zh: fe26c0b4f5f37e98
@@ -0,0 +1,41 @@
# Agent Note: Desktop-installed CLI runtime
Status: implemented
English | [中文](2026-09-27-desktop-cli-runtime.zh.md)
## Problem
Desktop contains dsh and its production dependencies. Users need terminal access to plugin management and other CLI functions without maintaining another installation.
## Decision
A shell script on macOS and a command script on Windows run the private Desktop CLI entry through the installed Electron executable in Node mode. The entry delegates to the ordinary CLI dispatcher and supplies bundled pnpm through its package-operation options. Desktop Host startup and ASAR packaging retain their existing behavior.
The Desktop-installed command can manage the initialized `desktop` profile while the application is quit. It uses the same profile write lock, package compatibility checks and reconciliation as existing plugin operations. Desktop owns profile initialization; the CLI refuses to initialize a missing Desktop profile as an ordinary CLI profile. npm-installed dsh cannot manage that reserved profile, and neither CLI boots it.
Windows registers a console callback through Koffi to deliver SIGINT and SIGBREAK to the CLI's JavaScript listeners for asynchronous shutdown. Electron's native default exits before those listeners run. The existing Office engine resolver supplies physical paths for Web attachment conversion; CLI profile startup does not inject Office authoring-skill configuration.
## Consequences
Users finish CLI commands before updating or uninstalling Desktop. Installers do not coordinate with CLI processes, and no runtime versions are retained for running commands. An overlapping update can interrupt commands or make their runtime files unavailable.
The command's runtime version follows Desktop. The [Electron runtime decision](../architecture/2026-09-11-desktop-electron-node-runtime.md) owns Electron/OpenSSL and third-party native-addon limitations. Desktop's existing Office resources are unchanged; the CLI does not automatically supply their authoring paths to user-enabled plugins.
## Command registration
The permanent **Manage dsh Command…** menu displays status and installation, repair, and removal actions. macOS uses a fixed `/usr/local/bin/dsh` link, with administrator authentication when needed, instead of editing shell startup files. Windows registers a dedicated directory containing only the public launcher in the current user's PATH. Registration and removal are optional menu actions after application installation; the installer and uninstaller do not perform them. Private installation helpers are outside that PATH directory.
Switching an existing command requires confirmation. The executor rechecks the displayed fingerprint and carries that exact command entry through withdrawal; a concurrent replacement is preserved. A receipt retains the previous macOS launcher. If another installation replaces the Desktop link before reinstallation, any older backup is preserved and reported; Remove restores only the current receipt’s backup. Windows inserts one owned PATH occurrence and records the count of identical pre-existing occurrences so removal preserves them. Other command locations are reported without being modified. Concurrent dialogs share one operation, and update preparation waits for it to finish.
The macOS worker reports operation failures in a JSON response with a successful process exit so AppleScript retains its error code and message. Cancelling administrator authentication ends the operation without an error dialog. Windows treats invalid PATH entries as unavailable candidates and preserves their text; environment-change notifications do not change the result of a committed registry write.
## Alternatives considered
**Another npm installation.** It duplicates dependencies and can drift from Desktop's runtime.
**Standalone Node for the CLI.** Ordinary Node cannot load the existing ASAR dependency tree. Moving or duplicating that tree expands the packaging change.
**Forward commands to the running Desktop Host.** This requires the GUI to be running and adds live-plugin lifecycle integration. Direct package operations support the documented quit-and-reopen workflow.
**Coordinate updates with every CLI process.** Native admission locks and installer handoff records add lifecycle behavior beyond the initial command integration. The CLI uses the existing application update policy.
@@ -0,0 +1,41 @@
# Agent Note: Desktop 安装的 CLI 运行时
Status: implemented
[English](2026-09-27-desktop-cli-runtime.md) | 中文
## 问题
Desktop 已包含 dsh 及其生产依赖。用户需要在终端中管理插件并使用其他 CLI(命令行界面)功能,无需维护另一份安装。
## 决策
macOS 的 shell 脚本与 Windows 的命令脚本使用已安装 Electron 的 Node 模式运行私有 Desktop CLI 入口。该入口委托给普通 CLI 分派器,通过包操作选项提供内置 pnpm。Desktop Host 启动与 ASAR 打包保留现有行为。
Desktop 内置命令可在应用退出时管理已初始化的 `desktop` profile。它与现有插件操作使用相同的 profile 写锁、包兼容性检查和协调逻辑。Desktop 拥有 profile 初始化流程;CLI 拒绝将缺失的 Desktop profile 初始化为普通 CLI profile。npm 安装的 dsh 不能管理此保留 profile,两种 CLI 都不会启动它。
Windows 通过 Koffi 注册控制台回调,将 SIGINT 和 SIGBREAK 送到 CLI 的 JavaScript 监听器以完成异步退出。Electron 原生默认行为会在这些监听器运行前退出。现有 Office 引擎解析器为 Web 附件转换提供物理路径;CLI profile 启动不注入 Office 创作 skill(技能)的配置。
## 影响
用户须在更新或卸载 Desktop 前结束 CLI 命令。安装程序不与 CLI 进程协调,也不为运行中的命令保留多个运行时版本。重叠的更新可能中断命令,或使其运行时文件不可用。
命令的运行时版本随 Desktop 变化。[Electron 运行时决策](../architecture/2026-09-11-desktop-electron-node-runtime.zh.md)说明 Electron/OpenSSL 与第三方原生 addon 的限制。Desktop 现有 Office 资源保持不变;CLI 不会自动向用户启用的插件提供创作资源路径。
## 命令注册
固定的**管理 dsh 命令…**菜单显示状态,并提供安装、修复和移除操作。macOS 使用固定的 `/usr/local/bin/dsh` 链接,必要时请求管理员认证,不修改 shell 启动文件。Windows 将专门存放公开启动器的目录注册到当前用户的 PATH。注册与移除是应用安装后的可选菜单操作,安装程序和卸载程序不执行这些操作。私有安装辅助程序不在这个 PATH 目录中。
切换已有命令需要确认。执行器重新核对展示过的指纹,并在移走命令时一直使用该次观察到的条目;并发替换的命令会被保留。所有权记录保存之前的 macOS 启动器。若其他安装在重新安装前替换了 Desktop 链接,旧备份会保留并报告;移除操作只恢复当前所有权记录中的备份。Windows 插入一个自有 PATH 条目,并记录此前完全相同条目的数量,移除时保留它们。其他命令位置只展示,不修改。并发对话框共享一个操作,更新准备会等待该操作完成。
macOS worker 以 JSON 响应报告操作失败,同时以成功状态退出进程,使 AppleScript 保留错误码和消息。取消管理员认证会结束操作,不显示错误对话框。Windows 将无效 PATH 条目视为不可用候选并保留其文本;环境变更通知不会改变已经提交的注册表写入结果。
## 考虑过的替代方案
**再次通过 npm 安装。** 这会重复依赖,并可能偏离 Desktop 的运行时。
**CLI 使用独立 Node。** 普通 Node 无法加载现有 ASAR 依赖树。移动或复制该树会扩大打包改动。
**将命令转发给运行中的 Desktop Host。** 这要求 GUI 保持运行,并增加运行中插件的生命周期集成。直接包操作支持文档约定的退出后重新打开流程。
**协调每个 CLI 进程与更新。** 原生启动锁和安装程序交接记录增加了初始命令集成以外的生命周期行为。CLI 使用应用现有的更新策略。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-28-desktop-login-shell-environment.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-28-desktop-login-shell-environment.md
/agent-note-desktop-reads-the-login-shell-environment-once:
en: f3d08cb5b03f7032
zh: 2f117f42aed85bc3
/agent-note-desktop-reads-the-login-shell-environment-once/problem:
en: 3a4b92c977e42f45
zh: 575d92a41e29ae7a
/agent-note-desktop-reads-the-login-shell-environment-once/decision:
en: d2af1761d96c498a
zh: 8419dea042446494
/agent-note-desktop-reads-the-login-shell-environment-once/alternatives-considered:
en: ed9ed8bb06a19d7f
zh: 52ceb0d70f0d05a7
/agent-note-desktop-reads-the-login-shell-environment-once/consequences:
en: f3cd204ab6691f1d
zh: 00c9ae619065b4c3
@@ -0,0 +1,27 @@
# Agent Note: Desktop reads the login-shell environment once
Status: implemented
English | [中文](2026-09-28-desktop-login-shell-environment.zh.md)
## Problem
macOS Dock, Finder, and Linux desktop launches start Desktop with only the session manager's environment. Variables that users export from `~/.zprofile` or `~/.zshrc`, including PATH entries for Homebrew, version managers, and API keys, are absent from the Host, agent shells, terminals, and profile configuration. The same Host launched from a terminal sees them.
## Decision
Before creating the first Host, [Desktop](../../../../apps/desktop/README.md) runs one delimited `env -0` dump through `<shell> -ilc`, where `<shell>` is the account record's login shell, followed by `/bin/zsh`, `/bin/bash`, and `/bin/sh`. Every Host in the application process uses the merged result. Shell values replace inherited values except probe-session variables and the `DSH_*` and `ELECTRON_*` namespaces. The read completes at the closing delimiter. A timeout or quit kills the probe's process group, and a failed read falls back to the inherited environment without blocking startup. Windows skips the read.
## Alternatives considered
**Read `$SHELL` instead of the account record.** A Dock launch inherits `$SHELL` from launchd, not from the user's current choice, and a terminal launch can carry a temporary shell. The account record names the shell the user selected with `chsh`.
**Non-interactive `-lc` only.** Most users export PATH additions from `~/.zshrc`, which only interactive shells read. Interactive mode requires the probe variables that stop oh-my-zsh update prompts and tmux autostart from blocking.
**Let shell values replace `DSH_*` variables.** Desktop resolves `DSH_HOME` and its profile paths before the read, so a `DSH_HOME` exported from `~/.zshrc` would give the Host a different home from the one Desktop prepared.
**Wait for stdout to close.** Background processes that startup files start inherit stdout and can hold it open indefinitely, which turns every read into a timeout.
## Consequences
Dock and Finder launches give the Host the environment a terminal launch provides. Changes to startup files apply only after Desktop restarts. Startup waits for the read, up to `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` per candidate. Account shells that cannot run the POSIX dump, such as csh, tcsh, and nushell, receive the first system shell's environment instead. `DSH_*` settings must come from the launcher's environment rather than startup files. Unit tests run stand-in shells for the early completion, timeout, abort, and fallback paths, and the Desktop startup test verifies that the Host waits for the read.
@@ -0,0 +1,27 @@
# Agent Note: Desktop 一次性读取登录 shell 环境
Status: implemented
[English](2026-09-28-desktop-login-shell-environment.md) | 中文
## 问题
从 macOS 的 Dock、Finder 或 Linux 桌面启动 Desktop 时,它只获得会话管理器提供的环境。用户在 `~/.zprofile` 或 `~/.zshrc` 中导出的变量,包括 Homebrew、版本管理器的 PATH 条目和 API key,在 Host、agent shell、终端和 profile 配置中都不存在。从终端启动同一个 Host 时这些变量都在。
## 决策
创建第一个 Host 之前,[Desktop](../../../../apps/desktop/README.zh.md) 通过 `<shell> -ilc` 运行一次带定界符的 `env -0` 输出,`<shell>` 依次为账户记录中的登录 shell、`/bin/zsh`、`/bin/bash` 和 `/bin/sh`。同一应用进程中的所有 Host 使用合并后的结果。shell 的值覆盖继承的值,但读取进程自身的会话变量以及 `DSH_*` 和 `ELECTRON_*` 命名空间除外。读取在结束定界符出现时完成。超时或退出会结束读取进程组;读取失败时回退到继承的环境,不阻止启动。Windows 跳过读取。
## 考虑过的替代方案
**读取 `$SHELL` 而不是账户记录。** 从 Dock 启动时,`$SHELL` 继承自 launchd,而不是用户当前的选择;从终端启动时它可能是临时 shell。账户记录给出用户用 `chsh` 选择的 shell。
**只使用非交互的 `-lc`。** 多数用户在 `~/.zshrc` 中添加 PATH,而只有交互式 shell 会读取它。交互模式需要设置读取变量,避免 oh-my-zsh 更新提示和 tmux 自动启动阻塞。
**允许 shell 的值覆盖 `DSH_*` 变量。** Desktop 在读取之前已解析 `DSH_HOME` 及其 profile 路径,因此 `~/.zshrc` 导出的 `DSH_HOME` 会让 Host 使用与 Desktop 已准备的不同的 home。
**等待 stdout 关闭。** 启动文件启动的后台进程继承 stdout,可能无限期保持它打开,使每次读取都超时。
## 影响
从 Dock 和 Finder 启动时,Host 获得与从终端启动相同的环境。修改启动文件后需要重启 Desktop 才生效。启动需要等待读取,每个候选最多 `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS`。无法运行 POSIX 输出命令的账户 shell(如 csh、tcsh 和 nushell)改为获得第一个系统 shell 的环境。`DSH_*` 设置必须来自启动方的环境,而不是启动文件。单元测试用替身 shell 覆盖提前完成、超时、中止和回退路径,Desktop 启动测试验证 Host 等待读取完成。
@@ -9,8 +9,8 @@
en: 4fb1c27a631b32d3
zh: 9dbe9a4b9aadfca9
/agent-note-serial-cross-platform-ci-reference/decision:
en: 7c58d88d4c002f19
zh: a3b961be49f2a96b
en: 6c682d0b7b2a03ca
zh: b75950853f45cccf
/agent-note-serial-cross-platform-ci-reference/alternatives-considered:
en: 3610152c5c4e93af
zh: f4c09d327716f750
@@ -24,7 +24,7 @@ Platform ownership remains explicit inside that complete aggregate. `terminal-ba
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. That job grants `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`, the coverage and serial lanes' key and value, and runs the unit inventory through the `ci-unit` aggregate that consumes it, so cases that inherit Vitest's defaults keep those lanes' budget on the shared macOS runner. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
@@ -24,7 +24,7 @@ Status: implemented
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也涵盖从交互式子进程继承而来的标记。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。该作业授予与覆盖率、串行通道相同键值的 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`,并通过消费该变量的 `ci-unit` 聚合运行单元测试清单,因此继承 Vitest 默认值的用例在共享的 macOS 运行器上保有这些通道的预算。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-28-dual-release-install-layout-work-budget.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md
/agent-note-dual-release-install-layout-work-budget:
en: 808f7c1d11ce15b5
zh: 861c63dd2de66452
/agent-note-dual-release-install-layout-work-budget/problem:
en: 46916488210ba493
zh: 98107c3805a22b2c
/agent-note-dual-release-install-layout-work-budget/decision:
en: c432b68c4ad1b3c1
zh: a11e958fa68dc2f3
/agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs:
en: 36d3e3ef39cbba2f
zh: 2480acf6260817b3
/agent-note-dual-release-install-layout-work-budget/alternatives-considered:
en: 29987b7634c84d28
zh: a1469e4b6aaa46e2
/agent-note-dual-release-install-layout-work-budget/consequences:
en: 8dffc0c3fa699980
zh: 99ec25001e5aa716
@@ -0,0 +1,50 @@
# Agent Note: Dual-release install layout work budget
Status: implemented
English | [中文](2026-09-28-dual-release-install-layout-work-budget.zh.md)
## Problem
The `Dependency layout` lane packs two incompatible synthetic DSH releases into a temporary consumer, runs one real npm resolution over them, and asserts the physical placement npm chose. It decided pass or fail with `TIMEOUT_MS = 300_000` on the npm child process. That deadline measures the runner, not the graph: the same graph resolves in 137-205 s on an idle developer host and in 288.59 s on the CI runner, 4% below the deadline, while the placement assertion reported no error. The diagnostic named only elapsed time, so a slow runner and a graph that had grown were indistinguishable.
## Decision
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it.
## What the resolution costs
Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`:
| Phase | Cost |
|---|---|
| Registry synthesis (`buildRegistryIndex`, 3952 installed manifests plus 319 DSH workspace manifests into 1544 names) | 1.67 s |
| npm child process | 180.66 s of 185.07 s end to end |
| `idealTree:buildDeps` inside that child | 153.30 s, and nearly all of it lands in 14 of 877 per-node timers |
| `checkCanPlace` / `canPlacePeers` peer-conflict analysis | 117.3 s, of which `new URL()` 65.9 s and `SemVer` 27.9 s |
| Registry traffic | 682 requests, 383 KB of packument bodies |
| Layout assertion and process startup | 4.4 s |
The same graph resolved in 1.12 s with `--legacy-peer-deps`, so peer placement is 152.7 s of the 153.8 s. `canPlacePeers` re-checks each internal edge against the incoming edges of its peer target, which makes the cost grow with placements times edge count; the nested release's copy of `@deepseek-ai/dsh-base` alone takes 48.8 s against 1.3 s for the identical package at the root of the same tree. About three quarters of the verified internal edges are peer edges (1874 of 2524), so this work is the assertion, not overhead around it.
Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time, a factor of 1.5 with no input change, which is why a wall-clock threshold cannot separate a slow host from a larger graph.
## Alternatives considered
**Reuse one npm cache directory across runs.** The lane's `npm_config_cache` lives inside the temporary consumer, and the registry listens on an ephemeral port, so `make-fetch-happen` keys every cache entry as `http://127.0.0.1:<port>/<name>` and a persisted cache cannot hit. Re-running the full graph warm with `--prefer-offline` over the cold run's 4.4 MB cache produced a byte-identical package lock in 189.8 s, against 153.8 s cold: correct, and slower. The profile's 10.2 s of idle time is the ceiling on any network saving, because npm spends the rest of the run in its own placement code.
**Merge or parallelize the two releases.** The lane already resolves both releases in one npm invocation, which is what makes the co-existence claim checkable, and one release cannot reuse the other's peer analysis because `canPlacePeers` examines the whole tree. Splitting the graph into two independent resolutions would remove the shared-Cordis and cross-release placement facts the assertion exists to check.
**Raise `TIMEOUT_MS`.** A larger deadline leaves the failure mode in place: the verdict still depends on runner speed, and a graph that grows until it is slow on every machine still fails with no measurement of what grew.
**`--prefer-dedupe`.** It resolves the same graph in 126.8 s but chooses a different placement (809 lock entries against 815, eight differing entries), so the lane would assert a non-default npm placement.
**`--legacy-peer-deps`.** It resolves in 1.12 s and drops the 52 peer-installed packages, along with the peer edges that make up most of the assertion's coverage.
## Consequences
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
@@ -0,0 +1,50 @@
# Agent Note: 双发布安装布局的工作量预算
Status: implemented
[English](2026-09-28-dual-release-install-layout-work-budget.md) | 中文
## Problem
`Dependency layout` lane 把两个互不兼容的合成 DSH 发布打进临时消费者目录,对它们跑一次真实 npm 解析,并断言 npm 选定的物理摆放。它用 npm 子进程上的 `TIMEOUT_MS = 300_000` 判定通过或失败。这个期限度量的是 runner,而不是图:同一张图在空闲开发机上用 137–205 秒解析完成,在 CI runner 上用 288.59 秒,比期限低 4%,而摆放断言没有报告任何错误。诊断信息只给出耗时,因此 runner 慢和依赖图变大无法区分。
## Decision
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。
## What the resolution costs
2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得:
| 阶段 | 开销 |
|---|---|
| Registry 合成(`buildRegistryIndex`,3952 个已安装 manifest 加 319 个 DSH workspace manifest,合成 1544 个包名) | 1.67 s |
| npm 子进程 | 端到端 185.07 s 中的 180.66 s |
| 该子进程内的 `idealTree:buildDeps` | 153.30 s,且几乎全部落在 877 个按节点计时器中的 14 个上 |
| `checkCanPlace` / `canPlacePeers` peer 冲突分析 | 117.3 s,其中 `new URL()` 65.9 s、`SemVer` 27.9 s |
| Registry 流量 | 682 次请求、383 KB packument 响应体 |
| 布局断言与进程启动 | 4.4 s |
同一个依赖图加上 `--legacy-peer-deps` 后 1.12 s 解析完成,因此 peer 摆放占 153.8 s 中的 152.7 s。`canPlacePeers` 会把每条内部边与其 peer 目标的入边重新核对一遍,这使开销随「摆放次数 × 边数」增长;嵌套发布的 `@deepseek-ai/dsh-base` 副本单独耗时 48.8 s,而同一棵树上根位置的同一包只耗时 1.3 s。被校验的内部边约有四分之三是 peer 边(2524 条中的 1874 条),所以这部分工作是断言本身,而不是断言之外的开销。
同一台主机上对同一张图的重复运行,npm 耗时在 137.8 s 到 204.8 s 之间波动,输入没有任何变化却有 1.5 倍差距,这正是墙钟阈值无法区分「主机慢」和「图更大」的原因。
## Alternatives considered
**跨次复用同一个 npm cache 目录。** lane 的 `npm_config_cache` 位于临时消费者目录内,registry 监听临时端口,因此 `make-fetch-happen` 把每个 cache 条目键为 `http://127.0.0.1:<port>/<name>`,持久化的 cache 无法命中。用冷启动那次留下的 4.4 MB cache 加 `--prefer-offline` 重跑整张图,产出的 package lock 逐字节相同,耗时 189.8 s,而冷启动为 153.8 s:结果正确,但更慢。profile 中 10.2 s 的空闲时间是任何网络节省的上限,因为 npm 把其余时间都花在自己的摆放代码里。
**合并或并行两个发布。** lane 已经在一次 npm 调用里解析两个发布,这正是共存声明可被校验的原因;而 `canPlacePeers` 会检查整棵树,一个发布无法复用另一个的 peer 分析。把图拆成两次相互独立的解析会丢掉断言存在的理由:共享 Cordis 与跨发布摆放。
**调大 `TIMEOUT_MS`。** 更大的期限保留了原有失效模式:判定仍取决于 runner 速度,而一个增长到在任何机器上都慢的图仍会失败,且不给出任何关于「什么变大了」的测量。
**`--prefer-dedupe`。** 它用 126.8 s 解析同一张图,但选择了不同的摆放(809 条 lock 条目对 815 条,8 条条目不同),因此 lane 断言的将是非默认的 npm 摆放。
**`--legacy-peer-deps`。** 它 1.12 s 完成解析,但丢掉了 52 个由 peer 安装的包,也丢掉了构成断言大部分覆盖面的 peer 边。
## Consequences
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
@@ -9,8 +9,8 @@
en: 5853599207f6b3bb
zh: d58b5e8e19d93a4f
/agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/decision:
en: 721ea003ebaef8c7
zh: e17be423bc4c7568
en: f2f641fe1f271edf
zh: 321df1f4ea9e783d
/agent-note-subagent-teardown-tests-inherit-their-execution-lane-budgets/alternatives-considered:
en: 3ad30b2574818a61
zh: d323adddd5065ae4
@@ -12,7 +12,7 @@ The Windows coverage run (run 34085536250, job 101628739668) reports two teardow
The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged.
The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget, which `vitest.config.ts` applies to every inline project from `DSH_COVERAGE_TEST_TIMEOUT_MS` ([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) owns why a CLI flag cannot carry it). Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect.
@@ -12,7 +12,7 @@ Windows 覆盖率运行 (run 34085536250, job 101628739668) 为测试和钩子
[ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。
[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算;`vitest.config.ts` 从 `DSH_COVERAGE_TEST_TIMEOUT_MS` 把该预算施加到每个内联 project([`coverageTestTimeoutOptions`](../../../../scripts/coverage-partitions.ts) 负责说明 CLI 标志为何无法承载它)。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
[原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。
@@ -9,8 +9,8 @@
en: 3f8862080b8c3c89
zh: 0e83c485f02c10f1
/agent-note-user-patch-tests-control-filesystem-event-delivery/decision:
en: a49b4a6e76725b7e
zh: 9813f60c40a19f83
en: 601a3437a4332b69
zh: 2b6bcc4636314c31
/agent-note-user-patch-tests-control-filesystem-event-delivery/alternatives-considered:
en: 5d559ee291e2e15e
zh: 828e7def587d71e8
@@ -12,7 +12,7 @@ The macOS Sandbox run (run 34238200206, job 102101292119) times out while waitin
The [user-patch test](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) writes real patch files and delivers their add, change, and unlink events through a Chokidar watcher without native watch handles. App-boot watcher registration, refresh serialization, Include recomposition, plugin activation, failure reporting, and recovery remain real; plugin rollback is absent under the [Loader policy](../simplification/2026-09-09-nontransactional-loader.md). The fixture restores its watcher factory and disposes the Context even when setup fails before the local cleanup block.
The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. The refresh test does not establish operating-system delivery guarantees.
The separate [watcher tests](../../../../packages/boot/hmr/tests/watch-config.spec.ts) own native notification delivery, including add/change/unlink, initially absent parents, and filesystem aliases. Before their first write they close a second `fs.watch` directory handle, which on darwin blocks until libuv has rebuilt the FSEvents stream that Chokidar readiness does not wait for, and their polling deadline is the case's effective budget. The refresh test does not establish operating-system delivery guarantees.
## Alternatives considered
@@ -12,7 +12,7 @@ macOS Sandbox 运行 (run 34238200206, job 102101292119) 在等待首次用户 p
[用户 patch 测试](../../../../packages/boot/app-boot/tests/user-patches.spec.ts) 写入真实 patch 文件,并通过不持有原生监听句柄的 Chokidar watcher 投递 add、change 和 unlink 事件。应用启动监视器注册、刷新串行化、Include 重组、插件激活、失败报告与恢复仍使用真实实现;[Loader 策略](../simplification/2026-09-09-nontransactional-loader.zh.md) 不提供插件回滚。即使初始化在进入局部清理块前失败,夹具也会恢复 watcher 工厂并销毁 Context。
独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。刷新测试不验证操作系统的投递保证。
独立的 [监视器测试](../../../../packages/boot/hmr/tests/watch-config.spec.ts) 负责原生通知投递,包括 add/change/unlink、初始不存在的父目录和文件系统别名。它们在首次写入前关闭第二个 `fs.watch` 目录句柄,该操作在 darwin 上会阻塞到 libuv 重建 FSEvents 流为止(Chokidar 的就绪信号并不等待该流建立);它们的轮询截止时间取用例的有效预算。刷新测试不验证操作系统的投递保证。
## 考虑过的替代方案
@@ -9,14 +9,14 @@
en: 270b015afc8de8ac
zh: 65aeb1e270d19a27
/agent-note-windows-lane-observations-wait-on-the-state-they-read/decision:
en: 479cb25421102304
zh: 9e23396728001e2c
en: 55a0bcab15f38620
zh: 677545442a19c7dc
/agent-note-windows-lane-observations-wait-on-the-state-they-read/alternatives-considered:
en: 00e59c561085e081
zh: 4f6ac4dfc3259f58
/agent-note-windows-lane-observations-wait-on-the-state-they-read/consequences:
en: 13db32f9da19d87b
zh: 48081fc7035dea45
en: 46517270dd3b22b1
zh: 94e300066237cece
/agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred:
en: 18dc81f98375fb04
zh: e30413cd3d201b80
en: 8e182739fdf90baf
zh: 5f93f7b7d9518764
@@ -16,7 +16,7 @@ Two self-hosted Windows lane cases observed state through a wall-clock window in
`observingChild` stays in flight until the test has read the run record, so the read cannot race the run's end, and its bounded wait starts at the mocked launcher's hand-over instead of at test start: the recorded run is one atomic write that follows the launch, so the bound only has to cover that write. `RECORD_WAIT_MS` (2 s) is deliberately below the case budget, so a record that never appears reports as the assertion that names it rather than as a runner timeout. Both the repair-install case and the single-run case hand the child over through the same `spawned` hook.
The loader-composition settle-reason assertion carries `JSON.stringify(settleReasons)` as its failure message. The threshold and the assertions are unchanged.
The loader-composition settle-reason assertions carry the recorded reasons and a per-send readiness timeline in their failure message: every decoded pty chunk with the sanitizer's marker verdict and prompt tail, every foreground poll result, every input write, each send's settle reason and elapsed time, the session's prompt evidence replayed from those chunks, and the host, shell, PSReadLine, and console-host versions. `onTestFailed` prints the same timeline when the runner's budget ends the case before the assertions. The threshold and the assertions are unchanged.
## Alternatives considered
@@ -30,6 +30,10 @@ The loader-composition settle-reason assertion carries `JSON.stringify(settleRea
Both plugin-manager record cases now observe the record while the run is in flight on a loaded runner, and a genuinely missing record still fails the same assertion. The bounded wait costs 2 s only when the operation never writes the record.
[The task-detail close note](2026-09-28-detail-close-waits-on-observed-state.md) applies the same rule to a web client case, where the sampled state lands in a passive effect one commit after the list the case already awaited.
## Deferred
The loader-composition failure of 2026-09-25/26 is an environment-sensitive readiness window, not a product logic error, and the product now owns a documented tolerance for it: the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md). On Windows `isStdinWaiting` returns false and `foregroundPgid` returns the shell pid, so `stdin_read` can only come from the controlled-prompt tail. A native Windows probe (Windows 11 ARM64 guest, 8 vCPU, with the readiness probe enabled) shows the fast path is deterministic when delivery is normal: every prompt render is the OSC marker alone in one pty chunk followed by a five-byte `dsh> ` chunk 2-28 ms later (56 renders), nothing printable follows any prompt, the output carries no terminal queries, and 21 runs (baseline, six CPU burners, emulated x64, four concurrent pty-heavy instances, and Windows PowerShell 5.1 as the shell) settled all seven sends on the prompt, six `stdin_read` plus the `exit` command's `session_exit`. Withholding each session's prompt tail for four seconds reproduces the reported failure exactly: `expected 4 to be greater than or equal to 6` with `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`, the degraded settles carrying `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3301/3313 ms (exactly the configured bound), 22.1/21.9 s against 17.5/20.9 s in CI, and every output assertion still passing. The CI trigger itself was not reproduced locally, so this case keeps its assertion and supplies `promptTailGraceMs` in its composition: it measures the controlled-prompt path rather than the host's delivery timing, and it still fails when no prompt marker appears at all.
The loader-composition failure on the self-hosted Windows lane (2026-09-25..27, issue #2487) remains open, and its mechanism is not a stalled prompt tail. The master run before the [prompt tail grace](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) landed (run 36309006133, 2026-09-27 09:19Z) and the first run carrying it with `promptTailGraceMs: 5000` in this composition (run 36326153388, 14:30Z, reasons `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`) both took 20.8 s, so the degraded sends settled at the plain `idleSilenceMs + handoffGraceMs` bound: either no marker was seen, or a marker was seen and its tail was invalidated by later printable text — the two states the tolerance does not cover. The foreground comparison is not a candidate on Windows: `WindowsProcessInspector.foregroundPgid` returns the shell pid unconditionally and `isStdinWaiting` returns false, so `shellPgid` always matches and `stdin_read` can only come from the controlled-prompt tail. The composition therefore keeps the field at its product default of `0`, and the failure message's timeline names the state directly the next time the lane fails; the 2026-09-27 12:51Z run (36320394583) instead hit the 120 s case budget before any assertion, which the same print covers.
Native evidence with pwsh 7.6.6 and PSReadLine 2.4.5 on Windows 11 25H2 (conhost 10.0.26100.1; the probes the tail-grace note cites ran under Windows PowerShell 5.1, the guest's only preinstalled shell): the case settles every send on the prompt at baseline, under eight CPU burners plus disk churn, under emulated x64 Node, under `vitest run --coverage --maxWorkers=1`, and with a 12-row viewport that scrolls from the second command, each prompt arriving as one in-order marker chunk followed by the five-byte tail. With an 8-row, 60-column viewport ConPTY re-emits the stored `133;D` mark inside repaint frames — five markers in one send, one followed by repainted row text (`\x07\e[Hdsh> Write-Output …`) — which leaves a tail that is not a prefix of the controlled prompt; on that console host the real prompt's marker still arrived last and the send settled `stdin_read`. That repaint path is the leading hypothesis for the lane, whose Windows build and console-host version are unknown (the job logs carry neither and the pool has no dispatchable job); the failure message records `os.release()`, `os.version()`, the pwsh and PSReadLine versions, and `conhost.exe`'s file version, and `dsh-subprocess-local` spawns node-pty without `useConptyDll`, so the system console host is the one in play. The pid `0` those probes also exposed on Windows is a separate defect, tracked by issue #5297.
@@ -16,7 +16,7 @@ Status: implemented
`observingChild` 会保持该 run 在进行中,直到测试读到 run 记录,因此读取不可能与 run 结束竞争;其有界等待从被 mock 的启动器交接处开始,而不是从用例开始处开始:被记录的 run 只是紧随启动之后的一次原子写入,因此等待上界只需覆盖这次写入。`RECORD_WAIT_MS`(2 秒)刻意低于用例预算,于是一条始终没有出现的记录会以指名它的断言报告,而不是以 runner 超时报告。修复安装用例与单 run 用例都通过同一个 `spawned` 钩子交接子进程。
loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作为失败信息。阈值与断言本身不变。
loader-composition 的结算层级断言把记录到的结算原因与一份逐 send 的就绪时间线放进失败信息:每个解码后的 pty 分块及 sanitizer 对它的标记判定与提示符尾部、每次前台轮询的结果、每次输入写入、每次 send 的结算原因与耗时、由这些分块回放出的会话提示符证据,以及宿主机、shell、PSReadLine 与控制台宿主的版本。当 runner 预算在断言之前结束用例时,`onTestFailed` 打印同一份时间线。阈值与断言本身不变。
## Alternatives considered
@@ -30,6 +30,10 @@ loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作
在负载较高的 runner 上,两个插件管理器记录用例现在都能在该 run 仍在进行时观测到记录,而真正缺失的记录仍会以同一条断言失败。只有在操作始终不写记录时,这段有界等待才会付出 2 秒。
[任务详情关闭断言](2026-09-28-detail-close-waits-on-observed-state.zh.md) 把同一条规则用在 web 客户端用例上:被采样的状态落在该用例已经等待过的那个列表之后一个 commit 的被动 effect 里。
## Deferred
loader-composition 于 2026-09-25/26 的失败是一个对投递时序敏感的就绪窗口,而不是 product 逻辑错误;product 现在为它拥有一个文档化的容忍度:[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)。在 Windows 上 `isStdinWaiting` 返回 false、`foregroundPgid` 返回 shell 自身的 pid,因此 `stdin_read` 只能来自受控提示符尾部。原生 Windows 探测(Windows 11 ARM64 guest,8 vCPU,启用就绪探测)表明只要投递正常,快路径就是确定的:每次提示符渲染都是 OSC 标记独占一个 pty 分块,紧随其后的下一个分块是 5 字节的 `dsh> `,间隔 2–28 毫秒(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询;21 次运行(基线、6 个 CPU burner、模拟 x64、4 个并发 pty 实例、以及以 Windows PowerShell 5.1 作为 shell)全部七次 send 都按提示符结算——六次 `stdin_read` 加 `exit` 命令的 `session_exit`。把每个 session 的提示符尾部扣留 4 秒可以精确复现上报的失败:`expected 4 to be greater than or equal to 6`,`settleReasons` 为 `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`,退化的结算带着 `promptSeen` 为 true、`promptTextSeen` 为 false、空尾部与 `idleFor` 3301/3313 毫秒(正是配置的上界),耗时 22.1/21.9 秒对照 CI 的 17.5/20.9 秒,且所有输出断言仍然通过。CI 的触发条件本身未能在本地复现,因此该用例保留其断言并在组合中提供 `promptTailGraceMs`:它度量受控提示符路径而不是宿主机的投递时序,且在完全没有提示符标记时仍然失败。
自托管 Windows lane 上 loader-composition 的失败(2026-09-25..27,issue #2487)仍未关闭,其机制不是提示符尾部停摆。[提示符尾部宽限](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)合入前的 master run(run 36309006133,2026-09-27 09:19Z)与首个带着该字段、且本组合设置了 `promptTailGraceMs: 5000` 的 run(run 36326153388,14:30Z,结算原因为 `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`)都耗时 20.8 秒,因此退化的 send 是按普通的 `idleSilenceMs + handoffGraceMs` 上界结算的:要么根本没看到标记,要么看到了标记但其尾部被随后的可打印文本失效——这正是该宽限不覆盖的两种状态。前台比较在 Windows 上不是候选:`WindowsProcessInspector.foregroundPgid` 无条件返回 shell pid、`isStdinWaiting` 返回 false,因此 `shellPgid` 总是匹配,`stdin_read` 只能来自受控提示符尾部。于是该组合把字段保持在 product 默认值 `0`,下一次该 lane 失败时,失败信息里的时间线会直接点名所处的状态;2026-09-27 12:51Z 的 run(36320394583)则是在任何断言之前就撞上 120 秒的用例预算,同一份打印也覆盖这条路径。
在 Windows 11 25H2(conhost 10.0.26100.1)上以 pwsh 7.6.6 与 PSReadLine 2.4.5 取得的原生证据(尾部宽限笔记所引用的探测运行在 Windows PowerShell 5.1 之下,那是 guest 唯一预装的 shell):基线、8 个 CPU burner 加磁盘反复读写、模拟 x64 Node、`vitest run --coverage --maxWorkers=1`、以及从第二条命令起就滚动的 12 行视口下,用例的每次 send 都按提示符结算,每个提示符都是一个按序到达的标记分块紧跟 5 字节尾部。在 8 行 60 列的视口下,ConPTY 会在重绘帧内重新发出已存储的 `133;D` 标记——一次 send 内出现五个标记,其中一个后面紧跟重绘的行文本(`\x07\e[Hdsh> Write-Output …`)——这使尾部不再是受控提示符的前缀;在该控制台宿主上,真正提示符的标记仍然最后到达,因此该 send 仍按 `stdin_read` 结算。这条重绘路径是该 lane 的首要假设,而该 lane 的 Windows 版本与控制台宿主版本未知(job 日志两者都没有,该 pool 也没有可手动触发的 job);失败信息会记录 `os.release()`、`os.version()`、pwsh 与 PSReadLine 版本以及 `conhost.exe` 的文件版本,而 `dsh-subprocess-local` 启动 node-pty 时未设置 `useConptyDll`,因此起作用的是系统控制台宿主。这些探测同时暴露的 Windows 上 pid 为 `0` 的问题是另一个缺陷,由 issue #5297 跟踪。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-27-oxlint-contract-spawn-case-budgets.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling:
en: 152f1e58efbf506e
zh: 889504ebfe5783ca
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/problem:
en: 08d6430c0b4e868f
zh: b98c15541bcea4ee
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/decision:
en: 34bd113b8c9228fb
zh: 99843bf33b703b5e
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/alternatives-considered:
en: 2b317170890802f7
zh: 2a5221dcfa8b9116
/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/consequences:
en: e484a10680ca20b8
zh: 8fd9fcf13e3dbcd7
@@ -0,0 +1,27 @@
# Agent Note: Oxlint contract cases that spawn a process carry a 90 s ceiling
Status: implemented
English | [中文](2026-09-27-oxlint-contract-spawn-case-budgets.zh.md)
## Problem
Twelve of the fourteen cases in [`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) spawn a real child process: `runOxlint` starts the oxlint CLI, and `runRepositoryOxlint` boots [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) under tsx, which runs oxlint and, after `--fix`, runs it once more. The root `vitest.config.ts` sets no `testTimeout`, so a case without its own budget inherits Vitest's 5000 ms default; the coverage lanes raise it through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` and the Windows native lane passes `--testTimeout 90000`, while the `unit tests (darwin parity, macos-latest)` job in `sandbox.yml` and a developer's `pnpm run test` run plain. Seven cases (five `it` declarations, one of them a three-way `it.each`) carried the explicit `90_000` that #3115 aligned the contended Windows spawn budgets to; the other seven ran on the default, and five of those spawn. Two runner classes exposed it: the self-hosted Windows pool, where process creation spikes to several seconds (#2581), and the hosted macos-latest runner of Sandbox run 36332053320, which ran the whole file 2.2× slower than the passing run 36320394290 and timed out `prints only the final diagnostics when a fix retry still fails` at 6309 ms, a case that took 1122 ms on the passing run. `Test timed out in 5000ms` names neither a contract violation nor the spawn that overran.
## Decision
Every case in the file that spawns oxlint or the repository lint entrypoint carries the same explicit per-case `90_000` ceiling. The two configuration-only cases, `keeps the complete stylistic contract in Oxlint` and `keeps repository lint workflows Oxlint-only`, spawn nothing and keep the default. The value is a completion ceiling for a hang, not a latency target: the slowest case on the slow darwin runner took 9507 ms, and the file under six-fold CPU oversubscription on a 12-core host peaked at 20767 ms, both under a quarter of the ceiling. Assertions, the absence of retries, and Vitest's global `testTimeout` are unchanged.
## Alternatives considered
**A `describe`-level `{ timeout: 90_000 }`.** Rejected: case-level values already bound seven cases and take precedence over a `describe` value, so a suite-level number would govern only the remaining seven, including the two configuration-only cases that spawn nothing, and the file would carry two budget styles. Per-case values keep one style, one place to read each case's ceiling, and the default on the cases that spawn nothing. Either form overrides the lane's `--testTimeout` rather than yielding to it (#2677). The archived [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) and [Lefthook budget](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md) notes chose the `describe` form for files whose cases carried no constants of their own, because a case added later without an allowance inherits the default; this file already carried per-case constants, so that risk is accepted here and named under Consequences.
**Passing `--testTimeout 90000` to the darwin parity job and letting the cases inherit the lane budget**, the form the [subagent teardown budgets](2026-09-07-subagent-teardown-test-budgets.md) note prefers for suites bound by process creation. Rejected: the cost is the file's own tsx boot plus two oxlint runs, not a darwin property, so a developer's plain `pnpm run test` would keep the default; the parity job exists to run the same command developers run; and seven cases already carry constants, so a lane flag would split the file's budget between two sources.
**Raising Vitest's global `testTimeout`.** Rejected: the whole unit inventory runs on the default; widening it to absorb one file's process spawns removes the hang detection the default provides everywhere else.
**A tighter value sized to the observed 6.3 s.** Rejected: the Windows contention fires as multi-second spikes that rotated across cases under 15–30 s budgets before the 90 s alignment; a smaller number moves the flake rather than removing it.
## Consequences
A contract violation still fails through its own assertion well inside the ceiling; the five cases bounded here settle in 0.5–1.7 s on an idle host and no spawning case in the file exceeded 3.8 s there, so only a genuine hang waits the full 90 s. The Windows self-hosted lane, the Linux coverage lanes, the darwin parity job, and a local `pnpm run test` read the same ceiling for this file regardless of `DSH_COVERAGE_TEST_TIMEOUT_MS`. A spawning case added later without its own `90_000` runs on the default until the file's inventory is checked. Under 72 busy-loop processes on a 12-core host the unbounded fix-retry case reproduced `Test timed out in 5000ms` at 7084 ms before the change and passes with the ceiling after it.
@@ -0,0 +1,27 @@
# Agent Note: 会 spawn 进程的 Oxlint 契约用例携带 90 s 上限
Status: implemented
[English](2026-09-27-oxlint-contract-spawn-case-budgets.md) | 中文
## 问题
[`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) 的 14 个用例中有 12 个会启动真实子进程:`runOxlint` 启动 oxlint CLI,`runRepositoryOxlint` 在 tsx 下引导 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts),后者运行 oxlint,并在 `--fix` 之后再运行一次。根 `vitest.config.ts` 没有设置 `testTimeout`,因此没有自带预算的用例继承 Vitest 的 5000 ms 默认值;coverage 通道通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 抬高它,Windows 原生通道传入 `--testTimeout 90000`,而 `sandbox.yml` 中的 `unit tests (darwin parity, macos-latest)` job 与开发者本机的 `pnpm run test` 都直接运行。7 个用例(5 个 `it` 声明,其中一个是三路 `it.each`)携带 #3115 把有争抢的 Windows spawn 预算对齐到的显式 `90_000`;另外 7 个跑在默认值上,其中 5 个会 spawn。两类 runner 暴露了这一点:自托管 Windows 池,其进程创建会出现数秒尖峰(#2581);以及 Sandbox run 36332053320 的托管 macos-latest runner,它跑整个文件比通过的 run 36320394290 慢 2.2 倍,并让 `prints only the final diagnostics when a fix retry still fails` 在 6309 ms 时超时,而该用例在通过的 run 中耗时 1122 ms。`Test timed out in 5000ms` 既不指出契约违规,也不指出超时的那次 spawn。
## 决策
文件中每个会 spawn oxlint 或仓库 lint 入口的用例都携带同一个显式的用例级 `90_000` 上限。两个只检查配置的用例 `keeps the complete stylistic contract in Oxlint` 与 `keeps repository lint workflows Oxlint-only` 不 spawn 任何进程,保持默认值。该值是针对挂起的完成上限,不是延迟目标:慢 darwin runner 上最慢的用例耗时 9507 ms,12 核宿主机上六倍 CPU 超订下整个文件的峰值为 20767 ms,两者都不到上限的四分之一。断言、不加重试、以及 Vitest 全局 `testTimeout` 均不变。
## 曾考虑的替代方案
**`describe` 级 `{ timeout: 90_000 }`。** 拒绝:用例级取值已经约束 7 个用例并且优先于 `describe` 级取值,因此套件级数字只会作用于其余 7 个用例,包括两个不 spawn 的纯配置用例,而且文件会同时带两种预算风格。用例级取值保持一种风格,每个用例的上限只在一处可读,不 spawn 的用例保持默认值。两种形式都覆盖通道的 `--testTimeout` 而不是让位于它(#2677)。已归档的 [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) 与 [Lefthook 预算](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md)两篇 Note 为用例本身不带常量的文件选择了 `describe` 形式,理由是之后新增而未带余量的用例会继承默认值;本文件已经带有用例级常量,因此这里接受该风险并在「后果」中点名。
**给 darwin parity job 传入 `--testTimeout 90000`,让用例继承通道预算**,这是[子代理清理预算](2026-09-07-subagent-teardown-test-budgets.zh.md)一篇对进程创建受限套件偏好的形式。拒绝:成本来自本文件自身的 tsx 引导加两次 oxlint 运行,不是 darwin 的属性,因此开发者直接运行的 `pnpm run test` 仍会停留在默认值;parity job 的存在意义就是运行与开发者相同的命令;而且 7 个用例已带常量,通道旗标会把本文件的预算拆到两个来源。
**抬高 Vitest 全局 `testTimeout`。** 拒绝:整个单元测试清单都跑在默认值上;为吸收一个文件的进程 spawn 而放宽它,会移除默认值在其他所有地方提供的挂起检测。
**按观测到的 6.3 s 收紧取值。** 拒绝:Windows 争抢表现为数秒尖峰,在对齐到 90 s 之前曾在 15–30 s 预算下于各用例间轮转出现;更小的数字只是移动 flake 而不是消除它。
## 后果
契约违规仍通过其自身断言在上限之内早早失败;本次加边界的 5 个用例在空载宿主机上 0.5–1.7 s 内结算,文件中任何会 spawn 的用例在那里都不超过 3.8 s,因此只有真正的挂起才会等满 90 s。Windows 自托管通道、Linux coverage 通道、darwin parity job 与本机 `pnpm run test` 对本文件读到同一个上限,与 `DSH_COVERAGE_TEST_TIMEOUT_MS` 无关。之后新增而未自带 `90_000` 的 spawn 用例会跑在默认值上,直到有人核对本文件的清单。在 12 核宿主机上以 72 个忙循环进程加载时,原本无边界的 fix-retry 用例在改动前于 7084 ms 复现 `Test timed out in 5000ms`,改动后在上限内通过。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-27-plugin-install-interceptor-lifetime.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md
/agent-note-keep-plugin-install-interception-for-the-page-lifetime:
en: 2f732d10bfaf9f5b
zh: fff9f6cf8bcd86a7
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/problem:
en: 71b72bb5b13530bb
zh: b78170e3ac09cccf
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/decision:
en: 45d689dced9dad9f
zh: cb89fbbb7937ca8e
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/alternatives-considered:
en: 1027b59d76cd3ff7
zh: c4c60fb406acb1db
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/verification:
en: 0e7fbf7fbb47b07c
zh: 9b52991ad8b355ca
/agent-note-keep-plugin-install-interception-for-the-page-lifetime/consequences:
en: 3583e26c8ec33e03
zh: 6080722751cf15e6
@@ -0,0 +1,35 @@
# Agent Note: Keep plugin-install interception for the page lifetime
Status: implemented
English | [中文](2026-09-27-plugin-install-interceptor-lifetime.zh.md)
## Problem
The [plugin-install scenario](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) can leave a directory read pending after cancelling an installation. The Host emits invalidations that trigger background reads while the scenario removes its Playwright routes between installation phases. A stalled read keeps the installed package out of the list, so enabling it closes the dialog but never exposes the highlighted card.
## Decision
The scenario registers its `installBundle` and `cancelInstall` interceptors before navigation and retains them until the browser closes. Each interceptor calls a typed handler that the scenario replaces when it changes phases. The delivery and cancellation barriers retain their ordering, and `activeReplySettled` joins the lost-response handler before the next installation starts. Ordinary requests still reach the real Host.
The [input-state decision](2026-09-27-web-lane-assertions-name-their-input-state.md) owns the separate wait for the highlighted card's status to become `running`. Keeping interception active lets the directory read complete; polling its result still establishes the state the golden records.
## Alternatives considered
**Remove routes with `unrouteAll({ behavior: 'wait' })`.** Waiting for matched handlers does not settle unrelated browser requests. A captured Chromium protocol trace shows `Fetch.disable` immediately followed by a `pluginManager/listPlugins` request with no response or failure before teardown. The Host's inventory and plugin methods still answer direct calls.
**Advance the browser clock or extend the locator timeout.** Advancing the paused clock does not release the stalled request. The 2.4-second highlight expiry is not responsible for a package that never reaches the directory.
**Add a general route-switching helper.** Two endpoint handlers in one scenario do not need another fixture API. Their local delegates preserve the existing cancellation and lost-response cases without changing the browser's interception configuration.
## Verification
The unchanged scenario passes alone and fails at the same highlighted-card locator in one of four concurrent independent processes. Instrumented failures place the unanswered directory request inside the second `unrouteAll` interval; advancing the page clock leaves it unanswered, while direct Host reads complete. The protocol trace identifies the interception transition independently of the DOM assertion.
`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` passes in four concurrent independent processes (1/1 each). One protocol capture observes one `Fetch.enable`, no `Fetch.disable`, and responses for all 33 directory requests. The existing goldens cover cancellation before delivery, cancellation of the running child, manifest and lockfile restoration, retry, enabled-card status, both themes and motion preferences, highlight expiry, and lost-result recovery.
## Consequences
The browser retains two narrowly matched interceptors for its lifetime. The scenario controls their behavior without interrupting concurrent directory reads, and browser closure owns their cleanup. Product code, goldens, test deadlines, and CI worker counts are unchanged.
Live-page `unrouteAll` calls also remain in [sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts), [queue-image](../../../../apps/web/tests/queue-image.e2e.ts), [subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts), and [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts). These are candidates for separate overlap investigations; this scenario's reproduction does not establish the cause of failures in those tests.
@@ -0,0 +1,35 @@
# Agent Note: 让插件安装拦截器持续到页面关闭
Status: implemented
[English](2026-09-27-plugin-install-interceptor-lifetime.md) | 中文
## Problem
[插件安装场景](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在取消安装后,可能留下一次未结束的目录读取。场景在安装阶段之间移除 Playwright 路由时,Host 发出的失效通知仍会触发后台读取。停滞的读取使已安装的包无法进入列表,因此启用操作虽然关闭了对话框,却始终不显示高亮卡片。
## Decision
场景在导航前注册 `installBundle` 和 `cancelInstall` 拦截器,并保留到浏览器关闭。每个拦截器调用一个带类型的处理函数,场景切换阶段时替换该函数。请求交付和取消屏障保持原有顺序,`activeReplySettled` 在下一次安装开始前等待丢失响应的处理函数结束。普通请求仍然到达真实 Host。
[输入状态决策](2026-09-27-web-lane-assertions-name-their-input-state.zh.md) 负责另一项等待:高亮卡片的状态变为 `running`。持续启用拦截使目录读取能够完成;轮询其结果仍负责确立预期输出记录的状态。
## Alternatives considered
**用 `unrouteAll({ behavior: 'wait' })` 移除路由。** 等待匹配的处理函数结束并不意味着其他浏览器请求已结束。捕获的 Chromium 协议记录显示,`Fetch.disable` 后紧接着发出 `pluginManager/listPlugins` 请求,直到清理前都没有响应或失败事件。直接调用 Host 的目录和插件方法仍能得到答复。
**推进浏览器时钟或延长 locator 超时。** 推进已暂停的时钟不能释放停滞的请求。包始终未进入目录,不是 2.4 秒的高亮到期计时器造成的。
**增加通用路由切换辅助函数。** 一个场景中的两个端点处理函数不需要另一层 fixture(测试前置数据)API。局部委托函数保留了现有取消和丢失响应场景,也不改变浏览器的拦截配置。
## Verification
未修改的场景单独运行通过,四个并发独立进程中有一个在同一高亮卡片 locator 处失败。插桩失败将未获响应的目录请求定位到第二次 `unrouteAll` 的执行区间内;推进页面时钟后请求仍未获响应,而直接读取 Host 可以完成。协议记录独立于 DOM 断言确认了拦截状态的切换。
`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` 在四个并发独立进程中全部通过,每个进程均为 1/1。一次协议记录观察到一次 `Fetch.enable`、零次 `Fetch.disable`,且全部 33 次目录请求均收到响应。现有预期输出覆盖取消先于请求交付、取消运行中的子进程、恢复 manifest(元数据清单)与 lockfile、重试、已启用卡片状态、两种主题与动画偏好、高亮到期,以及丢失结果恢复。
## Consequences
浏览器在整个生命周期内保留两个精确匹配的拦截器。场景控制其行为时不会中断并发目录读取,浏览器关闭负责清理它们。产品代码、预期输出、测试超时和 CI worker 数保持不变。
[sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts)、[queue-image](../../../../apps/web/tests/queue-image.e2e.ts)、[subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts) 和 [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts) 中也仍有页面存续期间的 `unrouteAll` 调用。这些位置可分别调查请求重叠;本场景的复现并不能确定那些测试失败的原因。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions:
en: 015053d7e4cc8b74
zh: 8afd22975bcb9814
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/problem:
en: 27ce4e01ca063f29
zh: cd8c872750c725bc
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/decision:
en: 49b7f008e97f05cb
zh: 00571e867ba82057
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/alternatives-considered:
en: 51c1315b1d424915
zh: 2f2f537cfdde2469
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/verification:
en: 316dbc106810d68f
zh: 8771fd22b885e4ab
/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/consequences:
en: 03eedcc3cfdad180
zh: 8d4843e01448fe83
@@ -0,0 +1,37 @@
# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions
Status: implemented
English | [中文](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md)
## Problem
[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) opens `meeting.xlsx`, clicks the sheet overlay at `{ x: 60, y: 40 }` to select `A1`, then widens and narrows the sidebar four times and asserts after each reflow that the name box still reads `A1`. Ten of the eleven `CI master` runs of the serial self-hosted Linux lane between 2026-09-24 and 2026-09-27 failed one or both locale cases of that scenario with `expected 'B1' to be 'A1'` at the first reflow, and hosted `node 24 / snapshots and artifacts` runs failed the same way (runs 36110572677 and 36213089675). The failure is not locale-bound: en-US failed alone in four master runs, both locales in three, zh-CN alone in three. Polling the read (#5243) changed nothing because `B1` is the committed selection, not a stale sample.
`A1` is the workbook's initial selection, so the polls that follow the click are satisfied before the click commits and never verify what the click selected. FortuneSheet's `SheetOverlay` handles the press as `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))`, and `handleCellAreaMouseDown` resolves the cell from `cellArea.getBoundingClientRect()` and the event's page coordinates at the moment the updater runs. React runs `useState` updaters during render and runs them again when the hook queue holds a lower-priority update ahead of them; the freshly mounted workbook still has such updates pending, and Playwright precedes the press with a `mousemove` whose FortuneSheet handler is a continuous-priority update on the same state. An instrumented verbatim copy of the scenario on the Blacksmith runner recorded four executions of `handleCellAreaMouseDown` for the one click: two before the click returned reading `left=969.5`, and two after the scenario had already set the panel to `1000px`, reading `left=725.5`. The stored pointer x of 985 then resolves to 259.5 inside the grid, and because the meeting sheet has two columns `colLocation` clamps it to the last one: `B1`. On a machine that commits the workbook's initial selection normalization before the click, the same point lies on A1's own selection box, whose `onMouseDown` stops propagation; the click then selects nothing, no geometry-bound updater is queued, and the scenario passes without having tested the click.
## Decision
The meeting-sheet step selects by keyboard. The scenario focuses the sheet overlay (`tabindex="-1"`), asserts the focus landed, presses `ArrowRight` and asserts `B1` with an empty formula bar, then presses `ArrowLeft` and asserts `A1` with `会议纪要`, and only then starts the reflow loop. `handleArrowKey` → `moveHighlightCell` computes the target from committed state — the current selection and `visibledatacolumn` — so a re-executed updater yields the same cell whatever the grid's geometry is by then, and the two moves make the interaction observable because `B1` differs from the initial selection. The reflow loop, its `A1` assertion, the sheet-tab scroll-control counts, and the canvas identity check are unchanged.
The budget-sheet click at the start of the scenario stays a pointer click: it targets a cell other than the initial selection, its result is verified by the `46281` formula read, and keyboard, clipboard, and layout reads separate it from the first reflow by far more than one deferred render. The `values.csv` click followed by a narrowing stays too: a re-execution against the narrowed grid resolves a negative x, which `colLocation` clamps to column A, the same cell.
## Alternatives considered
**Poll the name box after each reflow.** #5243 did this. The re-executed updater's result is the final committed state, so the poll observes `B1` for its whole budget; there is no later state to wait for.
**Wait for the selection box to render before clicking.** That restores the passing path in which the click lands on A1's own drag handle and selects nothing, so the assertions would verify the initial selection rather than an interaction. Clicking a cell other than the initial selection instead keeps the geometry-bound updater and its re-execution window open across the reflow that follows.
**Wait for the renderer to go idle before the first reflow.** No product state names "React has no re-execution pending"; an idle or fixed wait sized to the runner is the flake-masking wait [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) rejects.
**Extend the `@fortune-sheet/react` patch to resolve the cell outside the updater.** `handleCellAreaMouseDown` lives in `@fortune-sheet/core`, and the window a user would need to hit is a pointer press and a sidebar reflow inside one deferred render. It stays an upstream concern; the product change is out of this test fix.
## Verification
The instrumented verbatim copy of the shipped scenario, run on the hosted Linux lane of PR #5291 while it was a draft, reproduced `expected 'B1' to be 'A1'` at the first reflow in 7 of 38 runs across three lane runs (2 of 6, 1 of 6, 4 of 20). Every failure recorded `mousedown` targeting `#luckysheet-sheettable_0` rather than the drag handle and the `B1` commit preceding the deferred `cellInput.focus()` that only the press updater schedules; the five failures in the two lane runs that also hooked the cell area's `getBoundingClientRect` all carried the four-execution signature above. The keyboard-driven copy ran in those same two lane runs and passed 26 of 26. Locally, `Emulation.setCPUThrottlingRate` at 20× makes the click reach the cell area and records three executions of `handleCellAreaMouseDown` per click; the keyboard-driven copy passes 6 of 6 under that throttle and 6 of 6 with React's scheduler wake-ups deferred by 200 ms.
`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` passed both locale cases in 10 of 10 local repetitions, and the whole file passed its 6 cases in 2 of 3 local runs; the third failed the unrelated `Coding Tools` switch poll at `:849` while documentation gates ran concurrently, with the failure screenshot showing the switch on.
## Consequences
The scenario verifies a keyboard-made selection surviving sidebar reflows and no longer depends on where React's deferred render lands relative to the reflow. A pointer click in this lane remains valid when its target differs from the current selection and its effect is asserted before any layout change, or when a re-execution against the changed layout provably resolves the same cell; a click whose result feeds a reflow assertion within a few round trips does not. FortuneSheet's geometry-bound updater is unchanged, so a real pointer press followed within one deferred render by a sidebar reflow can still move the selection in the product.
@@ -0,0 +1,37 @@
# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions
Status: implemented
[English](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md) | 中文
## Problem
[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) 打开 `meeting.xlsx`,在表格 overlay 的 `{ x: 60, y: 40 }` 处点击以选中 `A1`,随后把侧栏加宽、收窄各两次,并在每次重排后断言名称框仍显示 `A1`。2026-09-24 到 2026-09-27 之间 `CI master` 的 serial 自托管 Linux lane 的 11 次运行中有 10 次在第一次重排处以 `expected 'B1' to be 'A1'` 失败该场景的一个或两个 locale 用例,托管的 `node 24 / snapshots and artifacts` 也以同样方式失败(run 36110572677 与 36213089675)。失败与 locale 无关:四次 master 运行只有 en-US 失败,三次两者都失败,三次只有 zh-CN 失败。把读取改为轮询(#5243)没有作用,因为 `B1` 是已提交的选区,不是过期采样。
`A1` 是工作簿的初始选区,所以点击之后的两个轮询在点击提交前就已满足,从未验证点击选中了什么。FortuneSheet 的 `SheetOverlay` 以 `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))` 处理按下,而 `handleCellAreaMouseDown` 在 updater 执行的那一刻用 `cellArea.getBoundingClientRect()` 与事件的页面坐标解析目标单元格。React 在渲染期间运行 `useState` 的 updater,并且当 hook 队列里有更低优先级的更新排在它前面时会再次运行它;刚挂载的工作簿仍有这类更新待处理,而 Playwright 在按下之前先派发一次 `mousemove`,其 FortuneSheet 处理器是同一状态上的 continuous 优先级更新。在 Blacksmith runner 上运行的带探针的逐字副本记录到这一次点击触发了四次 `handleCellAreaMouseDown`:两次在点击返回前读到 `left=969.5`,两次在场景已把面板设为 `1000px` 之后读到 `left=725.5`。保存下来的指针 x 985 于是在网格内解析为 259.5,而会议表只有两列,`colLocation` 把它钳到最后一列:`B1`。在点击前就已提交工作簿初始选区归一化的机器上,同一位置落在 A1 自身的选区框上,其 `onMouseDown` 阻止了传播;点击于是什么也没选中,没有几何绑定的 updater 入队,场景在没有测试到点击的情况下通过。
## Decision
会议表这一步改为键盘选择。场景聚焦表格 overlay(`tabindex="-1"`),断言焦点已落在其上,按 `ArrowRight` 并断言 `B1` 且公式栏为空,再按 `ArrowLeft` 并断言 `A1` 且公式栏为 `会议纪要`,然后才开始重排循环。`handleArrowKey` → `moveHighlightCell` 只依据已提交状态(当前选区与 `visibledatacolumn`)计算目标,所以被重新执行的 updater 无论此时网格几何如何都得到同一单元格,而两次移动使交互可观察,因为 `B1` 与初始选区不同。重排循环、其中的 `A1` 断言、表页滚动控件计数与 canvas 身份检查保持不变。
场景开头预算表的点击仍是指针点击:它的目标不是初始选区,其结果由 `46281` 公式读取验证,而键盘、剪贴板和布局读取把它与第一次重排隔开远超一次延迟渲染。`values.csv` 的点击及其后的收窄也保持不变:针对收窄后的网格重新执行会解析出负的 x,`colLocation` 把它钳到 A 列,即同一单元格。
## Alternatives considered
**每次重排后轮询名称框。** #5243 这样做了。被重新执行的 updater 的结果就是最终提交状态,所以轮询在整个预算内都观察到 `B1`;没有更晚的状态可等。
**点击前等待选区框渲染。** 这会恢复点击落在 A1 自身拖拽手柄上、什么也不选的通过路径,断言于是验证的是初始选区而不是一次交互。改为点击初始选区以外的单元格则会保留几何绑定的 updater,其重新执行窗口跨越随后的重排。
**第一次重排前等待渲染器空闲。** 没有产品状态能表达“React 没有待重新执行的更新”;按 runner 调校的空闲等待或固定等待正是 [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) 拒绝的掩盖式等待。
**扩展既有的 `@fortune-sheet/react` 补丁,在 updater 之外解析单元格。** `handleCellAreaMouseDown` 位于 `@fortune-sheet/core`,而用户需要命中的窗口是一次指针按下与一次侧栏重排落在同一次延迟渲染内。它仍是上游问题;产品改动不在本次测试修复范围内。
## Verification
PR #5291 处于草稿阶段时在托管 Linux lane 上运行的带探针逐字副本,在三次 lane 运行共 38 次中有 7 次(2/6、1/6、4/20)在第一次重排处复现 `expected 'B1' to be 'A1'`。每次失败都记录到 `mousedown` 的目标是 `#luckysheet-sheettable_0` 而不是拖拽手柄,且 `B1` 的提交先于只有按下 updater 才会安排的延迟 `cellInput.focus()`;在同时给 cell area 的 `getBoundingClientRect` 加了钩子的两次 lane 运行里,5 次失败全部带有上述四次执行的特征。键盘驱动的副本在这两次 lane 运行中 26 次全部通过。本地以 20× 的 `Emulation.setCPUThrottlingRate` 运行可让点击到达 cell area,并记录到每次点击三次 `handleCellAreaMouseDown` 执行;键盘驱动的副本在该限速下 6 次全部通过,在 React 调度器唤醒被推迟 200 ms 时也 6 次全部通过。
`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` 在本地 10 次重复中 10 次都通过两个 locale 用例;整个文件的 6 个用例在 3 次本地运行中 2 次通过,第 3 次在文档 gate 并行运行时失败于无关的 `:849` `Coding Tools` 开关轮询,失败截图显示开关已打开。
## Consequences
场景验证的是键盘做出的选区在侧栏重排后仍然保留,不再依赖 React 的延迟渲染落在重排之前还是之后。此 lane 中的指针点击在目标不同于当前选区、且其效果在任何布局变化之前被断言时仍然有效,或者在针对变化后布局重新执行可证明解析为同一单元格时也有效;而结果在几个往返之内就被送入重排断言的点击则无效。FortuneSheet 的几何绑定 updater 未改动,所以产品中一次真实的指针按下若在一次延迟渲染内紧跟着侧栏重排,仍可能移动选区。
@@ -13,10 +13,10 @@
zh: 4fe1f6abd6fd4c1e
/agent-note-web-lane-assertions-name-their-input-state/alternatives-considered:
en: 6cd5940f55500c3d
zh: ded68b931ed81e10
zh: 4a2e13717e87b783
/agent-note-web-lane-assertions-name-their-input-state/verification:
en: 6d11abb7a60f81ae
zh: 42bc43777a3b02b7
en: 9a35f9dbc4285674
zh: 0995a8b137912b6b
/agent-note-web-lane-assertions-name-their-input-state/consequences:
en: 2ecf084ca4df49c4
zh: bbd8823fd0910609
en: ad3d8ec4b171b04a
zh: e254cc9515b33e0e
@@ -40,10 +40,12 @@ Two negative controls reproduce the recorded signatures with the shipped asserti
Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs.
The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator.
The install-cancel scenario also recorded a separate 30 s highlighted-card wait after the enable click closed the dialog: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command. The page clock was paused, excluding highlight expiry. The [interceptor-lifetime decision](2026-09-27-plugin-install-interceptor-lifetime.md) owns the diagnosed directory-request stall and its fixture fix. The status poll still covers the read after the card appears.
The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass.
## Consequences
The three scenarios render the aria their goldens record whatever state the harness arrives in, and no golden, product surface, or timeout changed. The input state is now owned by each capture: a scenario that captures a hovered row, a focused Tooltip anchor, or a value published by a later Host read must establish that state itself. `document-preview.e2e.ts`'s spreadsheet-selection read has the same class of defect and stays with its own change.
[The task-detail close note](2026-09-28-detail-close-waits-on-observed-state.md) records the same class in the client unit lane, where the sampled state arrived through a post-commit effect rather than through an unsettled input.
@@ -32,7 +32,7 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此
**在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture(测试前置数据)前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。
## Verification
@@ -40,10 +40,12 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此
本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/<file>.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。
install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。
install-cancel 场景还记录过独立的 30 s 高亮卡片等待:启用点击关闭对话框后,`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都未能找到卡片。当时页面时钟已暂停,因此可排除高亮到期。[拦截器生命周期决策](2026-09-27-plugin-install-interceptor-lifetime.zh.md) 负责已定位的目录请求停滞及其 fixture 修复。状态轮询仍覆盖卡片出现之后的读取。
串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。
## Consequences
三个场景无论测试框架以何种状态进入,都渲染其 golden 记录的 aria,且没有任何 golden、产品界面或超时被改动。输入状态现在由每次捕获自己拥有:凡是捕获悬停行、聚焦的 Tooltip 锚点,或由后续 Host 读取发布的值的场景,都必须自行确立该状态。`document-preview.e2e.ts` 的电子表格选区读取属于同一类缺陷,仍留给它自己的改动。
[任务详情关闭断言](2026-09-28-detail-close-waits-on-observed-state.zh.md) 记录了同一类缺陷在客户端单测 lane 上的实例:被采样的状态来自一个提交后 effect,而不是来自尚未结算的输入。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-28-detail-close-waits-on-observed-state.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md
/agent-note-the-task-detail-close-waits-on-the-observed-state:
en: ffb94d33eb1c162c
zh: 32845a4113ab27c1
/agent-note-the-task-detail-close-waits-on-the-observed-state/problem:
en: 0a3fa08f242a0f7e
zh: fb8c39531405952b
/agent-note-the-task-detail-close-waits-on-the-observed-state/decision:
en: 3e4c20e32e3e9445
zh: 157750a7e1f1fd59
/agent-note-the-task-detail-close-waits-on-the-observed-state/alternatives-considered:
en: 8ee7951bdf933b45
zh: 4736d42c1d52c9b0
/agent-note-the-task-detail-close-waits-on-the-observed-state/consequences:
en: c0993f90f7af9c41
zh: 6a877cb1287000e3
/agent-note-the-task-detail-close-waits-on-the-observed-state/related:
en: f35c1fe1c2ff83b3
zh: 56a3357ab5a67822
@@ -0,0 +1,44 @@
# Agent Note: The task-detail close waits on the observed state
Status: implemented
English | [中文](2026-09-28-detail-close-waits-on-observed-state.zh.md)
## Problem
`packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx`, case "keeps details after a refresh failure and closes them after a successful retry", failed intermittently on the `node 24 / coverage` lane's `thread-safe` partition with `AssertionError: expected <aside aria-label="Task details" …> to be null` at its final assertion. The case and the whole 269-case file pass in local runs; the lane's coverage instrumentation changes only how often the window between the two commits below is observed.
The case's last step retries a failed catalog read after a confirmed deletion, awaits `screen.findByText(en['list.empty'])`, and then reads `screen.queryByRole('complementary')` once. The empty list and the removed detail are separate commits:
- `catalog-source.ts` publishes the read's `records: []` together with `status: 'ready'` in one snapshot, so `TaskManagerPage` renders the `list.empty` status region in that commit.
- `TaskDetail` derives `deleted = deletionConfirmed && !authoritative` in the same commit and closes through its passive effect. That effect calls `onDeleted` → `closeDetails`, wired at the page's `<TaskDetail>` element, which sets `selectedId` to null.
- Only the following render drops `<TaskDetail>`, and with it the `complementary` region.
`findByText` resolves on the commit that renders the empty list; the close is scheduled by that commit's passive effect and its render lands after it, so a single read issued on the resolution of the first signal can still see the panel. The page-level effect that also closes a selection whose record left an authoritative ready snapshot does not fire in this case: `useTaskDetail` keeps the confirmed deletion's task as its draft, so `selected` stays defined through the frame in which the row disappears.
## Decision
The final assertion waits for the state it asserts: `await waitFor(() => { expect(screen.queryByRole('complementary')).toBeNull() })`. The `findByText` await stays as the case's check that the list fell back to its empty state; it is not treated as a signal that the close has already been committed.
The product behavior is unchanged. The close is a post-commit effect by design: while a deletion is confirmed or a save failed, the panel must survive a refresh that removes its row, so it cannot be derived away during the render that drops the row.
## Alternatives considered
**Read the panel once after a longer wait, or retry the read outside the assertion.** Rejected on the grounds the two Related notes record for fixed windows and retries that are not bound to the asserted condition. What is specific to this case is the signal being trusted: `findByText` resolves on the commit that renders the empty list, which is not the commit that drops the panel, so the retried read has to be bound to the asserted DOM rather than to that earlier signal.
**Drive the retry through a `Promise.withResolvers` deferred and one `await act(...)`, as the neighboring refresh-ordering cases do.** Not chosen here: those cases hold both the deletion's and the refresh's promises because their subject is the ordering between the two, while this case's subject is the state the retry button's own path settles into. A deferred retry would hand the case the refresh's resolution and flush the close into the same act boundary, replacing the user-visible `list.retry` path with a hand-driven one. Polling keeps that path, and the negative control under Consequences shows the failure still lands on the assertion when the panel never closes.
**Close the detail during render instead of in an effect.** Rejected: the detail must stay mounted while a deletion is confirmed or a save failed, including through refreshes that no longer report the row, so that its failure notice and its saved records remain reachable. Deriving the selection from the catalog would unmount it on the first refresh that drops the row, which is the state this case's earlier assertions pin as retained.
**Assert that `onDelete` settled instead of asserting the DOM.** Rejected: the case's subject is that the panel left the page. A settled callback does not prove the region was removed, and the deferred close is what the case exists to cover.
## Consequences
The case now observes the commit that closes the detail rather than the commit before it, and its cost is bounded by `waitFor`'s default timeout only when the panel genuinely does not close.
The case pins the close in both directions. With coverage instrumentation, which is the CI condition this failure appeared under, the pre-fix single read failed 1 of 5 local runs of the case with the CI message, and the waiting assertion passed 10 of 10 runs of the same command. A 25 ms `setTimeout` inserted between the `deleted` condition and `onDeleted` in `TaskDetail` makes the pre-fix single read fail at the same assertion while the waiting assertion stays green under the same delay; with the close call removed from that effect (`if (deleted) onDeleted`, which references the function without invoking it), the waiting assertion fails at that assertion, so a panel that never closes is still reported. The case ran 10 more times under `-t` and the whole 269-case file 3 times, with no failure.
## Related
- [Windows-lane observations wait on the state they read](2026-09-27-observation-waits-on-observed-state.md) states the rule this case applies: an observation waits on the state it reads, not on a wall-clock window or an earlier signal.
- [Web lane assertions name their input state](2026-09-27-web-lane-assertions-name-their-input-state.md) records the same class on the web e2e lane, where five assertions sampled state a preceding action had not settled. This note stays separate because the lane, the committing mechanism (a passive effect), and the negative control differ.
@@ -0,0 +1,44 @@
# Agent Note: 任务详情关闭断言等待它所观测的状态
Status: implemented
[English](2026-09-28-detail-close-waits-on-observed-state.md) | 中文
## Problem
`packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx` 的用例 "keeps details after a refresh failure and closes them after a successful retry" 在 `node 24 / coverage` lane 的 `thread-safe` 分区上间歇失败,失败点是它最后一条断言:`AssertionError: expected <aside aria-label="Task details" …> to be null`。该用例与本文件全部 269 个用例在本地都通过;该 lane 的覆盖率插桩只改变下面两个 commit 之间那个窗口被观测到的概率。
该用例最后一步在一次已确认删除之后重试失败的目录读取,`await screen.findByText(en['list.empty'])`,然后只读一次 `screen.queryByRole('complementary')`。空列表与被移除的详情并不在同一个 commit:
- `catalog-source.ts` 把这次读取的 `records: []` 与 `status: 'ready'` 发布在同一个快照里,因此 `TaskManagerPage` 在这个 commit 渲染 `list.empty` 状态区域。
- `TaskDetail` 在同一个 commit 计算出 `deleted = deletionConfirmed && !authoritative`,并通过它的被动 effect 关闭。该 effect 调用 `onDeleted` → `closeDetails`(接线在页面的 `<TaskDetail>` 元素上),后者把 `selectedId` 置为 null。
- 只有下一次渲染才移除 `<TaskDetail>`,`complementary` 区域随之消失。
`findByText` 在渲染空列表的那个 commit 上返回;关闭由该 commit 的被动 effect 排入,其渲染落在其后,因此在第一个信号返回时就发出的单次读取仍可能看到面板。页面级那个同样会在记录离开权威 ready 快照时关闭选区的 effect 在本用例中不触发:`useTaskDetail` 把已确认删除的任务保留为草稿,于是 `selected` 在行消失的那一帧里仍然有定义。
## Decision
最后一条断言等待它所要断言的状态:`await waitFor(() => { expect(screen.queryByRole('complementary')).toBeNull() })`。`findByText` 的等待保留,作为该用例对「列表回退到空状态」的检查;它不再被当作「关闭已经提交」的信号。
产品行为不变。关闭按设计是提交后 effect:删除已确认或一次保存失败时,面板必须在该行消失的刷新过程中保留,因此它不能在丢弃该行的同一次渲染里被推导掉。
## Alternatives considered
**在更长的等待之后只读一次面板,或在断言之外重试该读取。** 按 Related 两篇 note 为固定窗口、以及未与被断言条件绑定的重试所记录的同一理由不采纳。本用例特有的部分是它所信任的信号:`findByText` 在渲染空列表的那个 commit 上返回,而丢弃面板的并不是那个 commit,因此重试的读取必须绑定到被断言的 DOM,而不是绑定到这个更早的信号。
**按邻近的刷新顺序用例那样,用 `Promise.withResolvers` 延迟量驱动重试,再用一次 `await act(...)` 冲刷。** 本用例不采用:那些用例同时持有删除与刷新的 promise,因为它们的对象是两者之间的先后顺序;而本用例的对象是重试按钮自身路径结算后的状态。把重试做成延迟量会把刷新的结算权交给用例,并把关闭冲进同一个 act 边界,从而把用户可见的 `list.retry` 路径换成手工驱动的路径。轮询保留该路径,而 Consequences 里的负例对照表明:面板永不关闭时失败仍落在该断言上。
**把详情关闭改为渲染期推导,而不是放在 effect 里。** 不采纳:删除已确认或保存失败期间,详情必须在刷新不再报告该行时继续挂载,这样它的失败提示与已保存记录仍然可达。把选区从目录推导出来,会在第一次丢弃该行的刷新就卸载它,而该用例前面的断言固定的正是「保留」这一状态。
**断言 `onDelete` 已结算,而不是断言 DOM。** 不采纳:该用例的对象是面板离开了页面。回调已结算并不能证明该区域已被移除,而延迟的关闭正是这个用例存在的原因。
## Consequences
该用例现在观测的是关闭详情的那个 commit,而不是它之前的一个 commit;只有面板真的不关闭时,它才付出 `waitFor` 默认超时的代价。
该用例在两个方向上都能固定这次关闭。在覆盖率插桩下(也正是该失败出现的 CI 条件),修前的单次读取在 5 次本地运行中有 1 次以 CI 的消息失败,而等待式断言在同一命令的 10 次运行中全部通过。在 `TaskDetail` 中把 `deleted` 条件与 `onDeleted` 之间插入 25 毫秒的 `setTimeout`,修前的单次读取会在同一断言处失败,而同一延迟下等待式断言为绿;把该 effect 里的调用去掉(`if (deleted) onDeleted`:只引用函数、不调用)时,等待式断言在同一断言处失败,因此永不关闭的面板仍会被报告。改动后又用 `-t` 跑了 10 次、整文件 269 个用例跑了 3 次,均未失败。
## Related
- [Windows-lane observations wait on the state they read](2026-09-27-observation-waits-on-observed-state.zh.md) 给出了本用例所应用的规则:观测要等待它所读取的状态,而不是等待墙钟窗口或更早的信号。
- [Web lane assertions name their input state](2026-09-27-web-lane-assertions-name-their-input-state.zh.md) 记录了同一类缺陷在 web e2e lane 上的实例:五条断言采样了前一个动作尚未结算的状态。本条单独成篇是因为 lane、提交机制(一个被动 effect)与负例对照都不同。
@@ -1,43 +0,0 @@
# Bilingual-pair consistency record for 2026-09-19-timed-user-question-two-settlements.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/proposed/architecture/2026-09-19-timed-user-question-two-settlements.md
/agent-note-timed-user-questions-as-two-settlements:
en: 15c55d3b120b8fd4
zh: 6b86ac34f4a1e9cd
/agent-note-timed-user-questions-as-two-settlements/problem:
en: f665127a9b5b8225
zh: b19845f62310f3a2
/agent-note-timed-user-questions-as-two-settlements/proposal:
en: 56ca8c0f97976358
zh: 69163bb5f064e069
/agent-note-timed-user-questions-as-two-settlements/proposal/modes-and-scope:
en: d23e622ee4643b48
zh: 3732c10c12344755
/agent-note-timed-user-questions-as-two-settlements/proposal/ownership:
en: 0543678ad9d26fc5
zh: aa6ba207e661c567
/agent-note-timed-user-questions-as-two-settlements/proposal/foreground-wait-and-claim-lifetime:
en: 447713bbdf1ab4d8
zh: 2757678ae47822aa
/agent-note-timed-user-questions-as-two-settlements/proposal/durable-state-and-late-answers:
en: b8729d06ab7814ed
zh: acaa8f4174701248
/agent-note-timed-user-questions-as-two-settlements/proposal/card-state-and-delivery-races:
en: 8c55b693e7a5bca4
zh: aea6e966ce3667e3
/agent-note-timed-user-questions-as-two-settlements/proposal/local-countdown-and-panel-actions:
en: 4c0c9ec59fc51217
zh: 322870eeb49176c7
/agent-note-timed-user-questions-as-two-settlements/proposal/reply-node-and-grouping:
en: 10323b0e0340a40b
zh: a17fc3a4d4d820a7
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
en: ce8ff9d5bc4b85b7
zh: 49024fe3a5b194b2
/agent-note-timed-user-questions-as-two-settlements/acceptance-criteria:
en: 5a993484c73befe9
zh: 38cd3209c68178c3
/agent-note-timed-user-questions-as-two-settlements/risks:
en: aae260b0cd95554f
zh: 3d7fbd6cbe9e2d37
-21
View File
@@ -1,21 +0,0 @@
---
name: agent-experience
description: Use when writing or changing a model-facing tool definition (name, description, parameter schema, or its system-prompt section), and when designing skills, context loading, or multi-step workflows, to make information discoverable and use context efficiently.
metadata:
date: "2026-09-11"
---
- **Start with minimal context:** expose purpose, available actions, and constraints first; load detailed instructions when needed.
- **Make discovery explicit:** every deferred resource needs a clear description and a reliable way to retrieve it.
- **Keep critical constraints visible:** permissions, destructive effects, and required validation should appear before the relevant action.
- **Prefer bounded outputs:** return concise results with identifiers or paths for retrieving details; avoid dumping entire logs or documents.
- **Use locality:** include a bounded amount of likely needed adjacent context with an operation’s result. For example, deliver a thread reply with a few preceding messages. Mark omissions and truncation explicitly, and provide a way to retrieve more.
- **Evaluate total work:** saving context is useful only if it does not cause more searches, repeated reads, or mistakes.
## Tool definitions
- **Delete obvious constraints.** Omit rules the model learns from the call result, such as a missing file failing to read or a failed send not being delivered.
- **Describe behavior, not implementation.** State what the tool does and returns; omit internal mechanisms, output markers the model sees in results, and enforcement details.
- **Put parameter rules on the parameter.** Defaults, ranges, pairing, and when-to-set rules belong in that parameter's description or schema; a description that varies with configuration usually means the varying part belongs to a parameter.
- **Say each fact once.** Do not repeat the tool definition in its system-prompt section, a parameter description in the tool description, or one tool's rules in another tool.
- **Measure the change.** Compare first-turn prompt tokens before and after a tool-definition change.
+1
View File
@@ -0,0 +1 @@
../../../packages/preset/agent-preset/skills/agent-experience/SKILL.md
@@ -0,0 +1,54 @@
---
name: dsh-create-upgrade-guide
description: Use when a deepseek-harness change breaks an externally perceptible surface (CLI, profiles, cordis.yml or settings keys, persisted user data, SDK or wire APIs, published package entry points), or when changing a surface that an unreleased upgrade guide already covers, to write or update docs/upgrade-guide/v<version>/<item>/guide.md.
---
# DSH Create Upgrade Guide
An upgrade guide tells a reader who runs release vT what to change to run the next release. Write it in the change that introduces the break, as soon as the break is recognized; do not defer it to release preparation.
## Scope
Write a guide only for breaks that someone outside the repository can observe after upgrading:
- `dsh` commands, flags, and profile names;
- `cordis.yml`, patch, overlay, and settings keys or their accepted values;
- persisted user data: Session logs, SQLite stores, and user data directories;
- TypeScript and Python SDK exports, JSON-RPC, HTTP, and ACP messages;
- published package names and entry points.
Pre-stable internal APIs whose consumers are all updated in the same change need no guide. A Session-format change still follows [type acknowledgements](../../../docs/cookbook/reviewing-persistence-type-changes.md); its guide states only the reader's actions and links that record.
## Location
`docs/upgrade-guide/v<version>/<item>/guide.md`
- `<version>` is the `version` field of the root `package.json` when you write the guide, for example `v0.1.7-rc.2`. The guide describes the upgrade from that release to the next one.
- `<item>` is a kebab-case name of the changed surface, for example `profile-flag-rename`.
- Each item directory holds exactly `guide.md`, its Chinese counterpart `guide.zh.md`, and the pairing record `guide.i18n.yaml`: no index, no attachments.
## Maintenance
While the root version is unchanged, the current version directory describes the delta from vT to current `master`. When a later change touches a surface a guide there covers, update that guide in the same change; delete the guide when the break is reverted. After the version bump, guides in older version directories are frozen; a new break goes into the new version directory. Never write a guide spanning more than one release.
## Format
Start from the [upgrade-guide template](../dsh-doc/templates/upgrade-guide.md). `verify-upgrade-guides` enforces:
- frontmatter with exactly `kind: upgrade-guide` and a one-sentence `description` naming what breaks; add a key only together with its gate check, then backfill every guide;
- one `#` title, then exactly the `## Change` and `## Migration` sections in `guide.md`, and `## 变更` and `## 迁移` in `guide.zh.md`, in that order;
- at most 500 words in `guide.md`, frontmatter included.
`Change` states the old and new behavior of the surface and who is affected. `Migration` lists ordered steps that name exact files, keys, commands, or symbols, and states how to confirm the migration worked.
When a guide needs more than 500 words, the extra text belongs elsewhere: link source files or symbols instead of restating code, move rationale into an [Agent Note](../../notes/README.md), and turn repeated mechanical steps into a script or `dsh` command that the guide invokes.
Follow [dsh-prose-standard](../dsh-prose-standard/SKILL.md). Write the Chinese counterpart in the same change under the [bilingual pairing rules](../../../docs/AGENTS.md), then record the pair with `pnpm run verify-translation-pairing --write docs/upgrade-guide/v<version>/<item>/guide.md`.
## Verify
```sh
pnpm run verify-upgrade-guides
pnpm run verify-translation-pairing docs/upgrade-guide/v<version>/<item>/guide.md
pnpm run verify-md-links
```
+2 -1
View File
@@ -57,6 +57,7 @@ The `kind` frontmatter field selects exactly one README template. Every kind in
- `persistence-change` → [templates/persistence-change.md](templates/persistence-change.md): a dated record in `docs/persistence-changes/` — acknowledge a detected type transition with per-root predecessors and generated after schemas.
- `persistence-release` → [templates/persistence-release.md](templates/persistence-release.md): a pinned tag in `docs/persistence-changes/releases/` — compare reconstructed historical types without claiming compatibility acknowledgement.
- `persistence-format` → [templates/persistence-format.md](templates/persistence-format.md): a historical Session format checkpoint in `docs/persistence-changes/historical-formats/`, with complete schemas; the current writer uses the existing catalog.
- `upgrade-guide` → [templates/upgrade-guide.md](templates/upgrade-guide.md): `docs/upgrade-guide/v<version>/<item>/guide.md` and its Chinese sibling — migration steps for one externally perceptible break, owned by [dsh-create-upgrade-guide](../dsh-create-upgrade-guide/SKILL.md).
Open the template before writing and follow its skeleton and rules; it states what the kind is, how the page is structured, and the fact checks each section owes. Add a new kind only together with a distinct template file, a documented repository position or declared owner, and a focused check that maps documents to it.
@@ -67,7 +68,7 @@ These rules decide what a section may say. They apply to every authored human-fa
- **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does.
- **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds.
- **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose.
- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. Agent Notes and postmortems retain their own historical scope.
- **Current state only.** Ordinary documentation describes the current codebase. Dedicated `persistence-change`, `persistence-release`, and `persistence-format` records retain historical type evidence; release comparisons and format checkpoints describe observed history without asserting compatibility. An `upgrade-guide` describes the change from its version directory's release to the next one. Agent Notes and postmortems retain their own historical scope.
- **Use controlled technical English.** Give each sentence an explicit actor and one main action when ambiguity can change behavior. Reuse one term per concept, prefer direct verbs, split stacked instructions and conditions, and preserve modality and exceptions. Apply the non-certified, ASD-STE100-inspired discipline in [the page-style reference](references/style.md#controlled-technical-english). Do not force a shorter sentence when precision would fall.
## Quality criteria
@@ -29,7 +29,7 @@ kind: "package-reference"
## The kind system
`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order:
`kind` selects the document template directly; every kind maps to exactly one template that exists in this skill, and no template exists without a kind. Files named `docs/persistence-changes/YYYY-MM-DD-slug.md` use `persistence-change`; files named `docs/persistence-changes/releases/dsh-v*.md` use `persistence-release`. Files named `docs/persistence-changes/historical-formats/vN.md` use `persistence-format`. Their Chinese siblings use the same kind. Files named `docs/upgrade-guide/v<version>/<item>/guide.md` and their `guide.zh.md` siblings use `upgrade-guide`. Folder READMEs are indexes, not records. Derive package README kinds mechanically, in this order:
1. The README is `packages/README.md` or `packages/<group>/README.md` → `package-group`.
2. The package manifest declares `dsh.bundle.patch` → `package-bundle`.
@@ -45,6 +45,7 @@ kind: "package-reference"
| `persistence-change` | `docs/persistence-changes/YYYY-MM-DD-slug.md` and its Chinese sibling | [persistence-change.md](../templates/persistence-change.md) | Historical acknowledgement: mechanically detected type changes, per-root predecessor references, compatibility decision, and generated after schemas. |
| `persistence-release` | `docs/persistence-changes/releases/dsh-v*.md` and its Chinese sibling | [persistence-release.md](../templates/persistence-release.md) | Retrospective observation: pinned tag, adjacent before/after digests, actual version constants, and changed after schemas; no compatibility acknowledgement. |
| `persistence-format` | `docs/persistence-changes/historical-formats/vN.md` and its Chinese sibling | [persistence-format.md](../templates/persistence-format.md) | Selected historical format: source evidence, complete root schemas and reachable types; the current writer uses the generated catalog. |
| `upgrade-guide` | `docs/upgrade-guide/v<version>/<item>/guide.md` and its Chinese sibling | [upgrade-guide.md](../templates/upgrade-guide.md) | Upgrade path: one externally perceptible break from the directory's release to the next, with `Change` and `Migration` sections in at most 500 English words. |
Before assigning `package-library` or `package-bundle`, inspect the facts: read `package.json` for `dsh.bundle.patch` and `src/index.ts` for the entry shape (`apply` export or a default service export is a plugin; a plain module API is a library). `dsh plugin --profile <name> add <package>` installs any npm dependency, but the profile reconcile activates a layer only for a package that declares `dsh.bundle`; never present that command as an install path for a library or a plain plugin. The documentation check derives the expected kind from these same facts, rejects another value, and rejects `name`, `audience`, `tags`, and README-local `i18n` metadata. Add a new kind only with a distinct template, an unambiguous repository position or declared owner, and a focused check that maps documents to it.
@@ -0,0 +1,29 @@
# Template: upgrade-guide
Use this kind for `docs/upgrade-guide/v<version>/<item>/guide.md` and its Chinese sibling `guide.zh.md`, whose sections are `## 变更` and `## 迁移`. The tree has no folder index. [dsh-create-upgrade-guide](../../dsh-create-upgrade-guide/SKILL.md) owns scope, placement, maintenance, and length rules; `pnpm run verify-upgrade-guides` enforces them.
## Frontmatter
```yaml
---
kind: upgrade-guide
description: "The externally perceptible surface that breaks and what replaces it."
---
```
## Skeleton
````markdown
# <Specific break, for example: `--profile` replaces `--preset`>
English | [中文](guide.zh.md)
## Change
State the old and new behavior of the surface and who observes it.
## Migration
1. Name the exact file, key, command, or symbol to change, with a before/after snippet when it is shorter than prose.
2. State how to confirm the migration worked.
````
+8 -1
View File
@@ -157,5 +157,12 @@ jobs:
- name: Install (immutable)
run: pnpm install --frozen-lockfile
# The same key and value the coverage and serial lanes grant: on the
# shared 3-vCPU runner, cases that inherit Vitest's 5000 ms default timed
# out in spawn.spec.ts and changes.spec.ts (run 36370945526) while
# passing in under a second on a quiet host. The ci-unit aggregate is
# `pnpm run test` through the path that consumes the variable.
- name: Unit tests (darwin parity)
run: pnpm run test
env:
DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'
run: pnpm run check:ci:unit
+2
View File
@@ -8,6 +8,8 @@ Public APIs are pre-stable; update every consumer. Follow [version/status](docs/
Acknowledge [declared persistence-type changes](docs/cookbook/reviewing-persistence-type-changes.md).
Record each externally perceptible breaking change immediately in an [upgrade guide](.agents/skills/dsh-create-upgrade-guide/SKILL.md).
**Application launch.** Only `dsh` profiles launch supported Node apps; package bins, demos, and public SDK argv escapes are forbidden ([rule](docs/architecture.md#application-launch)).
## Repository layout
+2 -2
View File
@@ -18,8 +18,8 @@
en: 819e3f7c144d0d64
zh: de815f040975dba7
/deepseek-harness/community-and-support:
en: 42848f543aff6e02
zh: d46a07bdc6f36629
en: 20acf6451fdc869b
zh: d06246dd019ecef7
/deepseek-harness/contributing:
en: cabc2d3e9f8898bd
zh: 83b6d238bae29ce3
+1 -1
View File
@@ -44,7 +44,7 @@ pnpm dsh web
- Submit feedback or bug reports through [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions).
- Add the [`dsh-plugin`](https://github.com/topics/dsh-plugin) topic to your plugin repository for discoverability.
- Join <a href="https://discord.gg/Ycq5dCaS4">DeepSeek Harness Discord community</a>.
- Join <a href="https://discord.gg/4MrtZUhpxg">DeepSeek Harness Discord community</a>.
## Contributing
+1 -3
View File
@@ -48,19 +48,17 @@ pnpm dsh web
- 通过 [GitHub Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions) 提交反馈或 bug 报告。
- 为你的插件仓库添加 [`dsh-plugin`](https://github.com/topics/dsh-plugin) 话题,便于被发现。
- 欢迎加入 DeepSeek Harness 企微群:扫码添加企微小助手并填写入群问卷,完成后小助手会邀请你入群。
- 欢迎加入 DeepSeek Harness 企微群!扫描下方二维码填写入群问卷,小助手会定期发送入群邀请。
<table>
<thead>
<tr>
<th align="center">企微小助手</th>
<th align="center">入群问卷</th>
<th align="center">微信公众号</th>
</tr>
</thead>
<tbody>
<tr>
<td align="center"><img src="https://cdn.deepseek.com/harness/readme/community-wecom-assistant.png" alt="DeepSeek Harness 企微小助手二维码" width="180" height="180"></td>
<td align="center"><a href="https://trtgsjkv6r.feishu.cn/share/base/form/shrcnIt5twSVdLGD52KJBckGCgg"><img src="https://cdn.deepseek.com/harness/readme/community-wecom-survey.png" alt="DeepSeek Harness 入群问卷二维码" width="180" height="180"></a></td>
<td align="center"><img src="https://cdn.deepseek.com/harness/readme/community-wechat-official-account.png" alt="DeepSeek Harness 团队微信公众号二维码" width="180" height="180"></td>
</tr>
+2 -1
View File
@@ -90,6 +90,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`execa`](https://github.com/sindresorhus/execa) | MIT |
| [`fast-xml-parser`](https://github.com/NaturalIntelligence/fast-xml-parser) | MIT |
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
| [`got`](https://github.com/sindresorhus/got) | MIT |
| [`immer`](https://github.com/immerjs/immer) | MIT |
| [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
@@ -138,7 +139,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
- `@earendil-works/[email protected]5.1` — [`patches/@[email protected]5.1.patch`](patches/@[email protected]5.1.patch)
- `@earendil-works/[email protected]7.1` — [`patches/@[email protected]7.1.patch`](patches/@[email protected]7.1.patch)
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
+4 -4
View File
@@ -6,8 +6,8 @@
en: ebdc75fa0da18801
zh: 5c6cd2554b8f8a98
/deepseek-ai-dsh/entry-modes:
en: 093936b9e1a59bea
zh: 12be7d2d931d9671
en: c4811e2012a5e919
zh: e6d20ec8238c2638
/deepseek-ai-dsh/app-arguments:
en: cc03e6b75206661b
zh: 99e41f4143198352
@@ -18,5 +18,5 @@
en: 3a40ddc44e55f79c
zh: 8d3949132e1ef93c
/deepseek-ai-dsh/development:
en: 99a36e4f7ea3f9c7
zh: 07ca2934de700caa
en: 0a289c1235869927
zh: a1f473f70036ad37
+3 -1
View File
@@ -17,7 +17,7 @@ The `dsh` command is the sole supported Node application launcher: profiles are
| `dsh web` | Boot the Web profile. |
| `dsh plugin --profile <name> <pnpm args>` | Manage a profile's plugins by forwarding to pnpm in the profile directory. |
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot, config-dump, and plugin-management requests for it.
The invoking directory is the default workspace root. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize on first use from shipped templates. Create another profile at an unused, non-shipped name with `--from-default-profile`, or initialize a base-backed profile through `dsh plugin`. The `desktop` name is reserved for the Electron-owned profile, so the CLI rejects boot and config-dump requests for it. The npm CLI also rejects its plugin-management requests; the [Desktop-installed command](../desktop/README.md#bundled-command-runtime) can manage the initialized Desktop profile using that installation’s runtime.
## App arguments
@@ -59,4 +59,6 @@ Production runs require built package and frontend artifacts. From the repositor
The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor for runtime package resolution while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown.
Packaged installations call the same `runCli()` entry with their package-manager executable. The Desktop carrier also enables plugin operations for its initialized profile; npm launches omit these options. Installation-owned package environments apply only to plugin package operations; the invoking directory, ordinary profile selection, and agent-shell PATH retain their CLI meanings.
The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts.
+3 -1
View File
@@ -17,7 +17,7 @@
| `dsh web` | 启动 Web profile。 |
| `dsh plugin --profile <name> <pnpm args>` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 |
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动、配置 dump 和插件管理请求。
运行命令时所在的目录将作为默认 workspace 根目录。`web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 在首次使用时会从随附模板自动初始化。使用 `--from-default-profile` 可以基于这些模板之一,在尚未使用的非内置名称处创建其他 profile;通过 `dsh plugin` 则可以初始化一个以 base 为基础的 profile。`desktop` 名称保留给 Electron 持有的 profile,因此 CLI(命令行界面)会拒绝针对它的启动和配置 dump 请求。npm CLI 也会拒绝其插件管理请求;[Desktop 内置命令](../desktop/README.zh.md#bundled-command-runtime)可以使用该安装的运行时管理已初始化的 Desktop profile。
## 应用参数
@@ -59,4 +59,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 为运行时包解析指定自己的安装锚点,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。
打包安装通过同一个 `runCli()` 入口传入包管理器可执行文件。Desktop 载体还会启用其已初始化 profile 的插件操作;npm 启动不传入这些选项。安装包提供的包管理环境仅用于插件包操作;调用目录、普通 profile 选择与 agent shell 的 PATH 保留 CLI 语义。
[Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.1.7-rc.2",
"version": "0.2.0-rc.2",
"publishConfig": {
"access": "public"
},
+2 -2
View File
@@ -24,8 +24,8 @@
en: 12bba432c07573bf
zh: ab6b365cfb95003f
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: 24e3da3716d261fe
zh: 060611966db4ca69
en: 6a7abc9339e8f37f
zh: 133adb834c8e195d
/dsh-cli-behavior-reference/source-execution:
en: 05c2b23790f7b296
zh: 51f809020fb230a1
+1 -1
View File
@@ -126,7 +126,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission
The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets.
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths.
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
+1 -1
View File
@@ -128,7 +128,7 @@ dsh web --help
基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;将其 `enabled` 配置设为 `false` 可关闭上传。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent 沙箱之外的受信任可执行代码。
+4 -3
View File
@@ -140,9 +140,10 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar
* error.
* @param argv - arguments after the Node binary and script.
* @param version - version string printed by `--version`.
* @param manageDesktopProfile - permit Desktop's installed carrier to manage its reserved profile's plugins.
* @returns the resolved invocation.
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
export function parseDshArgs(argv: readonly string[], version: string, manageDesktopProfile = false): DshInvocation {
const first = argv[0]
let resolved: DshInvocation | undefined
// Annotated, not inferred: the actions below call back into `program`, and an
@@ -191,9 +192,9 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
.argument('[args...]', 'pnpm arguments, forwarded verbatim (add <pkg>, remove <pkg>, why <pkg>, ...)')
.action((args: string[], options: { profile: string }) => {
if (options.profile === '') program.error('error: --profile needs a name')
rejectElectronProfile(plugin, options.profile)
if (!manageDesktopProfile) rejectElectronProfile(plugin, options.profile)
if (args.length === 0) program.error('error: plugin needs pnpm arguments to forward (e.g. add <package>)')
resolved = { mode: 'plugin', profile: options.profile, args }
resolved = { mode: 'plugin', profile: options.profile.toLowerCase() === 'desktop' ? 'desktop' : options.profile, args }
})
}
+13 -3
View File
@@ -10,14 +10,23 @@ import { getDshRuntimeVersion, loadLayeredEnv, StartupError } from '@deepseek-ai
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
import { parseDshArgs } from './args.ts'
import { reportStartupFailure } from './startup-diagnostics.ts'
import type { RunProfileOptions } from './profile-boot.ts'
/** Installation-owned dependencies supplied by a packaged CLI launcher. */
export type RunCliOptions = Pick<RunProfileOptions, 'packageManager'> & {
/** Permit plugin commands for Desktop's existing profile; reserved for its installed carrier. */
manageDesktopProfile?: boolean
}
/**
* Run the public dsh command-line interface.
* @param options - Package runtime and Desktop profile access supplied by the installation.
* @returns a promise that settles when the selected command mode finishes.
*/
export async function runCli(): Promise<void> {
export async function runCli(options: RunCliOptions = {}): Promise<void> {
const version = getDshRuntimeVersion()
const invocation = parseDshArgs(process.argv.slice(2), version)
const { manageDesktopProfile, ...profileOptions } = options
const invocation = parseDshArgs(process.argv.slice(2), version, manageDesktopProfile)
switch (invocation.mode) {
case 'profile': {
@@ -29,6 +38,7 @@ export async function runCli(): Promise<void> {
fromDefaultProfile: invocation.fromDefaultProfile,
patchFiles: invocation.patches,
args: invocation.args,
...profileOptions,
})
} catch (error) {
if (!(error instanceof StartupError)) throw error
@@ -39,7 +49,7 @@ export async function runCli(): Promise<void> {
}
case 'plugin': {
const { runPlugin } = await import('./plugin.ts')
process.exit(await runPlugin(invocation.profile, invocation.args))
process.exit(await runPlugin(invocation.profile, invocation.args, options.packageManager))
break
}
case 'dump-config': {
+30 -8
View File
@@ -1,12 +1,18 @@
/** Profile package management and explicit, exact-version compatibility approvals. */
import { runPluginCommand, setProfileVersionExemption } from '@deepseek-ai/dsh-plugin-manager/operations'
import { runPluginCommand, runProfilePnpm, setProfileVersionExemption, type PackageOperationOptions } from '@deepseek-ai/dsh-plugin-manager/operations'
import { INSTALL_ANCHOR } from './profile-boot.ts'
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir } from '@deepseek-ai/dsh-app-boot'
import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileCompatibility, resolveProfileDir, type ProfileContext } from '@deepseek-ai/dsh-app-boot'
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
import { existsSync } from 'node:fs'
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
function requireDesktopProfile(dir: string): void {
if (!existsSync(join(dir, 'package.json'))) {
throw new Error('Open DeepSeek Harness Desktop once to initialize its profile, then fully quit it before running dsh plugin --profile desktop.')
}
}
/** Parse only DSH-owned commands; all other arguments remain pnpm's responsibility. */
async function versionCommand(profile: string, args: readonly string[]): Promise<number | undefined> {
const [command, ...rest] = args
@@ -35,9 +41,10 @@ async function versionCommand(profile: string, args: readonly string[]): Promise
process.stderr.write('dsh: warning: allowing incompatible plugin versions can break the application or corrupt data. Approval applies only to the exact package and DSH versions.\n')
}
const dir = resolveProfileDir(profile)
await mkdir(dir, { recursive: true })
if (profile !== 'desktop') await mkdir(dir, { recursive: true })
await withFileLock(join(dir, 'package.json'), async () => {
if (!existsSync(join(dir, 'package.json'))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES)
if (profile === 'desktop') requireDesktopProfile(dir)
else if (!existsSync(join(dir, 'package.json'))) initProfile(dir, PROFILE_TEMPLATES[profile]?.bundles ?? DEFAULT_PROFILE_BUNDLES)
if (request === undefined) {
const { exemptions, warnings } = readProfileCompatibility(dir)
for (const warning of warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
@@ -55,24 +62,39 @@ async function versionCommand(profile: string, args: readonly string[]): Promise
}
/** Run package management for a profile.
* @param profile Profile name.
* @param profile Profile name; Desktop's reserved profile must already be initialized by the application.
* @param args DSH exemption command or pnpm arguments relative to the invoking directory.
* @param packageManager Installation-owned executable and environment for pnpm operations.
* @returns Zero on success; nonzero on invalid approval or package-manager failure.
*/
export async function runPlugin(profile: string, args: readonly string[]): Promise<number> {
export async function runPlugin(profile: string, args: readonly string[], packageManager?: ProfileContext['packageManager']): Promise<number> {
if (profile === 'desktop') {
try { requireDesktopProfile(resolveProfileDir(profile)) } catch (error) {
process.stderr.write(`dsh: ${String(error)}\n`)
return 1
}
}
const versionResult = await versionCommand(profile, args)
if (versionResult !== undefined) return versionResult
const dir = resolveProfileDir(profile)
if (existsSync(join(dir, 'package.json'))) {
for (const warning of readProfileCompatibility(dir).warnings) process.stderr.write(`dsh: warning: ${warning}\n`)
}
const result = await runPluginCommand({ profile, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }, args, {
const context = { profile, dir, installAnchor: INSTALL_ANCHOR, cwd: process.cwd() }
const options: PackageOperationOptions = {
...packageManager,
execution: 'cli',
outputBytes: 16384,
lockWaitMs: 120000,
lookupTimeoutMs: 120000,
onOutput: (text, stream) => { process[stream].write(text) },
})
}
const result = profile === 'desktop'
? await withFileLock(join(dir, 'package.json'), async () => {
requireDesktopProfile(dir)
return runProfilePnpm(context, args, options)
}, { waitMs: 120000 })
: await runPluginCommand(context, args, options)
if (result.exitCode === 127) process.stderr.write('dsh: pnpm was not found; install pnpm and make it available on PATH.\n')
for (const { name, version, runtimeVersion } of result.incompatible ?? []) {
process.stderr.write(`dsh: to accept the risk, run: dsh plugin --profile ${profile} allow-version ${name}@${version} --dsh-version ${runtimeVersion} --accept-risk\n`)
+17 -3
View File
@@ -1,15 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseDshArgs } from '../src/args.ts'
const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3')
const parse = (argv: string[], manageDesktopProfile = false) => parseDshArgs(argv, '1.2.3', manageDesktopProfile)
/** Capture the process exit code while muting Commander's output. */
function exitCode(argv: string[]): number {
function exitCode(argv: string[], manageDesktopProfile = false): number {
const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
vi.spyOn(process.stderr, 'write').mockReturnValue(true)
try {
parse(argv)
parse(argv, manageDesktopProfile)
throw new Error(`expected ${JSON.stringify(argv)} to exit`)
} catch {
return exit.mock.calls.at(-1)?.[0] as number
@@ -122,6 +122,20 @@ describe('parseDshArgs', () => {
.toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] })
})
it.each(['desktop', 'Desktop', 'DESKTOP'])('permits installed Desktop plugin commands for %s', (profile) => {
expect(parse(['plugin', '--profile', profile, 'add', 'example-plugin'], true))
.toEqual({ mode: 'plugin', profile: 'desktop', args: ['add', 'example-plugin'] })
expect(exitCode(['plugin', '--profile', profile, 'add', 'example-plugin'])).toBe(1)
})
it.each([
['desktop'], ['--profile', 'Desktop'],
['desktop', '--dump-config'], ['DESKTOP', '--dump-default-config'],
['desktop', '--dump-config-schema'],
])('keeps Desktop boot and inspection reserved in its installed CLI: %j', (...argv: string[]) => {
expect(exitCode(argv, true)).toBe(1)
})
it('routes profile and web config dumps', () => {
expect(parse(['--profile', 'web', '--dump-config']))
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: false, patches: [] })
+76 -3
View File
@@ -3,14 +3,15 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, onTestFinished, vi } from 'vitest'
import { getDshRuntimeVersion, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
import { runPluginCommand } from '@deepseek-ai/dsh-plugin-manager/operations'
import { getDshRuntimeVersion, initProfile, PROFILE_TEMPLATES, readProfileVersionExemptions } from '@deepseek-ai/dsh-app-boot'
import { runPluginCommand, runProfilePnpm } from '@deepseek-ai/dsh-plugin-manager/operations'
import { runPlugin } from '../src/plugin.ts'
vi.mock('../src/profile-boot.ts', () => ({ INSTALL_ANCHOR: '/installation/package.json' }))
vi.mock('@deepseek-ai/dsh-plugin-manager/operations', async importOriginal => ({
...await importOriginal<typeof import('@deepseek-ai/dsh-plugin-manager/operations')>(),
runPluginCommand: vi.fn(),
runProfilePnpm: vi.fn(),
}))
function fixture() {
@@ -22,9 +23,10 @@ function fixture() {
vi.restoreAllMocks()
vi.unstubAllEnvs()
vi.mocked(runPluginCommand).mockReset()
vi.mocked(runProfilePnpm).mockReset()
rmSync(home, { recursive: true, force: true })
})
return { dir: join(home, 'profiles', 'test'), stdout, stderr }
return { home, dir: join(home, 'profiles', 'test'), stdout, stderr }
}
it('requires explicit acknowledgement, grants only the exact pair, lists and revokes it without pnpm', async () => {
@@ -102,3 +104,74 @@ it('forwards all other commands unchanged and retains package diagnostics', asyn
expect(runPluginCommand).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), args, expect.objectContaining({ execution: 'cli' }))
expect(stderr.mock.calls.map(call => call[0]).join('')).toContain('pnpm was not found')
})
it('uses the installation package runtime without changing CLI authentication or output policy', async () => {
fixture()
vi.mocked(runPluginCommand).mockResolvedValue({ exitCode: 0, output: '', truncated: false, logPath: '/profile/log' })
const packageManager = {
command: '/installation/electron',
args: ['--expose-internals', '/installation/pnpm.mjs'],
env: { ELECTRON_RUN_AS_NODE: '1', PATH: '/installation/bin' },
}
expect(await runPlugin('test', ['list'], packageManager)).toBe(0)
expect(runPluginCommand).toHaveBeenCalledWith(
expect.objectContaining({ profile: 'test' }), ['list'],
expect.objectContaining({ ...packageManager, execution: 'cli' }),
)
})
it.each([
['list'], ['add', 'example-plugin'],
['version-exemptions'],
['allow-version', '[email protected]', '--dsh-version', 'CURRENT', '--accept-risk'],
['revoke-version', '[email protected]', '--dsh-version', 'CURRENT'],
])('requires Desktop initialization before %j without creating a profile', async (...arguments_) => {
const { home, stderr } = fixture()
const args = arguments_.map(value => value === 'CURRENT' ? getDshRuntimeVersion() : value)
expect(await runPlugin('desktop', args)).toBe(1)
expect(stderr.mock.calls.map(call => call[0]).join('')).toMatchInlineSnapshot(`
"dsh: Error: Open DeepSeek Harness Desktop once to initialize its profile, then fully quit it before running dsh plugin --profile desktop.
"
`)
expect(existsSync(join(home, 'profiles', 'desktop'))).toBe(false)
expect(runPluginCommand).not.toHaveBeenCalled()
expect(runProfilePnpm).not.toHaveBeenCalled()
})
it('runs Desktop package operations under the shared writer lock with bundled pnpm', async () => {
const { home } = fixture()
const dir = join(home, 'profiles', 'desktop')
initProfile(dir, PROFILE_TEMPLATES.web!.bundles)
const manifest = readFileSync(join(dir, 'package.json'), 'utf8')
const packageManager = { command: '/installation/electron', args: ['/installation/pnpm.mjs'], env: { ELECTRON_RUN_AS_NODE: '1' } }
vi.mocked(runProfilePnpm).mockImplementation(async (context, args, options) => {
expect(context).toEqual({ profile: 'desktop', dir, installAnchor: '/installation/package.json', cwd: process.cwd() })
expect(args).toEqual(['list'])
expect(options).toMatchObject({ ...packageManager, execution: 'cli' })
expect(existsSync(join(dir, 'package.json.lock'))).toBe(true)
return { exitCode: 0, output: '', truncated: false, logPath: '/profile/log' }
})
expect(await runPlugin('desktop', ['list'], packageManager)).toBe(0)
expect(runProfilePnpm).toHaveBeenCalledOnce()
expect(runPluginCommand).not.toHaveBeenCalled()
expect(existsSync(join(dir, 'package.json.lock'))).toBe(false)
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifest)
expect(existsSync(join(home, 'profiles', 'test'))).toBe(false)
})
it('applies version approvals to the existing Desktop profile without replacing its bundle selection', async () => {
const { home } = fixture()
const dir = join(home, 'profiles', 'desktop')
initProfile(dir, PROFILE_TEMPLATES.web!.bundles)
const manifest = readFileSync(join(dir, 'package.json'), 'utf8')
const runtime = getDshRuntimeVersion()
expect(await runPlugin('desktop', ['allow-version', '[email protected]', '--dsh-version', runtime, '--accept-risk'])).toBe(0)
expect(readProfileVersionExemptions(dir)).toEqual({ '[email protected]': [runtime] })
expect(await runPlugin('desktop', ['version-exemptions'])).toBe(0)
expect(await runPlugin('desktop', ['revoke-version', '[email protected]', '--dsh-version', runtime])).toBe(0)
expect(readProfileVersionExemptions(dir)).toEqual({})
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(manifest)
expect(existsSync(join(dir, 'package.json.lock'))).toBe(false)
expect(runPluginCommand).not.toHaveBeenCalled()
expect(runProfilePnpm).not.toHaveBeenCalled()
})
@@ -16,7 +16,7 @@
baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
reasoning: low
modelOverrides:
deepseek-v4-flash:
deepseek-flash:
maxTokens: 1024
streamIdleTimeoutMs: 1000
@@ -25,7 +25,7 @@
agents:
- id: main
provider: deepseek
model: deepseek-v4-flash
model: deepseek-flash
cwd: !!js process.cwd()
- id: agent-instructions
@@ -715,7 +715,7 @@ describe('headless stream-json snapshots', () => {
expect(header?.config).toMatchInlineSnapshot(`
{
"maxTokens": 1024,
"model": "deepseek-v4-flash",
"model": "deepseek-flash",
"provider": "deepseek",
"reasoningEffort": "low",
}
@@ -226,7 +226,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
expect(taskOutput.job).toMatchObject({ id: jobId, kind: 'bash', status: 'completed' })
}, 15_000)
it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', async () => {
it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', { timeout: 15_000, retry: 0 }, async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-task-cancel-'))
ctx = await backgroundPtcModeHarness(workdir)
@@ -238,15 +238,27 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
expect(preResult.isError).toBe(true)
expect(ctx.jobs.list()).toEqual([])
const jobs = ctx.jobs
// Registration is announced after the job's ownership has committed.
const registered = new Promise<void>((resolve) => {
onTestFinished(jobs.events.subscribe({ owners: 'all' }, (event) => {
if (event.type === 'registered') resolve()
}))
})
const afterPublication = new AbortController()
const running = runCode(ctx, `
const started = await tools.bash({ command: 'sleep 10', description: 'Wait for explicit task kill', run_in_background: true });
console.log(started.jobId);
await new Promise(() => {});
`, afterPublication.signal)
for (let attempt = 0; attempt < 100 && ctx.jobs.list().length === 0; attempt++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
await Promise.race([
registered,
running.then((result) => {
// Promise.race handles a late rejection after registration, too.
completion(result)
throw new Error('run_code completed before background job registration')
}),
])
const job = ctx.jobs.list()[0]
expect(job).toMatchObject({ id: 'bash-1', status: 'running' })
afterPublication.abort('outer-call-cancelled')
@@ -261,7 +273,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => {
return await tools.job_output({ job_id: ${JSON.stringify(job!.id)}, wait: true, timeout_ms: 5000 });
`))
expect(settled).toMatchObject({ job: { id: job!.id, status: 'killed' } })
}, 15_000)
})
it('keeps foreground bash coupled to the outer signal', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-foreground-cancel-'))
+9 -2
View File
@@ -1,15 +1,17 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.1.7-rc.2",
"version": "0.2.0-rc.2",
"private": true,
"license": "MIT",
"type": "module",
"main": "lib/index.js",
"files": [
"lib/index.js"
"lib/index.js",
"lib/cli.js"
],
"dependencies": {
"koffi": "^3.1.0",
"@deepseek-ai/libreoffice-kit": "^0.1.1",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh": "workspace:*",
@@ -24,5 +26,10 @@
"@deepseek-ai/dsh-skill-office": "workspace:*",
"@deepseek-ai/dsh-deepseek-account": "workspace:*",
"@deepseek-ai/dsh-workspace": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
"@deepseek-ai/cordis-plugin-include": "workspace:~",
"@deepseek-ai/dsh-skill": "workspace:*"
}
}
+36
View File
@@ -0,0 +1,36 @@
/** Public dsh commands using the immutable runtime carried by the Desktop installation. */
import { delimiter, dirname, join, resolve } from 'node:path'
import { runCli } from '@deepseek-ai/dsh/lib/bin.js'
import { installOfficeEngineResolution, runtimeArchivePath } from './office-engine.ts'
/**
* Run the ordinary CLI with Desktop's bundled package manager and reserved-profile plugin access.
* @param runtimeDir - Prepared or ASAR-contained production DSH package tree.
* @param supportDir - Physical Desktop runtime directory containing pnpm.
* @returns Completion of the selected CLI command; profile plugins own their process lifetime.
*/
export async function runDesktopCli(runtimeDir: string, supportDir: string): Promise<void> {
installOfficeEngineResolution(runtimeDir)
await runCli({
manageDesktopProfile: true,
packageManager: {
command: process.execPath,
args: ['--expose-internals', join(supportDir, 'pnpm', 'bin', 'pnpm.mjs')],
env: {
ELECTRON_RUN_AS_NODE: '1',
DSH_DESKTOP_NODE_EXECUTABLE: process.execPath,
PATH: `${join(supportDir, 'bin')}${delimiter}${process.env.PATH ?? ''}`,
},
},
})
}
if (import.meta.main) {
if (process.platform === 'win32') {
const { installWindowsCliSignals } = await import('./windows-cli-signals.ts')
await installWindowsCliSignals()
}
const runtimeDir = resolve(import.meta.dirname, '../../../..')
await runDesktopCli(runtimeDir, join(dirname(runtimeArchivePath(runtimeDir) ?? runtimeDir), 'runtime'))
}
@@ -0,0 +1,23 @@
/** Deliver console interrupts to CLI listeners in Electron's Windows Node mode. */
/**
* Register the CLI process's Windows console handler.
* Koffi queues callbacks from the console thread onto the JavaScript thread.
* The registration lasts until process exit; unknown events retain native handling.
* @returns Completion once the console handler is registered.
*/
export async function installWindowsCliSignals(): Promise<void> {
const { default: koffi } = await import('koffi')
const kernel = koffi.load('kernel32.dll')
const type = koffi.proto('int __stdcall DshCliConsoleHandler(uint32_t event)')
const handler = koffi.register((event: number) => {
if (event === 0) return Number(process.emit('SIGINT'))
if (event === 1) return Number(process.emit('SIGBREAK'))
return 0
}, koffi.pointer(type))
const install = kernel.func('int __stdcall SetConsoleCtrlHandler(DshCliConsoleHandler *handler, int add)')
if (!install(handler, 1)) {
koffi.unregister(handler)
throw new Error('desktop CLI: cannot register the Windows console handler')
}
}
+5 -4
View File
@@ -1,12 +1,13 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['lib/types/index.js'],
export default defineConfig(['index', 'cli'].map(name => ({
entry: ['lib/types/' + name + '.js'],
outDir: 'lib',
format: ['esm'],
format: ['esm'] as const,
outputOptions: { codeSplitting: false },
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
})
})))
+15 -6
View File
@@ -5,6 +5,9 @@
/deepseek-harness-desktop:
en: eccac345d51428fa
zh: 4a448b3d63cb8117
/deepseek-harness-desktop/terminal-command:
en: bef92953c237363f
zh: d21d6effd21b8c00
/deepseek-harness-desktop/closing-the-window-and-quitting:
en: c323e02e3f0305fe
zh: 3c730db632136443
@@ -12,14 +15,20 @@
en: 069fdfd72d2ea26a
zh: 76ae549d43c99bb4
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: 31cbb7452be8e1ad
zh: 502ec556e2ec301e
en: 320733f93ede61a4
zh: 33206c3c4753c802
/deepseek-harness-desktop/bundled-command-runtime:
en: beecf076aa2f0796
zh: c9001ce0f22d2379
/deepseek-harness-desktop/installation-ownership:
en: 8d686d07b0809596
zh: 9ca7658daf583a66
en: 22153a298a6662e1
zh: 3600803c8c9d0274
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: 0d11af8639977a6d
zh: 86b086b12d2210d3
/deepseek-harness-desktop/installation-ownership/host-environment:
en: 84a9cc7ca9455aa8
zh: 4091b8aa8231b74a
/deepseek-harness-desktop/develop:
en: 40d2ad4014bf445b
zh: 6a08de06df9ed042
@@ -39,8 +48,8 @@
en: f0e345fd89868803
zh: fd2c775ad23fa2f0
/deepseek-harness-desktop/package/upload-updates:
en: c64d7fe2fe2534b7
zh: 2188e1ae99ab4b33
en: 72073b2e72590067
zh: 63b1f9790395c37d
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
en: cfd5780dd26a8e46
zh: f7a16b1ede793b7b
+27 -3
View File
@@ -22,6 +22,14 @@ Desktop microphone access is restricted to audio requests from the primary `dsh-
Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Shift+I on Windows to toggle DevTools for the focused application page, including in packaged builds. These native shortcuts use hidden application-menu items. Update overlays and packaged embedded browser guests disable DevTools.
## Terminal command
The application menu's **Manage dsh Command…** entry, immediately below **Check for Updates…**, shows the current command and offers Install, Repair, and Remove. The command uses Desktop's installed runtime and the ordinary [dsh CLI](../cli/README.md), including when the Desktop application is closed. Open a new terminal after installation and run `dsh --version`.
On macOS, installation creates `/usr/local/bin/dsh` and requests administrator authentication when the directory requires it. Shell startup files are unchanged. On Windows, the management dialog registers the command in the current user's PATH. A pre-existing command requires confirmation before switching; repairing Desktop's own selected command does not repeat that confirmation. The macOS link preserves and restores the displaced launcher; Windows preserves other PATH entries, including entries that predate registration. A command with higher PATH precedence is reported with its location. After moving the application, use Repair on macOS or Install from the new location on Windows. Remove leaves unrelated installations intact.
Command registration is optional after installing Desktop. Use **Manage dsh Command… → Remove** before uninstalling Desktop to remove its CLI registration; the application uninstaller does not remove it. Finish CLI commands before updating or uninstalling Desktop. The CLI runtime version follows the installed Desktop release. See [bundled command runtime](#bundled-command-runtime) for Desktop plugin commands and runtime limitations.
## Closing the window and quitting
Closing the main window (macOS close button and ⌘W; Windows ×, Alt+F4, and the taskbar Close window command) hides it; Windows asks for acknowledgement before the first hide. The page and the Host keep running, tasks continue, and the next show presents the same document with its session, drafts, and scroll position; a fullscreen macOS window leaves fullscreen before hiding. The window returns through the Dock icon, a second launch, or `dsh://open` on macOS, and through the tray on Windows. Minimize is unchanged. Closing the welcome window before the workspace opens quits on Windows and, on macOS, keeps the application in the Dock without a window.
@@ -60,7 +68,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers
The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
| Decision | Why | Direct consequence |
|---|---|---|
@@ -76,9 +84,17 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09
Welcome loads the shared Toast palette and shadow tokens, with system typography for body-mounted notifications.
## Bundled command runtime
The installed `resources/runtime/cli/bin/dsh` shell script (`dsh.cmd` on Windows) runs the ordinary CLI dispatcher using Desktop's Electron executable and bundled pnpm. It works while Desktop is closed and retains the [Electron runtime limitations](../../.agents/notes/implemented/architecture/2026-09-11-desktop-electron-node-runtime.md). Ordinary profile, configuration, and plugin commands use the same implementation as npm dsh; the command does not open Desktop.
To manage Desktop's plugins, launch Desktop once to initialize its profile, fully quit the application, then run `dsh plugin --profile desktop add <package>`, `list`, or `remove <package>`. Reopen Desktop to use the changes. Package operations retain the shared profile write lock and compatibility checks. The installed command refuses an uninitialized Desktop profile instead of creating an ordinary CLI profile in its place.
Finish CLI commands before updating or uninstalling Desktop; the installer does not coordinate with running commands. The CLI does not supply Office authoring-skill resource paths. Desktop's existing Office setup and Web attachment conversion retain their own resource configuration. `prepare:runtime` copies the launch scripts without compiling a native CLI launcher.
## Installation ownership
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile.
Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot this profile. The Desktop-installed command can manage its plugins while the application is quit; npm-installed dsh cannot mutate it.
The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
@@ -92,6 +108,8 @@ Update copy preserves the complete version, including prerelease suffixes, witho
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance.
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
On macOS the custom menu retains Electron's standard Window menu and application hide commands, including Minimize (⌘M) and Hide (⌘H). Linux keeps the application and Edit menus.
@@ -116,6 +134,10 @@ Before the first fatal dialog opens, Electron writes a crash report to the platf
Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-<timestamp>` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document.
### Host environment
macOS and Linux GUI launches inherit only the session manager's environment, without variables exported by shell startup files. Before the first Host starts, Desktop runs the account's login shell (from the user database, not `$SHELL`) once as `<shell> -ilc` and reads its `env -0` output between delimiters, so `~/.zprofile` and `~/.zshrc` (or the shell's equivalents) take effect for the Host, agent shells, terminals, and profile configuration. Reading runs in parallel with profile preparation. The probe has no terminal input and sets `DISABLE_AUTO_UPDATE=true`, `ZSH_TMUX_AUTOSTARTED=true`, and `ZSH_TMUX_AUTOSTART=false` to keep oh-my-zsh and tmux plugins from blocking. Shell values replace inherited values except `PWD`, `OLDPWD`, `SHLVL`, `_`, those probe variables, and launcher-owned `DSH_*` and `ELECTRON_*` names; Desktop resolves paths such as `DSH_HOME` before the read, so the Host keeps the same values. The read completes at the closing delimiter, so background processes started by startup files may keep running with their output discarded. A candidate that fails to start, exits nonzero, prints no delimited output, or exceeds `DSH_DESKTOP_LOGIN_SHELL_TIMEOUT_MS` (integer milliseconds from 1000 through 2147483647, default `10000`; its process group is killed) is logged as a warning, and Desktop tries `/bin/zsh`, `/bin/bash`, and `/bin/sh` in turn; when all fail the Host uses the inherited environment. The dump command uses POSIX syntax, so an account shell such as csh, tcsh, or nushell fails and the Host receives the environment from the first system shell's startup files instead. Quitting Desktop during the read kills the running probe's process group. The result is read once per application process, so changes to shell startup files apply after quitting and reopening Desktop. Windows GUI launches already inherit the user and system environment variables from the registry, so Windows skips this step.
## Develop
The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files.
@@ -233,9 +255,11 @@ When extraction fails, the installer writes a report with the 7-Zip result and i
### Upload updates
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Test and production uploads through `upload:*` retain a fresh `.desktop-build/upload-records/<environment>-<target>-*` directory after release preflight. `plan.json` records destination, version, every file's size/SHA-512, and published YAML bytes; flushed `events.jsonl` records PUT intent and available response status/request ID; `result.json` records completion or the last failed stage. A missing final result means interruption or unavailable storage, not success. No credential values, authorization headers, or raw SDK errors are recorded. Audit-write failures stop later PUTs. Every object is one streamed Tencent COS PUT with an explicit length and Content-MD5; the COS SDK repeats a request only when its body is not a stream, and this uploader does not retry either. Retain partial records and inspect remote state before another operation: a timeout or failed receipt write does not prove that the object was not stored. These records are local, not tamper-proof or automatically backed up; archive each release's records with its build evidence in controlled storage. Public CDN readback remains separate release qualification, explicitly marked `not-performed` in the upload result.
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds/<target>/` and binaries use `dsh-desk/bin/<target>/`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk/<release-id>/feeds/<target>/` and `dsh-desk/<release-id>/bin/<target>/`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
Loaded 100 of 1202 files, more files were not shown because too many files have changed in this diff. Show more