Merge master into fix/focus-ring-brand-primary

This commit is contained in:
liukx0205 committed 2026-09-24 12:05:19 +08:00
commit e71fa92030
1007 files changed
+64375 -7956

No files matched your search

@@ -9,11 +9,11 @@
en: 8578b584bda3abdc
zh: 85dcf44aab2cfd51
/agent-note-desktop-welcome-window-material/decision:
en: df31ede930b7c3c0
zh: c7fc4272bc6b2d85
en: df7f9dc0c48769ab
zh: 5e667c736dd58a1c
/agent-note-desktop-welcome-window-material/alternatives-considered:
en: 6e095dceffe75fa8
zh: 679a6026afd735a7
en: cf42e200b1d0f00a
zh: de96b5647f5ffb23
/agent-note-desktop-welcome-window-material/consequences:
en: 8ab73e6c67f4ce88
zh: 6b1edad02f90ba5b
en: fd2533d63d9a1e4e
zh: 5cb602962608f5de
@@ -12,7 +12,7 @@ The desktop welcome design blurs the desktop behind its entire window. Chromium
The [Electron welcome window](../../../../apps/desktop/src/welcome-window.ts) owns native material and window controls. Its independently bundled React renderer supplies the entry, account sign-in states and API-key form. It shares the `StateDot` loading component with the Web UI without booting the Web plugin graph. The Desktop build emits local JavaScript and CSS under `lib/welcome`, included in packaged applications; the document keeps its network-denying content security policy. A narrow preload supplies typed shell copy, a write-only key operation, and a skip operation; IPC rejects other windows and subframes. Each sandboxed preload is bundled independently because Electron’s restricted require cannot load sibling chunks. The Electron main process authenticates through the shared Web launch URL and resolves the official provider's reference through the existing settings and credential RPC methods. The [thin Web wrapper](2026-09-10-desktop-web-wrapper.md) owns the HTTP server; onboarding adds no Host endpoint or child IPC operation. Responses contain metadata or a safe outcome, never a key or private provider diagnostics.
Cold startup opens the entry when neither an account credential nor a model API key is stored. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a completion flag. The next process launch checks the credential store again. The Desktop preload marker suppresses only the Web credential step; settings and the welcome notice remain mounted. Other native shells can set `credentialOnboarding` through the Models Host plugin, which publishes that boolean through the page-injection event before dialogs register. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. Startup reads account presence and API-key configuration independently. Account sign-in enters the workspace; sign-out reopens the welcome window only when no model API key remains configured.
Cold startup opens the entry when neither an account credential nor a model API key is configured. Saving enters the workspace after persistence succeeds; skipping enters without saving a draft or a credential-entry completion flag. The next process launch checks credentials again. The Desktop preload marker suppresses the automatic Web credential step and welcome notice; settings and explicit API-key editing remain available. The [Desktop onboarding decision](../feature/2026-09-16-desktop-onboarding.md) owns the separate device-local introduction after account login. Other native shells can disable only the credential step through the Models Host plugin's `credentialOnboarding` page-injection value. The module graph carries package identities rather than arbitrary Host config, so a Host row alone does not configure its Client half. The [account provider](2026-09-14-deepseek-account-login.md) supplies login and sign-out state while preserving independently stored API keys.
The generated project follows declared workspace dependencies because pnpm’s hoist index alone can omit configured plugins.
@@ -24,7 +24,7 @@ The welcome flow shares the Desktop backend controller with startup recovery. Th
**A CSS-only backdrop filter.** It cannot blur other application windows or the desktop, so it cannot provide the required native effect.
**Persisting a completed-onboarding flag.** A skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials.
**Persisting a credential-entry completion flag.** A native credential skip is valid only for the current process; retaining it would suppress the next cold-start choice while the user still has no credentials. The account introduction's separate completion flag does not bypass this authentication decision.
**Keeping both native and Web credential dialogs.** Their separate completion state would ask for a key twice after a native skip. The composition assigns credential onboarding to one owner.
@@ -34,4 +34,4 @@ The welcome flow shares the Desktop backend controller with startup recovery. Th
## Consequences
Native blur strength and font fallback vary by system. macOS uses menu vibrancy with the onboarding window tint: 40% white in light mode and 50% rgb(24 25 28) in dark mode. Native materials own the blur strength. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and a built-Host acceptance test proves credential persistence across restarts. Account sign-out preserves independently configured API keys. Onboarding presentation produces no Session events.
Native blur strength and font fallback vary by system. The main window uses the shared sidebar vibrancy; full-window onboarding retains a transparent renderer without an additional tint. Windows compositing needs platform QA. Owner-local text expectations cover both pages and locales, and built-Host acceptance covers credential persistence across restarts. Account sign-out preserves independent API keys. Onboarding presentation produces no Session events.
@@ -12,7 +12,7 @@ Status: implemented
[Electron 欢迎窗口](../../../../apps/desktop/src/welcome-window.ts)负责原生材质和窗口控件。独立打包的 React 渲染器提供入口、账号登录状态和 API Key 表单。它与 Web UI 共用 `StateDot` 加载组件,不启动 Web 插件图。Desktop 构建将本地 JavaScript 和 CSS 输出到 `lib/welcome`,并纳入应用安装包;文档继续使用禁止网络访问的内容安全策略。窄接口 preload 提供强类型桌面文案、仅写密钥操作和跳过操作;IPC 拒绝其他窗口和子 frame。每个沙箱 preload 都独立打包,因为 Electron 受限的 require 无法加载同目录中的拆分模块。Electron 主进程通过共享 Web 启动 URL 认证,并通过现有设置和凭证 RPC 方法解析官方提供方的引用。[Web 薄壳](2026-09-10-desktop-web-wrapper.zh.md)负责 HTTP 服务;引导不增加 Host 端点或子进程 IPC 操作。响应只包含元数据或安全结果,不包含密钥或提供方的私有诊断。
冷启动在既无已存账号凭证、也无已配置的模型 API Key 时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或完成标记。下次进程启动时会重新检查凭证存储。Desktop preload 标记只抑制 Web 凭证步骤;设置和欢迎须知仍然挂载。其他原生壳可以通过 Models Host 插件设置 `credentialOnboarding`,由页面注入事件在注册弹窗前发布该布尔值。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。启动分别读取账号凭证是否存在和 API Key 配置状态。账号登录后进入工作区;退登后仅在没有已配置的模型 API Key 时重新打开欢迎窗口。
冷启动在账号凭证与模型 API Key 均未配置时打开入口。保存会在持久化成功后进入工作区;跳过会直接进入,不保存草稿或凭证入口完成标记。下次进程启动时会重新检查凭证。Desktop preload 标记抑制自动 Web 凭证步骤和欢迎须知;设置与显式 API Key 编辑仍然可用。[Desktop 引导决策](../feature/2026-09-16-desktop-onboarding.zh.md)负责账号登录后独立的设备本地介绍流程。其他原生壳可以通过 Models Host 插件注入页面的 `credentialOnboarding` 值仅禁用凭证步骤。模块图传递的是包标识,而不是任意 Host 配置,因此单独配置 Host 行不会配置其 Client 半部。[账号提供方](2026-09-14-deepseek-account-login.zh.md)提供登录与退出登录状态,同时保留独立存储的 API Key。
生成的开发项目沿已声明的 workspace 依赖建立链接,因为单独依赖 pnpm 的提升索引会漏掉配置插件。
@@ -24,7 +24,7 @@ Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在
**仅使用 CSS 背景滤镜。** 它无法模糊其他应用窗口或桌面,因此不能提供所需的原生效果。
**持久化已完成引导标记。** 跳过只对当前进程有效;保存该标记会在用户仍无凭证时抑制下次冷启动的选择。
**持久化凭证入口完成标记。** 跳过原生凭证入口只对当前进程有效;保留标记会在用户仍无凭证时抑制下次冷启动选择。账号介绍流程的独立完成标记不会绕过此认证判断。
**同时保留原生与 Web 凭证弹窗。** 两者独立的完成状态会在跳过原生引导后再次要求输入密钥。组合将凭证引导交给单一归属方。
@@ -34,4 +34,4 @@ Desktop 在打开欢迎窗口前读取共享的 `locale.preference`。Client 在
## 后果
原生模糊强度和字体回退因系统而异。macOS 使用 menu vibrancy,叠加 onboarding 的窗口背景色:浅色模式为 40% 白色,深色模式为 50% rgb(24 25 28)。模糊强度由原生材质控制。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收测试证明凭证可跨重启持久化。账号退登保留独立配置的 API Key。引导展示不会生成 Session 事件。
原生模糊强度和字体回退因系统而异。主窗口沿用共享的 sidebar vibrancy;全窗引导保持渲染器透明,不额外叠加底色。Windows 合成效果需要平台验证。所属目录内的文本预期覆盖两页和两种语言,构建后 Host 的验收覆盖凭证跨重启持久化。账号退出登录保留独立的 API Key。引导展示不会生成 Session 事件。
@@ -3,20 +3,20 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
/agent-note-deepseek-account-browser-login:
en: 666b3732ccc1c2fb
zh: 37dffad1bb4e26ae
en: 8328647bcdf8a666
zh: 445a07c7ec4bcca4
/agent-note-deepseek-account-browser-login/problem:
en: 67c00444d9dcf06d
zh: ef294910952ba052
/agent-note-deepseek-account-browser-login/decision:
en: db9297d04d25d0f6
zh: 20938f92c1563146
en: 3b6c781b5374a8af
zh: 2a3fb7c63dfdaf83
/agent-note-deepseek-account-browser-login/alternatives-considered:
en: b7694861ea5282b6
zh: a5ff57bdcc9fcf60
en: 3c2dd2c948ffb51b
zh: d019197df0ee3dcf
/agent-note-deepseek-account-browser-login/consequences:
en: 348fcdb33c193cf2
zh: ad82da41941d3afb
en: a6eff4a0350e9018
zh: 2e3200ca089023ab
/agent-note-deepseek-account-browser-login/verification:
en: 881edcb454b941a6
zh: 7dc5a9b7a066f623
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-09-14-deepseek-account-login.zh.md)
Sign-in captures the initiating UI language per attempt so browser authorization follows the Desktop or Settings locale without depending on the Platform default.
Sign-in captures the initiating UI's client metadata per attempt so browser authorization follows the Desktop or Settings language without depending on the Platform default.
## Problem
@@ -20,11 +20,11 @@ Authorization registers a temporary /oauth/callback route on the Host webServer.
Platform navigation and authorization share one validated platformOrigin from Cordis configuration. Private profile patches or environment expressions supply deployment-specific origins without committing development addresses.
The Desktop profile supplies the native platform for Host API headers. Account and update-policy clients share one mapping to avoid different client identities for the same installation. This identity does not depend on a login attempt, since stored grants also issue requests after restart. Embedded pages use the same platform identity and retain their other separately configured headers.
Platform client identity belongs to the calling UI rather than to the Host. Every account operation carries the caller's client version, active language, and current UTC offset, and the provider derives the five Platform client headers for that call from them plus the composed native platform. Client identity headers override same-named deployment headers, so one Host shared by several callers reports the UI that made each request instead of the last one it saw, and the update-policy client keeps the same header meanings for the installed shell. PlatformSession deliberately omits caller identity; the embedding client composes its own five headers and limits them to the configured origin.
## Alternatives considered
Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. These alternatives are rejected.
Putting the protocol in Electron duplicates the implementation for Web consumers and gives the shell credential ownership. Putting tokens in DEEPSEEK_API_KEY loses the distinction between account grants and user API keys. Treating a browser callback as success before persistence admits false success. Waiting for a remote cancel acknowledgment leaves local cancellation dependent on network availability. Remembering one client identity in the Host would mislabel every other caller that shares it or reaches it through a forward. These alternatives are rejected.
## Consequences
@@ -34,7 +34,7 @@ API keys and account grants remain separately stored. Account tokens have no pro
The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs.
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and its temporary session.
Platform embedding transfers a grant from Host to Electron over private Node IPC and then once to the trusted Platform main frame through a sandboxed preload. Preload performs one synchronous IPC before page scripts execute; the main process only validates the sender and returns prepared memory. Platform reads the token synchronously thereafter without a readiness API. A failed initialization retains embedded mode and a throwing getter. Account UI projections remain credential-free. The Platform document can read this credential, so its script security is part of account protection; context isolation protects native capabilities, not a token intentionally returned to the document. Replacing or removing the grant destroys the document and clears browser authentication; [account-scoped page storage](2026-09-22-platform-browser-storage.md) survives.
Support questionnaires receive only available environment metadata through explicit prefill fields. Account UIDs, tokens and masked contact details stay out of questionnaire URLs.
@@ -56,6 +56,8 @@ The embedded Platform document stays hidden during loading because native child
Separate accountRequestHeaders route account data and embedded Platform traffic independently of authorization and logout. Cookie overrides merge by name, retaining deployment authentication. Host passes the resolved headers over private process IPC; Electron injects them only at the configured origin and omits them from bootstrap.
Bonus notices use Platform-provided text and order identities. Reads occur when an account becomes active — signed-in startup and successful sign-in — and once per Settings entry or return from the top-up view, with no periodic polling. The card is painted as soon as the server offers it, including underneath the open Settings panel, so display is never deferred. The client acknowledges an order after a presented frame or the user closes it; nothing is written to browser storage, so a later sign-in re-displays an order the server still offers. Failed acknowledgments retry with capped exponential backoff for the rest of the signed-in session. Platform acknowledgments suppress delivery on other devices, but concurrent displays before acknowledgment are possible because the API does not reserve delivery. Fetch success alone never records a display.
The Host binds private Platform sessions to the account provider lifetime. Removal or watch termination clears Electron’s session; replacement subscribes to the new provider, and disposed reads cannot publish old credentials.
The client distinguishes plugin disposal from terminal account-stream failure. RemoteStream aborts its signal after either outcome, so the plugin owns a separate disposal flag to preserve failure feedback while suppressing reports after unload.
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-09-14-deepseek-account-login.md) | 中文
登录申请保存发起界面的语言,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。
登录申请保存发起界面的客户端元数据,使浏览器授权跟随桌面或设置页的语言,不依赖平台默认值。
## 问题
@@ -20,11 +20,11 @@ Status: implemented
平台导航与授权共享 Cordis 配置中同一个经过校验的 platformOrigin。私有 profile patch 或环境表达式提供部署来源,开发地址不进入仓库。
Desktop profile 为 Host API 请求头提供原生平台信息。账号与更新策略客户端共用一份映射,避免同一安装实例发送不同的客户端标识。此标识不依赖登录尝试,因为重启后保存的授权仍会发起请求。内嵌页面使用同一平台标识,并保留独立配置的其他请求头。
Platform 客户端身份属于发起调用的界面,而不属于 Host。每个账号操作都携带调用方的客户端版本、当前语言和当前 UTC 偏移,provider 据此与组合出的原生平台推导该次调用的五个 Platform 客户端请求头。客户端身份请求头覆盖部署配置中的同名请求头,因此一个被多方共享的 Host 报告的是每次请求的发起界面,而不是它上一次见到的调用方;更新策略客户端对已安装的壳沿用同一组请求头语义。PlatformSession 有意不携带调用方身份;内嵌客户端自行组装同样的五个请求头,并仅在配置来源发送。
## 替代方案
把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。这些方案均被拒绝。
把协议放在 Electron 会为 Web 使用方重复实现,并让外壳拥有凭证。把 token 放入 DEEPSEEK_API_KEY 会丢失账号授权与用户 API Key 的区别。在持久化前将浏览器回调视为成功会产生虚假成功。等待远程取消确认会使本地取消依赖网络。在 Host 中记住一份客户端身份,会让共享该 Host 或经转发接入的其他调用方被标错。这些方案均被拒绝。
## 影响
@@ -34,7 +34,7 @@ API Key 与账号记录独立保存。账号 token 没有主动刷新流程;
账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档及其临时会话。
Platform 内嵌通过私有 Node IPC 将授权从 Host 传给 Electron,再经沙箱 preload 一次性传给受信任的 Platform 主 frame。preload 在页面脚本执行前执行一次同步 IPC;主进程仅校验调用来源并返回已准备的内存数据。Platform 此后同步读取 token,无需就绪接口。初始化失败时保留内嵌模式,由 getter 抛错。账号 UI 投影仍不包含凭证。Platform 文档能够读取此凭证,因此其脚本安全也是账号保护的一部分;上下文隔离保护原生能力,不能隐藏有意返回给文档的 token。替换或移除授权会销毁文档并清理浏览器认证;[账号范围内的页面存储](2026-09-22-platform-browser-storage.zh.md)会保留。
支持问卷仅通过显式预填字段接收已有的环境信息,URL 不包含账号 UID、token 或脱敏联系方式。
@@ -56,6 +56,8 @@ exchange 的 user 数据在凭证提交后供首次资料读取使用,展示
独立的 accountRequestHeaders 将账号资料及内嵌 Platform 流量与授权、退登分开路由。Cookie 覆盖按名称合并,保留部署认证。Host 通过私有进程 IPC 传递合并后的请求头;Electron 仅在配置来源注入,并从 bootstrap 排除。
赠金通知使用 Platform 提供的文案和订单标识。账号进入登录态(已登录启动、登录成功)时读取一次,每次进入设置页或从充值视图返回时再读取一次,不进行定时轮询。服务端下发的通知立即绘制,包括在打开的设置面板之下,因此展示从不延后。客户端在通知卡完成一帧绘制后或用户关闭卡片时确认订单。不写入任何浏览器存储,因此之后再次登录会重新展示服务端仍在下发的订单。确认失败会在本次登录会话内按带上限的指数退避重试。Platform 的确认记录阻止其他设备继续获取通知,但接口不预留展示资格,因此确认前仍可能并发展示。仅获取成功不会记录为已展示。
Host 将私有 Platform 会话绑定到账号提供者的生命周期。提供者移除或订阅结束时清空 Electron 会话;替换后订阅新的提供者,已释放的读取不得发布旧凭据。
客户端区分插件卸载和账户状态流的终止错误。RemoteStream 在两种情况下都会中止其信号,因此插件独立记录卸载状态,保留错误反馈并抑制卸载后的报告。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-16-host-schedule-storage.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-16-host-schedule-storage.md
/agent-note-host-owned-scheduled-messages:
en: 11854b220af809ea
zh: 0f0575de37107ed8
/agent-note-host-owned-scheduled-messages/problem:
en: 2c774d05ae75784f
zh: 619ba7f281b13233
/agent-note-host-owned-scheduled-messages/decision:
en: 4a38d9bd933241b0
zh: b246c74f68e92a25
/agent-note-host-owned-scheduled-messages/alternatives-considered:
en: 21d7ec9a9ff3cbf2
zh: 93d03b8b3012aa7e
/agent-note-host-owned-scheduled-messages/consequences:
en: 5b8d0a26024f795e
zh: c4d6f36d54783c47
@@ -0,0 +1,65 @@
# Agent Note: Host-owned scheduled messages
Status: implemented
English | [中文](2026-09-16-host-schedule-storage.zh.md)
## Problem
Scheduled messages must remain discoverable after restart without loading every Session. A timer attached to a live Agent cannot dispatch a task whose Session remains cold. Users also need to inspect pending and delivered tasks without activating their conversation; removing a task after delivery loses that management record.
## Decision
The existing Schedule package owns one Host storage domain containing each task's original Session id, reminder record, `active` or `inactive` status, latest delivery receipt, and saved delivery history. The domain is authoritative for retained tasks. The Host scheduler restores timers only for active tasks; a due task uses the Session controller's existing resolution operation and queues an ordinary follow-up in the original Session. Querying and deleting tasks do not resolve an Agent. Stored records lacking status normalize to `active` without scanning or recreating historical Sessions.
The task domain declares the whole-unit layout. Tasks are authoritative, so the JSON backend rejects an unreadable file, malformed document, unsupported version, or invalid task as a complete load failure instead of interpreting it as deletion. Failed recovery leaves `schedule.json` unchanged, while an initially absent file opens as an empty domain; repairing the file preserves task identity. `Service.init` waits for both storage validation and runtime setup, management methods wait for the same initialization result, and an unloading owner releases late-acquired storage without starting dispatch. Dispatch admission is separate from initialization: an ancestor can close Agent admission after a task mutation commits but before runtime cleanup starts. That refusal is logged without automatic retry and does not turn the persisted mutation into a failed management result. Invalid storage and failed cleanup registration still reject startup.
This supersedes the Session-log authority, live-only activation, and read-only catalog decisions in the [Session-local reminders note](../feature/2026-08-05-durable-web-schedule.md). Its explicit absolute-time validation, fixed-rate arithmetic, and distinction between queue admission and model completion remain applicable. Historical `schedule/change` creation, fold, and invariant types remain restricted to After, At, and Every through `LegacyScheduleRecord`; a separate Host decoder accepts the current `ScheduleRecord`, including Daily, Weekly, and Cron. The Host record decoder requires a stored `title` that is non-empty after trimming, already trimmed, and at most 120 characters, and the strict task schema rejects the whole domain open instead of dropping such a task. The historical change decoder tolerates an absent `title` so an already-written Session log stays readable, and it applies the same three checks when the member is present. The storage domain remains version 1. Historical events do not populate that domain, and existing `at` tasks are not converted into daily or weekly rules. Loading a Session with active legacy reminders warns that they must be recreated.
A plugin-sourced `followup()` synchronously appends the message to the Session inbox. Successful Session flush acknowledges durable delivery. One task-record put stores `lastDelivery`, appends a delivery record with the occurrence target, acknowledgment time, actual message id, and instruction snapshot, and updates the one-shot's `inactive` status or the recurring task's next target and status. Task state and its saved history therefore share one commit. Inactive tasks remain stored and never schedule another delivery; explicit deletion stops future delivery and removes the row together with its saved records. These records acknowledge inbox delivery, not user receipt or model execution completion.
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.
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.
The maintained `@js-temporal/polyfill` 0.5.1 resolves local dates and overlap/gap behavior using the Host's IANA data; Schedule does not own offset-sampling logic or add another package layer. Each daily or weekly record stores normalized `HH:mm:ss.SSS` time, its zone, its weekday set where applicable, and a committed next UTC instant. Decoding and restart do not recompute that instant. Valid zone aliases remain readable across canonical-name changes, while subsequent targets use current Host time-zone data.
Model `schedule_list`, Remote `list`, and the Session header's right-aligned reminder clock show only active tasks. The independent Automation tasks page opens from the sidebar and reads active and inactive Host tasks through the read-only Remote `catalog` method, without activating Sessions or reading their history. That clock is absent while the Session has no active reminder, opens a single task's detail directly when exactly one remains, and otherwise lists them so one can be chosen; both paths open one right-Sidebar task tab that renders the same detail component the Automation tasks page uses, so neither path selects the main panel. The tab names its task and original Session through its navigation parameters and carries that binding into every mutation, so opening or deleting from it never activates a Session. The page has one All, Enabled, and Inactive status filter row, retained inactive details, latest delivery metadata, and an original-Session control that sits in the detail tab strip beside the task actions. Its header's New action starts a new Session, where the reminder is created by asking the model; the page offers no creation form. Navigation waits for Session and archive metadata, explains unavailable targets, and never restores an archived Session implicitly. Task retention remains independent of original-Session availability. Every, Daily, Weekly, and Cron are recurring rules; the page groups tasks only by status. Daily and Weekly rule labels retain the saved local time, exact rule zone, and stored weekday set rather than substituting the browser zone used for next-target display. Explicit confirmed deletion reuses `delete` with the entry's original Session binding, requiring that the supplied binding match the stored task. This argument check is not independent caller authorization: model tools capture the current Agent's Session, while the global user interface selects the task's original binding. Cross-Session model modification policy remains a product decision, not a consequence of the storage layout. A payload-free change notification invalidates queries, and reconnect queries recover missed notifications. Deletion prevents subsequent dispatch but does not retract a queued message.
The second detail level separates Rules from Delivery records. Opening records requests `history` with the task's original Session binding and an explicit page limit of 1–100. An exclusive message-id cursor reads older receipts without changing position when new deliveries append. Catalogs retain only latest-receipt metadata rather than duplicating full histories in every task-list response. Missing tasks and unknown cursors are distinct failures, not empty successful history. The records view loads lazily, rejects stale responses, and keeps original-Session navigation separate from inspection.
Name, instruction, and timing updates share the dispatch and deletion FIFO. Comparing the complete observed record inside that queue prevents a stale draft from overwriting a saved rule or a target advanced by delivery. This avoids adding a persisted revision counter, at the cost of rejecting drafts after normal delivery changes the target. One task put replaces the rule, name, or instruction, preserving identity, original Session binding, status, latest receipt, and saved history. A request may omit the timing change, the name, or the instruction; an omitted field keeps its stored value, and a name or instruction change alone leaves the committed target untouched. Normalized no-ops leave the committed target and stored bytes unchanged. Changed one-shots retain their identity as At records; changed fixed intervals use a new accepted-save anchor; changed wall-clock rules re-resolve their first future target from the same accepted-save sample, and an equivalent normalized rule stays a no-op. A timing change may select a different recurrence kind, and the new rule computes its target exactly as creation would from that accepted-save sample. Deletion removes the row together with its saved records ([consequences of that reversal](2026-09-22-schedule-hard-delete-and-bounded-history.md)), so the runtime never schedules the task again and `history` no longer resolves for its original binding. Inactive tasks are not reopened.
The page uses the existing Host task domain and scheduler, not a mock engine or a Session projection. The name, the instruction, and the run-time rows edit one local draft for the shown task, and one Save commits that draft as a single update carrying the complete expected record; Cancel restores the stored values, a failed save retains the draft, an unsaved draft survives an authoritative refresh, and a committed save and a failed catalog refresh remain separate outcomes. Pause and execution-history operations remain unsupported. Lifecycle status and latest delivery acknowledgment do not describe model results.
The client presentation derives from existing persisted data instead of widening a persisted format. The `schedule_create` transcript card reads the created task out of the call's model-visible result JSON, which already serializes the canonical task value, rather than declaring `presentationMeta`: a persisted metadata channel would add a durable field for a card that Session-log replay reconstructs from the result alone, and previously recorded Sessions render the card without a format change. The Sidebar Session-row clock mark occupies the root-scoped `sidebar.session.row.leading` seat rather than requesting a per-Session provider, because a Session-scoped seat would bind every listed Session and thereby activate and retain it; the same root-scoped owner share feeds `sidebar.session.row.hover`, and both read the shared Host catalog snapshot `schedule/catalog()` without reading a Session log.
Session inbox persistence and the task put occur in separate persistence systems. Inbox persistence precedes the task put to avoid marking delivery before its message exists. A crash or task-write failure after inbox persistence can repeat a message; storing status, target, and receipt together does not make the two stores atomic. Dispatch failures retain the task and produce a diagnostic; there is no independent retry timer. This feature does not create or replace a Goal.
## Alternatives considered
**Load every Session at startup.** Session activation is unnecessary for discovering tasks and would couple timer restoration to Agent initialization and conversation size.
**Introduce separate rule, run, scheduler, and delivery packages.** Durable schedules and their saved inbox receipts support task inspection without independent execution records or model completion tracking.
**Write history separately from the task.** The domain exposes independent per-record writes, not a multi-record transaction. Keeping history in the task row makes status, next target, latest receipt, and appended history commit together. A separate history store requires an explicit transactional or recovery design before adopting it.
**Advance before enqueue or promise exactly-once delivery.** Advance-first can lose a message on enqueue failure. Exactly-once delivery needs a shared transaction or a durable idempotency protocol that the two stores do not provide.
**Import every historical Session automatically.** A startup scan would reintroduce the cold-history dependency. Explicit recreation keeps the persisted authority unambiguous; historical decoding preserves access to existing logs.
**Represent daily reminders as 86,400-second intervals.** Fixed elapsed time cannot preserve a local wall-clock rule when offsets change. A distinct Daily record retains the rule needed to resolve each later date. Weekly extends the same decision with an explicit weekday set rather than a 604,800-second interval, so a later offset change still resolves each selected local date.
**Sample offsets manually or recalculate saved targets during decoding.** Temporal supplies maintained calendar and disambiguation behavior without a Schedule-owned offset search. A committed target remains due even when newer time-zone data would resolve the rule differently; only later targets use that data.
## Consequences
**Keep the mock's hourly choice out of the stored vocabulary.** The approved mock lists 每小时整点 beside the daily, weekly and one-shot choices. Hourly and fixed-rate intervals are the same elapsed-time rule, so the card maps that menu slot onto the existing interval kind instead of adding a persisted kind. A dedicated hourly kind would need a new durable variant, its decoder, its target resolution, and its tool schema, all to express what an interval already expresses.
Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and the opt-in overlay remains responsible for enabling Schedule. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
Required coverage includes startup from stored tasks, missing-status normalization, cold Session restoration, latest-only catch-up, inactive-task retention without repeat dispatch, real message receipts, deletion across restart, removal of a deleted task's saved deliveries, queue/persistence failures, and disposal. Wall-clock obligations include gaps and skipped dates, earlier-only overlaps, fractional seconds, UTC-range edges, committed-target and alias preservation, and post-restoration clock rollback. Weekly adds weekday normalization and validation, next-target selection across a gap and an overlap, and stored set preservation. Historical decoders must reject Daily, Weekly, and Cron while Host storage accepts them. Client coverage includes every recurring kind, exact rule time, zone, and weekday set, All/Enabled/Inactive status filters, retained inactive details and original-Session navigation, authoritative refresh after confirmed deletion and notification, empty lists, reconnect, and late responses. Presentation coverage includes the page's New action starting a Session, the transcript card's narrowing of persisted result JSON including an incomplete or replayed result, the header clock's direct open for exactly one task and its count label, the idle-only row mark with the hover-card two-task limit and omission line. History coverage includes legacy byte preservation, append and restart, immutable sent instructions, strict latest-receipt consistency, stable cursor pagination, binding checks, failed persistence, and late page replies during refresh or task changes. Cross-store crash duplication remains an explicit limitation rather than a completion claim.
Name, instruction, and timing coverage includes all supported rule kinds, complete-record conflicts, delivery/save ordering, cancellation, normalized no-ops, unchanged-history persistence, pending controls, draft retention, and collision-free task identity. The [built CLI process test](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) drives public Remote requests and reloads actual task-file bytes in a fresh Host. Its task and receipt seeds establish retained-data inputs, not real creation, delivery, or recorded-model evidence; those require ordinary Session operations and captured model requests.
@@ -0,0 +1,65 @@
# Agent Note: Host 拥有的定时消息
Status: implemented
[English](2026-09-16-host-schedule-storage.md) | 中文
## Problem
定时消息必须能在重启后被发现,无须加载每个 Session。挂在 live Agent 上的 timer 无法投递目标 Session 仍为 cold 的任务。用户还需要在不激活对话的情况下查看待投递和已投递任务;投递后删除任务会丢失这条管理记录。
## Decision
现有 Schedule 包拥有一个 Host storage domain,保存每个任务的原始 Session id、提醒记录、`active` 或 `inactive` 状态,最近一次投递回执,以及已保存的投递历史。该 domain 是保留任务的权威来源。Host scheduler 仅为活动任务恢复 timer;到期任务使用 Session controller 现有的解析操作,在原 Session 中排入普通 follow-up。查询和删除任务不会解析 Agent。已存储但缺少状态的记录规范化为 `active`,不扫描或重建历史 Session。
任务 domain 声明整 unit 布局。任务是权威数据,因此 JSON 后端会把文件不可读、文档损坏、版本不受支持或任务非法作为整批加载失败,而不是解释为删除。恢复失败不会改写 `schedule.json`,初始不存在的文件则作为空 domain 打开;修复该文件后会保留任务身份。`Service.init` 等待存储校验和运行时初始化,管理方法等待同一初始化结果;拥有方卸载时释放迟到的存储资源,而不开始投递。投递调度准入与初始化分开:任务变更提交后、运行时清理开始前,祖先可能关闭 Agent 准入。此类拒绝会记录日志而不自动重试,不会将已持久化的变更变成失败的管理结果。存储非法和清理注册失败仍拒绝启动。
此决策替代[会话内提醒记录](../feature/2026-08-05-durable-web-schedule.zh.md)中的 Session 日志权威来源、仅 live 时激活和只读目录决策。其中显式绝对时间校验、固定速率计算,以及区分排队接纳与模型完成的决策仍然适用。历史 `schedule/change` 的创建、fold 和 invariant 类型通过 `LegacyScheduleRecord` 保持为 After、At 和 Every;独立的宿主解码器接受包含 Daily、Weekly 与 Cron 的当前 `ScheduleRecord`。宿主记录解码器要求已存储的 `title` 去除首尾空白后非空、本身已去除首尾空白且不超过 120 个字符,严格的 task schema 会拒绝整个 domain 的打开,而不是丢弃这样的任务。历史变更解码器容忍缺失的 `title`,以便已写入的 Session 日志仍可读取;该成员存在时,同样按这三项检查校验。storage domain 保持版本 1。历史事件不会填充该 domain,已有 `at` 任务也不会转换为每日或每周规则。加载含有活动旧提醒的 Session 时,会警告需要重新创建提醒。
插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后确认持久投递。一次任务记录 put 存储 `lastDelivery`,追加包含发生目标、确认时间、真实消息 id 和指令快照的发送记录,并更新一次性任务的 `inactive` 状态或重复任务的下一目标和状态。因此,任务状态与其保存的历史共用一次提交。未运行任务仍保留在存储中,且不会再次安排投递;显式删除会阻止后续投递,并把该行连同其已保存记录一并移除。这些记录确认收件箱投递,不表示用户收到消息或模型执行完成。
新任务初始化空的 `deliveryHistory`。缺少该可选字段的任务会展示已有的最近一次回执(如果存在),并标明更早记录不可用。读取不会重写旧任务,也不会虚构已丢失的历史或指令快照。下一次确认投递时,保留已有回执并追加新记录。严格解码拒绝重复消息 id,以及最近一次回执与历史末项不一致的情况。历史按追加顺序排列,不按墙钟排序,因此时钟回拨不会颠倒已确认投递的顺序。
重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。
Daily 是互斥的第四个创建选择器,Weekly 是第五个,Cron 是第六个,三者都不是对 `every_seconds` 或 `at` 的转换。Weekly 选择本地日期 ISO 星期属于其所存集合的每个日期;解码器拒绝未规范化的已存集合,选择器拒绝空、重复、超出范围或非整数的集合。它们的首个目标严格晚于当前时间。缺失的本地时间或日期被跳过;重叠时间选择较早时点,每个日期一次。下一目标晚于投递判断时刻,且其本地日期晚于所选发生时点的日期。仅 UTC 目标限制在 0001–9999 年;边缘处的本地日期可以进入第 0 年或第 10000 年。Session 恢复后,运行时依据当前时钟重新核对到期成员,避免时钟回拨时接纳未来任务。
受维护的 `@js-temporal/polyfill` 0.5.1 使用宿主 IANA 数据解析本地日期及重叠或缺口;Schedule 不拥有偏移量采样逻辑,也不增加包层级。每日或每周记录保存规范化后的 `HH:mm:ss.SSS` 时间、时区、适用的星期集合及已提交的下一 UTC 时点。解码和重启不会重新计算该时点。规范名称变化后,有效时区别名仍可读取;后续目标使用宿主当前的时区数据。
模型 `schedule_list`、Remote `list` 和 Session 顶部右侧的提醒时钟仅显示活动任务。独立的自动化任务页面从侧边栏打开,通过只读 Remote `catalog` 方法读取活动和未运行 Host 任务,不激活 Session 或读取其历史。该时钟在该 Session 没有活动提醒时不显示;恰好有一个时直接打开该任务详情,否则列出提醒供选择。两条路径都打开同一个右侧边栏任务页签,该页签渲染自动化任务页面所用的同一详情组件,因此都不会切换主面板。页签通过导航参数指明任务及其原 Session,并把该绑定带入每个修改操作,因此从页签打开或删除都不会激活 Session。页面提供一行“全部/已开启/未运行”状态筛选,保留未运行任务详情、最近一次投递元数据和固定在位于详情页签条内、与任务操作并排的原 Session 入口。页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面不提供创建表单。导航等待 Session 与归档元数据,解释不可用目标,且不会隐式恢复已归档 Session。任务保留与原 Session 可用性相互独立。Every、Daily、Weekly 和 Cron 均为重复规则;页面只按状态分组。Daily 与 Weekly 规则标签保留保存的本地时间、精确规则时区和所存星期集合,不替换为下一目标显示所用的浏览器时区。显式确认删除复用 `delete`,传入条目的原始 Session 绑定,要求传入绑定与已存任务一致。这项参数校验不是独立的调用者鉴权:模型工具捕获当前 Agent 的 Session,全局用户界面则选择任务的原始绑定。跨会话模型修改政策仍是产品决策,不由存储布局直接决定。无载荷的变更通知使查询失效,重新连接时重新查询以恢复漏掉的通知。删除阻止后续投递,但不撤回已排队消息。
第二层详情将“规则”与“发送记录”分开。打开发送记录时,`history` 请求携带任务的原始 Session 绑定,以及 1–100 的显式分页数量。排他的消息 id 游标用于读取更早回执,新投递追加后不会改变其定位。目录仅保留最近一次回执元数据,不在每次任务列表响应中复制完整历史。任务不存在和游标未知属于独立失败,不是查询成功的空历史。记录视图按需加载,拒绝晚到的过期响应,并将原 Session 导航与查看记录分开。
名称、指令和时间更新与投递、删除共用 FIFO。在该队列内比较完整的已观察记录,可防止旧草稿覆盖已保存规则或投递已推进的目标。这样无须增加持久版本计数器,代价是正常投递改变目标后也会拒绝旧草稿。一次任务 put 替换规则、名称或指令,保留身份、原始 Session 绑定、状态、最近一次回执和已保存历史。请求可以省略时间变更、名称或指令;省略的字段保留已存储值,仅修改名称或指令不会改变已提交目标。归一化后等价的修改保留已提交目标与存储字节。修改后的一次性任务以 At 记录保留身份;固定间隔改变后以接纳保存时刻重新起算;钟表规则改变后以同一接纳保存时刻重新解析首个未来目标,而归一化后等价的规则不产生变更。时间变更可以选择不同的重复类型,新规则按创建时相同的方式、以同一接纳保存时刻计算目标。删除会移除该行及其已保存记录([该反转的后果](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md)),因此运行时不会再调度该任务,`history` 也不再按其原始绑定解析。未运行任务不会被重新启用。
页面使用现有 Host 任务 domain 和调度器,不使用 mock 引擎或 Session projection。名称、指令和运行时间行编辑当前任务的同一份本地草稿,一次保存把该草稿作为携带完整预期记录的单个更新提交;取消恢复已存储值,保存失败保留草稿,未保存的草稿会保留到权威刷新之后,保存已提交与目录刷新失败仍是不同结果。暂停和执行历史操作仍不受支持。生命周期状态与最近一次投递确认不描述模型结果。
客户端的呈现从已有持久化数据派生,而不扩展持久化格式。`schedule_create` transcript(文本记录)卡片从该调用对模型可见的结果 JSON 中读出创建的任务——该 JSON 已经序列化规范任务值——而不是声明 `presentationMeta`:持久化 metadata 通道会为一张仅凭结果即可在 Session 日志回放中重建的卡片增加持久字段,而早于该卡片录制的 Session 无需格式变更即可渲染它。侧栏 Session 行的时钟标记占用 root 作用域的 `sidebar.session.row.leading` slot,而不是请求逐 Session provider,因为 Session 作用域的 slot 会绑定列表中每个 Session,从而激活并保留它;同一个 root 作用域属主约定也供给 `sidebar.session.row.hover`,两者都读取共享的 Host 目录快照 `schedule/catalog()`,不读取 Session 日志。
Session 收件箱持久化和任务 put 发生在两个持久化系统中。收件箱持久化先于任务 put,以免消息尚不存在时就标记已投递。收件箱持久化后崩溃或任务写入失败可能重复发送消息;状态、目标和回执一起存储,并不使两个存储具有原子性。投递失败保留任务并输出诊断;没有独立的重试 timer。此功能不创建或替换 Goal。
## Alternatives considered
**启动时加载每个 Session。** 发现任务不需要激活 Session,这会让 timer 恢复依赖 Agent 初始化和对话大小。
**引入独立的 rule、run、scheduler 和 delivery 包。** 持久 schedule 与已保存的收件箱回执足以支持任务查看,无须独立执行记录或模型完成跟踪。
**将历史与任务分开写入。** domain 提供独立的逐记录写入,不提供多记录事务。将历史保存在任务行内,可使状态、下一目标、最近一次回执和追加历史一起提交。采用独立历史存储之前,需要明确的事务或恢复设计。
**入队前推进,或承诺恰好投递一次。** 先推进可能在入队失败时丢失消息。恰好投递一次需要共享事务或持久幂等协议,这两个存储并不提供。
**自动导入全部历史 Session。** 启动扫描会重新引入对 cold 历史的依赖。显式重建使持久化权威来源保持明确;历史解码保留了现有日志的访问能力。
**以 86,400 秒间隔表示每日提醒。** 固定经过时长无法在偏移量变化时保留本地钟表规则。独立的 Daily 记录保留了解析每个后续日期所需的规则。Weekly 以显式星期集合而非 604,800 秒间隔延续同一决策,因此偏移量变化后仍能解析每个所选本地日期。
**手动采样偏移量或在解码时重新计算已保存目标。** Temporal 提供受维护的日历与消歧行为,无须由 Schedule 实现偏移量搜索。即使较新的时区数据对规则给出不同解析,已提交目标仍然到期;仅后续目标使用该数据。
## Consequences
**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。
任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,Schedule 仍由显式启用的 overlay 加载。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
必要覆盖范围包括从已存储任务启动、缺失状态规范化、冷 Session 恢复、仅补发最新发生时点、保留未运行任务且不重复投递、真实消息回执、重启后的删除结果、删除会移除已保存发送记录、排队或持久化失败,以及销毁。钟表规则的验证要求包括缺口和跳过的日期、仅选择较早时点的重叠、小数秒、UTC 范围边缘、已提交目标和别名保留,以及恢复后的时钟回拨。Weekly 另需覆盖星期集合的规范化与校验、跨缺口与重叠的下一目标选择,以及已存集合的保留。历史解码器必须拒绝 Daily、Weekly 与 Cron,而宿主存储接受它们。客户端覆盖范围包括每种重复规则、精确规则时间、时区和星期集合、全部/已开启/未运行状态筛选、保留未运行任务详情和原 Session 导航、确认删除和通知后的权威刷新、空列表、重新连接和晚到响应。呈现覆盖范围包括页面“新建”操作打开新会话、transcript 卡片从不完整或回放的结果 JSON 中收窄任务、页头时钟在恰好一个任务时直接打开并给出数量标签、仅空闲行标记及其悬浮卡片两个任务上限与遗漏行。历史覆盖范围包括旧文件字节保留、追加与重启、不可变的发送指令、最近回执一致性严格校验、稳定游标分页、绑定校验、持久化失败,以及刷新或切换任务期间晚到的分页响应。跨存储崩溃导致的重复投递仍是明确限制,不能被表述为完成保证。
名称、指令和时间编辑覆盖范围包括所有支持的规则类型、完整记录冲突、投递与保存的顺序、取消、规范化后的无变更、历史持久化不变、等待期间的控件、草稿保留,以及无碰撞的任务标识。[已构建 CLI 进程测试](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) 驱动公开 Remote 请求,并在新 Host 中重新加载实际任务文件字节。任务和回执种子只是已保留数据的输入,不是真实创建、投递或模型录制证据;这些证据需要普通 Session 操作与捕获的模型请求。
@@ -9,11 +9,11 @@
en: 4b02ed98a500205c
zh: e1a075f6a8251059
/agent-note-developer-messages-for-incremental-session-changes/decision:
en: 5fecb076fa475b6d
zh: b59c76222ec00123
en: 986e4806b1d7da88
zh: dedecc5248fdd5f3
/agent-note-developer-messages-for-incremental-session-changes/alternatives-considered:
en: a9ec42fb650d6cd8
zh: c748181c603e675f
/agent-note-developer-messages-for-incremental-session-changes/consequences:
en: 28e92dab15059d0c
zh: 31b4f1955d82c02c
en: e98265bb86e38a2d
zh: a9749feb7d90c22a
@@ -20,7 +20,7 @@ Dynamic tool changes need durable, ordered records before providers can preserve
The V4 codec confines developer roles to the `developer/message` slot, including rejection from inbox and title-request arrays. Native validation checks required developer fields, historical header bindings, and developer-only tool-change blocks while preserving additional JSON properties. Optional metadata additions therefore remain readable under the persistence checker's optional-property rule; additional fields cannot weaken required-field, role, or open-step checks. An ignorable marker does not establish reader support: physical decoding preserves developer payloads until vocabulary-aware admission, while writers and native readers reject malformed known data. Developer admission leaves malformed ordinary message slots to decoder recovery. Installed Session adoption validates surface relationships without converting developer records into released-V3 user records. The [released migration rules](2026-08-31-released-session-format-migrations.md) continue to protect committed predecessor generations.
The producer requirement has one scoped exception: finalized Session V4 reserves the persisted representation needed by issue #4146. Waiting for provider and UI implementations would couple the format release to those independent integrations. The reservation permits storage and validation only; no shipped profile emits developer records, and unsupported consumers throw. Production emission requires provider, UI, and compaction support together.
Session V4 reserves the persisted representation needed by issue #4146. Production emission, provider projection, UI presentation, and compaction integration are described in the [dynamic tool update decision](2026-09-20-dynamic-tool-updates.md).
## Alternatives considered
@@ -32,4 +32,4 @@ The producer requirement has one scoped exception: finalized Session V4 reserves
## Consequences
Session V4 persists the tool-change representation for issue #4146. Automatic emission, provider serialization, deferred loading, and UI presentation are intentionally deferred so this type-and-persistence change does not block the V4 merge. Both DeepSeek protocols and pi-ai throw for developer history and deferred-loading requests; Chat and Trajectory throw for developer events. Shipped model-output and SDK snapshot scenarios contain no developer records.
Session V4 persists the tool-change representation for issue #4146. Emission, route projection, DeepSeek serialization, and Chat and Trajectory presentation are owned by the [dynamic tool update decision](2026-09-20-dynamic-tool-updates.md).
@@ -20,7 +20,7 @@ Status: implemented
V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并拒绝 inbox 与标题请求数组中的该角色。原生验证检查 developer 必需字段、历史请求头绑定及工具变更块仅限 developer 的约束,同时保留附加 JSON 属性。因此,可选元数据添加符合持久化检查器的可选属性规则,仍可读取;附加字段不能削弱必需字段、角色或打开 step 的检查。可忽略标记不代表读取方支持该事件:物理解码保留 developer 载荷,直到按词表执行接纳;写入方和原生读取方仍拒绝格式错误的已知数据。developer 接纳逻辑将格式错误的普通消息字段留给解码器恢复。已安装的 Session 接纳逻辑验证 surface 关系,不把 developer 记录转换为已发布 V3 的 user 记录。[已发布格式迁移规则](2026-08-31-released-session-format-migrations.zh.md)继续保护已提交的前代文件。
产生方要求有一个限定例外:已定稿 Session V4 为 issue #4146 保留所需的持久化表示。等待提供方与 UI 实现会将格式发布与这些独立集成耦合。该预留仅允许存储和验证;已提供的 profile 不发出 developer 记录,不支持的消费者会抛出错误。生产环境发出这些记录要求提供方、UI 与压缩支持一起落地。
Session V4 预留 issue #4146 所需的持久表示。生产发出、提供方投影、UI 展示和压缩集成见[动态工具更新决策](2026-09-20-dynamic-tool-updates.zh.md)。
## Alternatives considered
@@ -32,4 +32,4 @@ V4 编解码器将 developer 角色限定在 `developer/message` 字段内,并
## Consequences
Session V4 为 issue #4146 持久化工具变更表示。自动发出事件、提供方序列化、延迟加载与 UI 展示有意留待后续实现,使此类型与持久化变更不阻塞 V4 合并。DeepSeek 两种协议与 pi-ai 对 developer 历史及延迟加载请求抛出错误;Chat 与 Trajectory 对 developer 事件抛出错误。已提供的模型输出与 SDK 快照场景不包含 developer 记录。
Session V4 持久化 issue #4146 的工具变更表示。发出、路由投影、DeepSeek 序列化及 Chat 和 Trajectory 展示由[动态工具更新决策](2026-09-20-dynamic-tool-updates.zh.md)负责。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-20-device-local-shortcut-preferences.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-20-device-local-shortcut-preferences.md
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation:
en: 0f033ac1e9acec35
zh: 06f3bf59003446ea
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/problem:
en: d1d3ef4f6e66e5a7
zh: 5691f2b106617a86
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/decision:
en: 21f8da8d396c3f55
zh: 46de3df8141bcb0a
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/alternatives-considered:
en: 202dcaddc6c9c400
zh: 3f8e3576c2324636
/agent-note-keep-shortcut-preferences-device-local-and-commit-before-activation/consequences:
en: 9dcfdef13faa374f
zh: 1951198555fcc9ea
@@ -0,0 +1,27 @@
# Agent Note: Keep shortcut preferences device-local and commit before activation
Status: implemented
English | [中文](2026-09-20-device-local-shortcut-preferences.zh.md)
## Problem
Keyboard bindings depend on the receiving device's operating system and browser restrictions. A Host can serve several devices, while a local save can fail after a user records a new combination. Activating an unpersisted binding makes the interface disagree with the next application launch. Rewriting unreadable or future-version preferences can destroy choices an older client cannot interpret.
## Decision
[The shortcut service](../../../../packages/client/shortcuts/README.md) stores only versioned overrides per runtime/platform. Web owns origin-local browser storage; Electron main owns `userData/keybindings.json`, independently of Harness home. Both adapters share validation and transaction logic. Successful persistence publishes one accepted configuration from which command matching, keycaps, and native interception derive.
Missing commands retain dormant overrides. Explicit overrides win over newly introduced defaults; conflicting explicit overrides are all disabled. Unreadable configuration blocks ordinary edits and preserves the last accepted values. Restore All clears only the accepted current profile and cannot overwrite unreadable or future-version data.
## Alternatives considered
**Host settings.** A server's operating system and settings scope do not identify the visitor's device or browser restrictions. Host storage would also couple remote visitors' preferences.
**Optimistic activation.** Updating bindings before persistence makes a failed save appear successful and leaves restart behavior inconsistent with the visible controls.
**Cross-tab atomic merging.** localStorage has no compare-and-swap transaction. Web rereads before each operation and rejects known stale drafts, but accepts last-writer-wins for simultaneous writes. Electron main serializes edits and rejects stale revisions.
## Consequences
Devices and Web/Desktop do not synchronize bindings automatically. Repairing unreadable preferences requires correcting the stored document; application edits preserve those bytes. Browser concurrency does not promise lossless merging. Atomic Desktop replacement uses the shared writer's Windows retry behavior without promising fsync durability. Deterministic transaction, browser-storage, and Desktop-file tests cover failure retention, conflicts, stale drafts, and refusal to replace unreadable data; platform keyboard acceptance remains separate from persistence correctness.
@@ -0,0 +1,27 @@
# Agent Note: 快捷键偏好按设备保存,提交后才激活
Status: implemented
[English](2026-09-20-device-local-shortcut-preferences.md) | 中文
## 问题
快捷键取决于接收输入设备的操作系统和浏览器限制。一个 Host 可服务多个设备,而用户录入新组合后,本地保存仍可能失败。激活未持久化的绑定会使界面与下一次启动不一致。重写不可读或未来版本的偏好可能破坏旧客户端无法解释的选择。
## 决策
[快捷键服务](../../../../packages/client/shortcuts/README.zh.md) 只保存按运行端/平台区分的版本化覆盖。Web 使用同 origin 的浏览器存储;Electron main 管理 `userData/keybindings.json`,与 Harness home 独立。两端适配器共享校验和事务逻辑。持久化成功后发布一个已接受配置,命令匹配、键帽和原生拦截都由此派生。
未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。全部恢复只清除已接受的当前平台覆盖,不会覆盖不可读或未来版本的数据。
## 考虑过的替代方案
**Host 设置。** 服务端操作系统和设置范围不能标识访问者设备或浏览器限制,Host 存储还会耦合远程访问者的偏好。
**乐观激活。** 在持久化之前更新绑定会让保存失败看起来像成功,并使重启行为与可见控件不一致。
**跨标签页原子合并。** localStorage 没有比较并交换事务。Web 每次操作前重新读取并拒绝已知过期草稿,但同时写入采用最后成功写入生效。Electron main 串行处理编辑并拒绝旧 revision。
## 影响
不同设备以及 Web/Desktop 不自动同步绑定。修复不可读偏好需要更正存储文档;应用内编辑保留这些原始内容。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿以及拒绝替换不可读数据;平台键盘验收与持久化正确性分别验证。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-20-dynamic-tool-updates.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-20-dynamic-tool-updates.md
/agent-note-active-tool-headers-and-stateful-request-projection:
en: c40dd7568bba391f
zh: 295ce33341e0aab7
/agent-note-active-tool-headers-and-stateful-request-projection/problem:
en: 97fe58a13135b4a1
zh: 7490373a8d941da5
/agent-note-active-tool-headers-and-stateful-request-projection/decision:
en: 0c40a9283650c643
zh: 91a18d752d09a9ce
/agent-note-active-tool-headers-and-stateful-request-projection/alternatives-considered:
en: b78785f2a603eba8
zh: b8c268228da7e2e7
/agent-note-active-tool-headers-and-stateful-request-projection/consequences:
en: 93c32b795b71a1cc
zh: d42b5c563341c898
@@ -0,0 +1,37 @@
# Agent Note: Active tool headers and stateful request projection
Status: implemented
English | [中文](2026-09-20-dynamic-tool-updates.zh.md)
## Problem
Plugin and MCP tools change during conversations. Providers differ in whether they activate deferred definitions or remove tools through history. Storing retained provider declarations as active tools makes comparisons repeat removals and miss restorations.
## Decision
`request/header.tools` records assembled active tools independently of model capability. The loop compares names against the preceding header and emits one `developer/message` with additions and removals under the `tool-registry` source. Additions reference the new header through `headerSeq`. Definition changes remain header changes; unchanged names emit no update. Existing header equality and request-series rules remain in force.
`Session.toolHistory()` caches a capability-independent fold of committed headers and developer messages. First access restores inherited events; later reads consume only unseen events. Immutable snapshots carry initial declarations and ordered update identities with additions resolved through their historical headers. Explicit series starts and changed retained definitions rebuild declarations. Restoring a retained name with an unchanged definition continues the series: the projection keeps the declaration unchanged and lets the recorded addition re-offer the tool after its earlier removal, so a removal never disables a declared tool and a loading mode never rewrites an earlier declaration.
Loop and compaction requests carry this snapshot as `GenerateOptions.toolHistory`. At adapter dispatch, `projectToolUpdates` uses the prepared route's `toolUpdate`. Unsupported routes receive active definitions without `deferLoading` or developer messages. Supported routes defer historical additions; `in-history` retains removed definitions and removal blocks, while `addition-only` omits both. Only current-series update identities survive. Missing history or a request prefix omitting recorded updates uses current declarations without developer updates. Explicit deferred loading remains available on capable routes.
DeepSeek translates projected updates into system-role `tool_addition` and `tool_removal` blocks and `defer_loading` declarations, sending the tool-changes beta header when update blocks occur. The default `deepseek-flash` entry declares `addition-only`; pi-ai declares no mode. Chat and Trajectory display developer messages as context nodes, using localized tool-change notices: single changes show the tool name; multiple changes show counts and expandable name lists.
## Alternatives considered
**Store provider declarations in headers.** Retained removed definitions are not active tools. Mixing those meanings makes durable updates capability-dependent and breaks change detection.
**Reconstruct definitions in adapters from messages alone.** Addition messages contain names, not schemas or event-level header references. Session owns that historical relationship; adapters translate projected requests.
**Scan the complete log on every request.** The cached fold consumes new events and supplies stable snapshots to ordinary and auxiliary calls without adding arbitrary synchronous history readers.
**Reject same-name schema changes during tool registration.** This restriction is not enforced. Immediate failure for a plugin registering an incompatible tool requires comparing the new registration with historical definitions of tools called on each affected current surface, while excluding calls removed by compaction. That registration-time historical and scope-aware check is deferred because of its implementation complexity. Same-name schema changes remain allowed; they rebuild declarations and invalidate prefix reuse even on `in-history` routes.
## Consequences
Tool lists and logical update events are capability-independent; only outgoing declarations and messages vary. Calls to removed retained tools fail through the existing `UNKNOWN_TOOL` path. Changed definitions and incomplete auxiliary prefixes sacrifice prefix reuse for coherent declarations; unchanged restoration keeps the cached prefix on `in-history` routes only while the request series continues. Routes supporting tool updates and in-history system prompts preserve earlier messages when tools are added alongside a changed prompt or after an earlier prompt update. Routes without tool-update support still consolidate on tool changes; explicit series starts and surface replacements still consolidate on every route. No persisted fields or Session format revision are added.
The authored [headless prompt/tool scenario](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml) pins an appended prompt beside a deferred tool addition and verifies that the first request's messages stay unchanged. It shares the stable-prompt scenario's tool lifecycle and schema expectations.
The [real DeepSeek SDK test](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) measures the first request after a native tool addition with both unchanged and simultaneously updated instructions. Unique user history prevents a shared system-prompt cache hit from satisfying the preceding-input assertion. Removal checks tool availability without requiring cache retention; each case disables retries.
@@ -0,0 +1,37 @@
# Agent Note: 有效工具请求头与有状态请求投影
Status: implemented
[English](2026-09-20-dynamic-tool-updates.md) | 中文
## Problem
插件和 MCP 工具会在对话期间变化。不同提供方对通过历史激活延迟定义或移除工具的支持不同。把为提供方保留的声明当作有效工具存储,会使比较重复记录移除并遗漏恢复。
## Decision
`request/header.tools` 记录组装得到的有效工具,不依赖模型能力。循环将名称与前一请求头比较,以 `tool-registry` 来源发出一条包含添加和移除的 `developer/message`。添加通过 `headerSeq` 引用新请求头。定义变化仍体现为请求头变化;名称未变时不发出更新。现有请求头相等判断和请求序列规则保持有效。
`Session.toolHistory()` 缓存对已提交请求头和 developer 消息的归约,不依赖模型能力。首次访问恢复继承事件;后续读取仅处理新增事件。不可变快照携带初始声明和有序更新标识,添加定义通过历史请求头解析。显式新序列和保留定义变化会重建声明。以未变化的定义恢复保留名称时序列继续:投影保持声明不变,由记录的添加在先前移除之后重新提供该工具,因此移除不会禁用已声明的工具,加载模式也不会改写先前声明。
循环和压缩请求通过 `GenerateOptions.toolHistory` 携带此快照。适配器分发时,`projectToolUpdates` 使用已准备路由的 `toolUpdate`。不支持的路由接收有效定义,不携带 `deferLoading` 或 developer 消息。支持的路由延迟历史添加;`in-history` 保留已移除定义和移除块,`addition-only` 则省略两者。仅保留当前序列的更新标识。缺少历史或请求前缀遗漏已记录更新时,使用当前声明且不发送 developer 更新。显式延迟加载在支持的路由上仍然可用。
DeepSeek 把投影后的更新转换为 system 角色的 `tool_addition` 和 `tool_removal` 块以及 `defer_loading` 声明,在存在更新块时发送工具变更 beta 请求头。默认 `deepseek-flash` 条目声明 `addition-only`;pi-ai 不声明模式。Chat 和 Trajectory 将 developer 消息显示为上下文节点,工具变更使用本地化通知:单项变更显示工具名,多项变更显示数量及可展开的名称列表。
## Alternatives considered
**在请求头存储提供方声明。** 为提供方保留的已移除定义不是有效工具。混合两种含义会使持久更新依赖能力,并破坏变更检测。
**仅凭消息在适配器重建定义。** 添加消息包含名称,不包含模式或事件级请求头引用。Session 拥有该历史关系;适配器转换投影后的请求。
**每次请求扫描完整日志。** 缓存归约处理新事件,为普通和辅助调用提供稳定快照,不增加任意同步历史读取接口。
**在工具注册时拒绝同名 schema 变化。** 当前不执行此限制。要让注册不兼容工具的插件立即收到异常,需要将新注册与各受影响当前 surface 中已调用工具的历史定义比较,同时排除已被压缩移除的调用。由于实现复杂,注册时结合历史和作用域的检查留待后续实现。同名 schema 变化仍被允许;它们会重建声明,即使在 `in-history` 路由上也会使前缀复用失效。
## Consequences
工具列表和逻辑更新事件不依赖能力;只有发出的声明和消息不同。调用已移除但保留声明的工具时,通过现有 `UNKNOWN_TOOL` 路径失败。定义变化和不完整辅助前缀为保证声明一致而放弃前缀复用;仅当请求序列继续时,未变化的恢复才在 `in-history` 路由上保留缓存前缀。同时支持工具更新和历史内系统提示词的路由,在工具添加与提示词变化同时发生或晚于此前提示词更新时,保留此前消息。不支持工具更新的路由仍在工具变化时归并提示词;所有路由仍在显式新序列和 surface 替换时归并。不增加持久字段或 Session 格式版本。
手写的[无界面提示词/工具场景](../../../../snapshots/session/dynamic-tool-prompt-updates/snapshot.yml)钉住追加提示词与延迟工具添加同时发生的行为,并验证首次请求的消息保持不变。它复用稳定提示词场景的工具生命周期和 schema 期望值。
[真实 DeepSeek SDK 测试](../../../../apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts) 分别在指令不变和指令同时更新时测量原生工具新增后的首次请求。独有的用户历史确保仅命中共享系统提示词缓存无法满足前序输入断言。移除场景检查工具可用性,不要求保留缓存;每个用例均禁用重试。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-21-desktop-page-close-shortcuts.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md
/agent-note-desktop-close-shortcuts-follow-page-focus:
en: e6ce0bc9592af8ba
zh: b43fa4c48c634e27
/agent-note-desktop-close-shortcuts-follow-page-focus/problem:
en: feb9d491ad4a9016
zh: 291f0e6c913b0b19
/agent-note-desktop-close-shortcuts-follow-page-focus/decision:
en: c587d88575b6ce7e
zh: 2287cf4d6d2e7587
/agent-note-desktop-close-shortcuts-follow-page-focus/alternatives-considered:
en: 10c7bb2fc87bdd16
zh: 796ecde8bcf09d34
/agent-note-desktop-close-shortcuts-follow-page-focus/consequences:
en: aea659e749840caa
zh: e75d757165055a55
@@ -0,0 +1,27 @@
# Agent Note: Desktop close shortcuts follow page focus
Status: implemented
English | [中文](2026-09-21-desktop-page-close-shortcuts.zh.md)
## Problem
The [shortcut PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) gives the close command two targets: the focused right-sidebar page, or the Desktop window when no page can close. Electron's native close role bypasses the page owner. On Windows, closing the last window also quits the Desktop instance and stops its Host tasks, so this fallback is a lifecycle choice rather than a menu-label change.
## Decision
The sidebar page owner resolves close against live focus and captures the target's Session, pane, tab occurrence, and navigation revision. It closes that page through its resource cleanup handler, or collapses the sole docked guide. Stale sidebar targets do not fall back to another page or the window. Without a closeable page, Desktop requests native window closure; Web has no window fallback.
The macOS File menu routes Close Page or Window through the same owner and displays the accepted single-key binding. Native closure requires the current configuration revision, a focused and enabled product window, and inactive shortcut recording. The existing window lifecycle applies without a shortcut-specific confirmation: macOS keeps the application alive after its last window closes; Windows and Linux quit and stop the Host.
This decision owns File-menu and close behavior in place of the close-role choice in [the standard macOS menu note](../bug-fix/2026-09-16-desktop-window-menus.md). That note retains the rationale for native Window and application hide commands. [Shortcut preference persistence](2026-09-20-device-local-shortcut-preferences.md) owns accepted bindings and revision publication.
## Alternatives considered
**Use Electron's native close role.** It closes the window without asking the focused page owner and cannot preserve page cleanup or the accepted custom binding's routing.
**Disable the Windows window fallback.** This avoids a task-stopping close shortcut but omits the PRD's explicit Desktop fallback. The product keeps native window-close semantics; users who need the application to remain running can minimize it.
## Consequences
Close behaves consistently across the shortcut and macOS File menu. On Windows and Linux, pressing it outside a closeable sidebar page can stop active tasks by quitting the instance. The command does not add a background Host lifetime or a task-aware shutdown confirmation. Desktop keyboard tests cover revision and focus guards; sidebar focus tests cover captured page identity and stale targets; startup tests cover the platform menu declarations.
@@ -0,0 +1,27 @@
# Agent Note: Desktop 关闭快捷键跟随页面焦点
Status: implemented
[English](2026-09-21-desktop-page-close-shortcuts.md) | 中文
## 问题
[快捷键 PRD](https://trtgsjkv6r.feishu.cn/wiki/APvtwgbztiBLzvk9DM4cQbccnhf) 为关闭命令规定了两类目标:获得焦点的右侧栏页面,或没有可关闭页面时的 Desktop 窗口。Electron 原生 close role 绕过页面 owner。Windows 关闭最后一个窗口还会退出 Desktop 实例并停止其 Host 任务,因此该回退属于生命周期选择,不只是菜单标签变更。
## 决策
侧栏页面 owner 根据实时焦点解析关闭操作,并捕获目标的 Session、窗格、tab occurrence 和导航 revision。它通过资源清理处理器关闭该页面,或收起唯一的停靠引导页。过期侧栏目标不会回退到其他页面或窗口。没有可关闭页面时,Desktop 请求原生窗口关闭;Web 没有窗口回退。
macOS File 菜单通过同一个 owner 路由“关闭页面或窗口”,并显示已接受的单键绑定。原生关闭要求配置 revision 仍为当前值、产品窗口已聚焦且启用,并且没有在录制快捷键。现有窗口生命周期直接生效,不增加快捷键专用确认:macOS 最后一个窗口关闭后保留应用;Windows 和 Linux 退出并停止 Host。
本决策接管[标准 macOS 菜单记录](../bug-fix/2026-09-16-desktop-window-menus.zh.md)中 close role 选择所涉及的 File 菜单和关闭行为。该记录保留原生 Window 与应用隐藏命令的理由。[快捷键偏好持久化](2026-09-20-device-local-shortcut-preferences.zh.md)负责已接受绑定及 revision 发布。
## 考虑过的替代方案
**使用 Electron 原生 close role。** 它不询问获得焦点的页面 owner 就关闭窗口,无法保留页面清理和已接受自定义绑定的路由。
**禁用 Windows 窗口回退。** 这能避免关闭快捷键停止任务,但会遗漏 PRD 明确要求的 Desktop 回退。产品保留原生关窗语义;需要应用继续运行时,用户可以最小化窗口。
## 影响
快捷键与 macOS File 菜单的关闭行为一致。在 Windows 和 Linux 上,焦点不在可关闭侧栏页面时按下该快捷键,可能因退出实例而停止活动任务。该命令不增加后台 Host 生命周期或感知任务状态的退出确认。Desktop 键盘测试覆盖 revision 和焦点保护;侧栏焦点测试覆盖捕获的页面身份和过期目标;启动测试覆盖各平台菜单声明。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-22-platform-browser-storage.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-22-platform-browser-storage.md
/agent-note-account-scoped-platform-browser-storage:
en: ade088e230a39dd2
zh: 437a9be5fe2ef704
/agent-note-account-scoped-platform-browser-storage/problem:
en: 84cfcf61f58fab58
zh: 14844d5e3d3638fe
/agent-note-account-scoped-platform-browser-storage/decision:
en: 7c8ec7ea318f3689
zh: 2c45ac28670dfda1
/agent-note-account-scoped-platform-browser-storage/alternatives-considered:
en: 101fa0ff0b01b096
zh: 6c6b0a4136531d6b
/agent-note-account-scoped-platform-browser-storage/consequences:
en: 0aa0a16abba90b43
zh: 6fcb59f3ecea2e54
@@ -0,0 +1,23 @@
# Agent Note: Account-scoped Platform browser storage
Status: implemented
English | [中文](2026-09-22-platform-browser-storage.zh.md)
## Problem
Embedded Platform notices store dismissal in localStorage. Disposable browser partitions lose that preference whenever the view closes.
## Decision
Desktop partitions browser storage by a SHA-256 hash of the Platform origin and stable account ID. Persistent partitions retain localStorage page preferences across view and application lifetimes, including sign-out and later sign-in to the same account. Raw account IDs and tokens do not appear in partition names. While the Host has no account ID from a successful profile read, the view uses a disposable partition, so preparing a Platform session never waits for a profile request. An ID that arrives for the credential already open leaves the mounted disposable document in place; the next open uses the account-scoped partition.
A persistent partition keeps only localStorage; opening one clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads, while a disposable partition clears all of its storage. Closing a view destroys its document, detaches the credential-bearing request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install awaits it before the installer takes over the exit; shutdown reports a failure instead of blocking the quit. A failed cleanup rejects that open and keeps its account from opening again until a later cleanup succeeds, while other accounts keep opening. Host-only credentials still enter the trusted page through the existing preload and are not written to browser storage by the bridge.
## Alternatives considered
One shared partition mixes account preferences. Token-derived names discard preferences when credentials change. A notice-specific native API would duplicate Platform preference ownership and require coordinated frontend changes. Clearing storage only when a view closes leaves authentication from an unclean exit readable by the next document.
## Consequences
Preferences remain local to this Desktop browser-data directory and are not synchronized across devices. Platform scripts remain trusted to handle the credential they receive; persistent site storage is not a credential vault. Storage for a signed-out account stays on disk until that account signs in again or the browser-data directory is removed. The real Electron regression checks notice dismissal across view recreation, account switches, and process restart, and that cookies, Cache Storage, and IndexedDB are cleared, a page beforeunload handler does not hold the close, a disposable session neither inherits nor retains preferences, and cookie residue from a previous process is cleared.
@@ -0,0 +1,23 @@
# Agent Note: 按账号隔离的 Platform 浏览器存储
Status: implemented
[English](2026-09-22-platform-browser-storage.md) | 中文
## Problem
内嵌 Platform 通知将关闭状态写入 localStorage。一次性浏览器分区会在每次关闭视图后丢失该偏好。
## Decision
Desktop 使用 Platform 来源和稳定账号 ID 的 SHA-256 哈希划分浏览器存储。持久分区跨视图和应用生命周期保留 localStorage 页面偏好,包括退出登录后再次登录同一账号。分区名不包含原始账号 ID 或 token。在 Host 尚未从成功的资料读取获得账号 ID 期间,视图使用一次性分区,因此准备 Platform 会话不会等待资料请求。同一凭证下账号 ID 迟到时,已挂载的一次性文档保持不变;下次打开使用账号分区。
持久分区只保留 localStorage;打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态,而一次性分区会清理其全部存储。关闭视图会销毁文档、移除携带凭证的请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待;退出流程上报清理失败,而不阻塞退出。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号再次打开,其他账号不受影响。仅限 Host 的凭证仍通过现有 preload 传入受信任页面,bridge 不会将其写入浏览器存储。
## Alternatives considered
共享分区会混用账号偏好。以 token 命名会在凭证变化时丢失偏好。专用于通知的原生 API 会重复承担 Platform 偏好管理职责,并要求前端同步修改。只在关闭视图时清理,会让异常退出遗留的认证被下一个文档读取。
## Consequences
偏好仅保存在此 Desktop 浏览器数据目录中,不跨设备同步。Platform 脚本仍须可信地处理收到的凭证;持久站点存储不是凭证保险库。已退出账号的存储会保留在磁盘上,直到该账号再次登录或浏览器数据目录被删除。真实 Electron 回归测试验证视图重建、账号切换和进程重启后的通知关闭状态,并断言 Cookie、Cache Storage 和 IndexedDB 已清理、页面 beforeunload 处理函数不会阻止关闭、一次性会话既不继承也不保留偏好,以及上一进程遗留的 Cookie 已清除。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-22-schedule-hard-delete-and-bounded-history.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-22-schedule-hard-delete-and-bounded-history.md
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history:
en: 17a73230ad22ec0b
zh: 5bcf27d7c0e86764
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/problem:
en: 98e82a0add324882
zh: c10e85a3f2a522f8
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/decision:
en: 56328745115714b5
zh: f30d39f59aa06a13
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/alternatives-considered:
en: 0c4eb01054be1141
zh: 95980f8097fa36fd
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/consequences:
en: 05121f4b77b8ee12
zh: 1751c9b1a752ce6b
@@ -0,0 +1,33 @@
# Agent Note: Schedule deletes task rows and bounds delivery history
Status: implemented
English | [中文](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md)
## Problem
The Schedule domain is one whole-unit JSON document that the backend rewrites on every mutation, so every retained byte in a task row grows every write. Saved delivery records were unbounded: each acknowledgment appended to the array, and a deleted task kept its row and records so the open detail could still read them. The document's size therefore followed a task's delivery count, and deletion added a state every reader had to skip.
## Decision
Deletion is hard deletion. `ScheduleService.delete` removes the storage row through `tasks.delete`, so the task stops scheduling, leaves `list` and `catalog`, and `history` answers `schedule_not_found` for the same `(sessionId, id)`; the row's saved delivery records are removed with it. The task schema has no `deleted` field, and no client surface offers a recycle bin or a read of an already deleted task's records.
Delivery history is bounded by plugin `Config`: `deliveryHistoryDays` (default 30, 1–3650) and `deliveryHistoryRecords` (default 200, 1–10000). `appendDelivery` applies both bounds on the only write path. It keeps the records whose `deliveredAt` is at or after the appended receipt's `deliveredAt` minus the configured day window, keeps the newest `deliveryHistoryRecords` of those, and always retains the appended latest receipt; a pruned window sets `earlierRecordsUnavailable` and `earlierRecordsPruned`, which later appends preserve. The latter flag is optional in stored rows and confirms actual removal; legacy unavailability alone does not imply pruning. The history response includes the current retention limits, and the Client shows the cleanup notice only for confirmed pruning after loading the final nonempty page.
The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision: one authoritative version-1 task domain, timer restoration for active tasks only, cold-Session delivery through the Session controller, the catalog and detail surfaces, and the historical-versus-Host decoder split. Its status vocabulary is `active` or `inactive`; `inactive` is the sole non-active name, and the client filter row reads All, Enabled, and Inactive. That note stays active: this supersession is partial, so it is neither archived nor frozen, and it remains the owner of everything above while the deletion and retention decisions it recorded are replaced here.
## Alternatives considered
**Keep the retained `deleted` row.** The flag existed so `history` could answer after deletion. Nothing read those records after deletion, the row kept every prompt snapshot in the storage document, and the state had to be excluded by hand from `list`, `catalog`, and the timer. Removing the row makes deletion mean one thing.
**Bound only by record count.** A count cap alone keeps arbitrarily old receipts when deliveries are sparse, and it drops a burst of recent ones when they are not. The day window bounds staleness while the cap bounds the document.
**Measure the window from the task instead of from each receipt.** Anchoring the window to the task's creation or committed target would discard a newly written receipt of an old task. Measuring back from the appended receipt's `deliveredAt` keeps the window anchored to the data being kept.
**Keep history unbounded and let operators clean up.** The domain publishes one whole-unit document per write, so unbounded history makes the storage document's size a function of delivery counts. Retention is a bound the storage layout requires, not a retention preference.
## Consequences
Deletion now offers no recovery: a deleted task's saved deliveries cannot be read back, and a task that still needs its full history must be read before deletion. Configured retention also discards receipts the task once had; the retained flag records that a window was pruned rather than pretending the history is complete.
In exchange, the persisted task set has one deletion semantics, the storage document's size is bounded by configuration instead of by delivery counts, and `inactive` is the only non-active lifecycle name. The plugin `Config` is the place to raise the bounds when a deployment needs longer delivery history.
@@ -0,0 +1,33 @@
# Agent Note: Schedule 删除任务行并限制投递历史
Status: implemented
[English](2026-09-22-schedule-hard-delete-and-bounded-history.md) | 中文
## Problem
Schedule domain 是后端每次变更都会重写的单个整 unit JSON 文档,因此任务行中保留的每个字节都会随每次写入增长。已保存的发送记录没有上限:每次确认都会向数组追加,而被删除的任务会保留其行与记录,以便已打开的详情仍能读取。于是文档大小随任务的投递次数变化,删除还引入了一个所有读取方都必须跳过的状态。
## Decision
删除是硬删除。`ScheduleService.delete` 通过 `tasks.delete` 移除存储行,因此任务停止调度、离开 `list` 与 `catalog`,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`;该行已保存的投递记录随行一并移除。任务 schema 没有 `deleted` 字段,也没有任何界面提供回收站或读取已删除任务记录的能力。
投递历史受插件 `Config` 限制:`deliveryHistoryDays`(默认 30,1–3650)与 `deliveryHistoryRecords`(默认 200,1–10000)。`appendDelivery` 在唯一的写入路径上同时应用这两个上限。它保留 `deliveredAt` 不早于「本次追加回执的 `deliveredAt` 减去配置的天数窗口」的记录,在其中保留最新的 `deliveryHistoryRecords` 条,并始终保留本次追加的最近一次回执;发生裁剪时设置 `earlierRecordsUnavailable` 和 `earlierRecordsPruned`,后续追加会保留这两个标志。后者在存储行中可选,用于确认实际删除;仅有旧格式的不可用标志不表示发生过裁剪。历史响应携带当前保留上限,客户端仅在加载完最后一页且历史非空、确认裁剪时显示清理提示。
[Host-owned scheduled messages](2026-09-16-host-schedule-storage.zh.md) 笔记保留其余决策:唯一的权威版本 1 任务 domain、只为活动任务恢复定时器、通过 Session controller 投递到冷会话、目录与详情界面,以及历史解码器与宿主解码器的划分。其状态词汇为 `active` 或 `inactive`;`inactive` 是唯一的非活动名称,客户端筛选行为全部、活动和未运行。该笔记保持活动状态:这是一次部分取代,因此它既未归档也未冻结,仍是上述内容的归属笔记,而它记录的删除与保留决策由本笔记取代。
## Alternatives considered
**保留带 `deleted` 标志的任务行。** 该标志的存在是为了让 `history` 在删除后仍能回答。删除后没有任何代码读取这些记录,而该行把每个提示文本快照都留在存储文档中,还必须手工把它排除在 `list`、`catalog` 和定时器之外。移除该行让删除只有一种含义。
**只按条数设上限。** 仅有条数上限时,投递稀疏的任务会保留任意久远的回执,投递密集时又会丢弃近期的一批回执。天数窗口限制陈旧程度,条数上限限制文档大小。
**以任务而非每条回执为窗口起点。** 把窗口锚定在任务的创建时间或已提交目标上,会丢弃旧任务刚写入的回执。从本次追加回执的 `deliveredAt` 向前计算,使窗口锚定在所要保留的数据上。
**保留无上限的历史,由运维清理。** 该 domain 每次写入都发布整个 unit 文档,因此无上限的历史会让存储文档的大小随投递次数增长。保留上限是存储布局所要求的约束,而不是保留策略偏好。
## Consequences
删除不再提供恢复能力:已删除任务的已保存投递无法再读回,仍需要完整历史的必须在删除前读取。配置的保留策略也会丢弃任务曾经拥有的回执;保留的标志记录窗口发生过裁剪,而不是假装历史完整。
换取的是:持久任务集合只有一种删除语义,存储文档大小由配置而非投递次数决定,且 `inactive` 是唯一的非活动生命周期名称。当某个部署需要更长的投递历史时,应提高插件 `Config` 中的上限。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-23-schedule-local-menu.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.md
/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client:
en: a9ed8f9cf0bdfd66
zh: ff11822efe6b48b7
/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/problem:
en: 076e6ca799bf2045
zh: d3f87030015cdf3c
/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/decision:
en: c35ab9fa2549866d
zh: 01855f87b884dc93
/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/alternatives-considered:
en: 3648e55bb76a4381
zh: 83c98e0789a6df9c
/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/consequences:
en: f6380c38e35bce37
zh: 1379ff1763d0eb10
/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/testing:
en: f1de601175678a9a
zh: 58fddec29a462b98
@@ -0,0 +1,43 @@
# Agent Note: Own the Schedule detail menus inside the Schedule client
Status: implemented
English | [中文](2026-09-23-schedule-local-menu.zh.md)
## Problem
The Automation tasks detail needs a menu whose list carries a pinned control: the Time zone row's search field must stay above the scrolling zone rows. The shared `Menu` in `@deepseek-ai/dsh-client-ui-primitives` renders rows, separators, labels, a selection mark, and a keyboard walk, but it has no header slot. The Delivery records tab shows a saved record's message id behind an info glyph, and the reader must be able to read and copy that id.
Serving either need by extending `ui-primitives` puts one feature's behavior into the package every client plugin consumes. The same branch had already removed unrelated `ui-layout` and `ui-sidebar-right` edits after review asked why a Schedule change touched them, and a shared primitive extended for a single caller still costs every future reader of that primitive.
## Decision
`packages/client/ui-schedule` owns its menu. `src/client/TaskMenu.tsx` with `TaskMenu.module.css` holds a schedule-local copy that renders rows, separators, labels, disabled and destructive rows, a trailing `selectedId` mark, the pointer and keyboard walk, portaled placement, the pinned `header` slot, and the `data-menu-field` focus handover. It imports the shared `useAnchoredPosition` and `useDismissOnOutsidePointer` hooks, which are the placement and dismissal behavior any anchored panel needs, and it imports no runtime value from `ui-primitives`. The four detail menus use it.
The Delivery records tab names and copies a record's message id through an info button. The button's accessible name states the action and the record (`Copy message ID: <id>`), the shared `Tooltip` states the id on hover and on keyboard focus, and activation copies it through the shared `writeClipboard` helper and reports `Copied` in a status region beside the glyph. `Tooltip` therefore keeps its master shape: the mode that held one bubble open under the pointer is gone, and the Delivery records view does not use the `HoverCard` that opens on the pointer alone.
`packages/client/ui-primitives` returns to master except the clock glyph's stroke. The client bundle purity gate in `packages/client/tsdown.client.ts` is the neighboring rule for the other direction of this boundary: a client plugin may import another plugin's package only as types or as a module-table request, so shared runtime vocabulary travels through an inline-safe layer or a cordis service rather than a package edge.
## Alternatives considered
**Add a `header` prop to the shared `Menu`.** This is what the branch first did: an optional slot plus the `data-menu-field` focus handover, with tests in the shared package. It gives the smallest diff and keeps one menu implementation, and it was rejected because the only caller is the Schedule detail: `ui-primitives` would carry API that exists for one feature, and every client plugin's bundle would read a header contract none of them use.
**Add an `interactive` mode to the shared `Tooltip`.** Shipped first and then removed: a grace period between anchor and bubble, pointer hold, click toggling, and selectable text, with six tests. It lost because the shared tooltip already opens on focus and the shared clipboard helper already performs the copy, so the mode added a second way to hold one bubble open for a single caller.
**Name and copy the message id through `HoverCard`.** Shipped second and then replaced: the card holds while the pointer rests on it, selects its text, and copies on activation. It lost because `HoverCard` opens on `onPointerEnter` alone, so the already-focusable glyph showed a keyboard reader nothing and offered no copy path.
**Render the zone search field outside the menu list.** A search input above the trigger, or a separate row in the rule card, needs no primitive change. It was rejected because the field would scroll away from the list it filters or sit in a different surface than the rows it filters.
**Build the zone picker on the schedule-local `PickerPopover`.** The Date and Time pickers already use that panel, so the zone list could reuse it. It was rejected because `PickerPopover` positions and dismisses a panel but renders no list semantics: the keyboard walk, the row roles, and the selected-row mark would have to be rebuilt inside the Schedule client anyway, which is the copy this note describes with more surface than the menu it replaces.
## Consequences
The Schedule client carries a second menu implementation: 346 lines of component, 189 lines of CSS, and 403 lines of tests, in exchange for `ui-primitives` returning to master except the clock glyph's stroke. The copy is trimmed to what the detail uses, and the absent capabilities stay absent: submenus, component-rendered rows, a pinned footer, multiple selection ids, the fill selection mode, dense and compact spacing, mount-time autofocus, a caller-supplied anchor rect, pointer-leave closing, and a side parameter. A later Schedule menu that needs one of them extends the copy or revisits this decision.
Copying is an activation, not a text selection: the tooltip shows the id but does not accept the pointer, so the desktop gesture that copies the id is a click on the glyph. That is the capability the removed interactive mode had and this shape gives up. Shared behavior stays shared: placement, dismissal, focus handover, the duplication gate's exclusion markers naming both owners, and per-file coverage all apply to the copy. The copied region carries `jscpd:ignore-start` and `jscpd:ignore-end` with the two owners named, following the [cross-package value dependencies note](../../archived/process/2026-08-23-client-cross-package-value-dependencies.md); the schedule-specific header, handover, and Tab semantics sit outside that region.
The copy places its list with `useAnchoredPosition`, which clamps every edge by the same 12px margin, while the shared menu's top edge used the frame-top clearance helper. A list that must clamp upward against the top of a macOS window can therefore sit about 12px lower than the shared menu did in the same situation; the stylesheet still reserves the clearance for the list's own maximum height, so the common case is unchanged.
## Testing
`packages/client/ui-schedule/tests/task-menu.client.spec.tsx` pins the pointer and keyboard walk, the `header` field's Home/End ownership, the `data-menu-field` handover after placement, the selected-row mark, disabled rows, portal placement, outside dismissal, and focus return; the three menu-header tests that lived in `ui-primitives` moved there with the capability. `delivery-history.client.spec.tsx` pins the tooltip that keyboard focus raises, the button's accessible name, the clipboard write, and the copy confirmation that clears. The coverage gate reports 100% per file for `packages/client/ui-schedule/src`, and the duplication gate reports no clones across `packages/client/ui-schedule` and `packages/client/ui-primitives`.
@@ -0,0 +1,43 @@
# Agent Note: Schedule 详情菜单由 Schedule 客户端自己拥有
Status: implemented
[English](2026-09-23-schedule-local-menu.md) | 中文
## Problem
Automation tasks 详情需要一个列表顶部钉住控件的菜单:Time zone 行的搜索框必须停在滚动时区行之上。`@deepseek-ai/dsh-client-ui-primitives` 里的共享 `Menu` 会渲染行、分隔线、标题行、选中标记和键盘走位,但没有 header 槽。Delivery records 页把一条已保存记录的 message id 放在 info 图标背后,读者要能读到并复制这个 id。
用扩展 `ui-primitives` 来满足其中任一需求,都会把单个功能的行为放进每个 client 插件都消费的包里。同一条分支此前已经因为 review 质询“Schedule 的改动为什么碰它们”而撤掉了无关的 `ui-layout` 与 `ui-sidebar-right` 编辑;而一个只为单一调用方扩展的共享原语,对之后每一个读它的人都是成本。
## Decision
`packages/client/ui-schedule` 自己拥有菜单。`src/client/TaskMenu.tsx` 与 `TaskMenu.module.css` 保存一份 schedule 本地副本,渲染行、分隔线、标题行、disabled 与 dangerous 行、`selectedId` 尾随标记、指针与键盘走位、portal 定位、钉住的 `header` 槽,以及 `data-menu-field` 焦点交接。它导入共享的 `useAnchoredPosition` 与 `useDismissOnOutsidePointer` hook——任何锚定面板都需要的定位与关闭行为——不从 `ui-primitives` 导入任何运行时值。详情的四个菜单都改用它。
Delivery records 页用一个 info 按钮给出并复制记录的消息 id。该按钮的可访问名称同时说明动作与记录(`Copy message ID: <id>`),共享 `Tooltip` 在悬停与键盘聚焦时给出该 id,激活时通过共享的 `writeClipboard` 复制,并在图标旁用 status 区域报告 `Copied`。因此 `Tooltip` 保持 master 形态:那个让气泡在指针下保持展开的模式已不存在,Delivery records 页也不使用只在指针下打开的 `HoverCard`。
`packages/client/ui-primitives` 除时钟图标描边外回到 master。`packages/client/tsdown.client.ts` 的 client bundle purity gate 是这条边界另一方向上的相邻规则:client 插件只能以类型或 module-table request 的形式导入另一个插件的包,因此共享运行时词汇要么走 inline-safe 层,要么走 cordis service,而不是包与包之间的边。
## Alternatives considered
**给共享 `Menu` 加 `header` prop。** 这是分支最初的做法:一个可选槽加 `data-menu-field` 焦点交接,并在共享包里带测试。它的 diff 最小、只保留一份菜单实现;被否决是因为唯一的调用方是 Schedule 详情:`ui-primitives` 会背上只为单个功能存在的 API,而每个 client 插件的 bundle 都要读一份没人使用的 header 契约。
**给共享 `Tooltip` 加 `interactive` 模式。** 先落地后撤除:锚点与气泡之间的宽限期、指针停留、点击切换、可选中的文字,外加六个测试。被否决是因为共享 tooltip 本来就支持聚焦展开、共享剪贴板助手本来就负责复制,这个模式只是为单一调用方多出了一种保持气泡展开的方式。
**用 `HoverCard` 给出并复制消息 id。** 第二个落地又被替换的版本:指针停在卡片上时保持展开、文字可选中、激活即复制。被否决是因为 `HoverCard` 只在 `onPointerEnter` 打开,于是那个本来就可聚焦的图标对键盘读者什么都不显示,也没有复制入口。
**把时区搜索框渲染在菜单列表之外。** 放在触发器上方的输入框,或规则卡里的独立一行,都不需要改原语。被否决是因为该输入框会与它所过滤的列表分离滚动,或落在与它过滤的行不同的界面层上。
**用 schedule 本地的 `PickerPopover` 实现时区选择器。** Date 与 Time picker 已经用它。被否决是因为 `PickerPopover` 只负责定位与关闭面板,不渲染列表语义:键盘走位、行 role、选中行标记都要在 Schedule 客户端里重建,那正是本 note 描述的副本,而且比它所替代的菜单要维护更多东西。
## Consequences
Schedule 客户端多出一份菜单实现:346 行组件、189 行 CSS、403 行测试,换来 `ui-primitives` 除时钟图标描边外回到 master。副本按详情实际使用裁剪,缺失的能力保持缺失:submenu、组件渲染行、钉住的 footer、多个 selectedId、fill 选中模式、dense 与 compact 间距、挂载时 autofocus、调用方提供锚点矩形、指针离开即关闭,以及 side 参数。日后某个 Schedule 菜单需要其中一项时,要么扩展副本,要么重新审视这个决定。
复制是一次激活,而不是选中文本:tooltip 给出 id 但不接受指针,所以桌面端复制该 id 的手势是点击图标;这正是被撤掉的交互模式所具备、而当前形态放弃的能力。共享行为仍是共享的:定位、关闭、焦点交接、点名两个 owner 的 duplication gate 排除标记,以及 per-file 覆盖率都适用于这份副本。被拷贝的区域带 `jscpd:ignore-start` 与 `jscpd:ignore-end` 并点名两个 owner,遵循 [cross-package value dependencies note](../../archived/process/2026-08-23-client-cross-package-value-dependencies.md);schedule 专有的 header、交接与 Tab 语义不在该区域内。
副本用 `useAnchoredPosition` 定位列表,它对四条边都用同一个 12px margin,而共享菜单的顶边用的是 frame-top clearance helper。因此在 macOS 窗口顶部需要向上钳制的列表,可能比共享菜单原先的位置低约 12px;样式表仍为该列表自身的最大高度保留 clearance,常见情形不变。
## Testing
`packages/client/ui-schedule/tests/task-menu.client.spec.tsx` 钉住指针与键盘走位、`header` 输入框对 Home/End 的独占、落位后的 `data-menu-field` 交接、选中行标记、disabled 行、portal 定位、外部关闭与焦点回返;原先位于 `ui-primitives` 的三个菜单 header 测试随该能力一起迁入。`delivery-history.client.spec.tsx` 钉住键盘聚焦展开的工具提示、按钮的可访问名称、剪贴板写入,以及会自行清除的复制确认。覆盖率门禁对 `packages/client/ui-schedule/src` 报告 per-file 100%,duplication 门禁在 `packages/client/ui-schedule` 与 `packages/client/ui-primitives` 上报告 0 克隆。
@@ -9,14 +9,14 @@
en: 75c201a0a687b59d
zh: 34593038f673d06f
/agent-note-desktop-standard-macos-window-menus/decision:
en: ea0310537d0251d9
zh: daf3f8a745bb2872
en: 1797b5695e873e5a
zh: 3376fda4fc36aaae
/agent-note-desktop-standard-macos-window-menus/alternatives-considered:
en: e2d3ed82f6247910
zh: 7e449d21f0601f65
en: 957382dbfb6cac48
zh: 2db98b7dbf3e01c0
/agent-note-desktop-standard-macos-window-menus/consequences:
en: 54527f9422525fb8
zh: 285fd6ffda5ff4fd
en: 1fa44c82ca450bcd
zh: b618f5ec0a90ed13
/agent-note-desktop-standard-macos-window-menus/testing:
en: 0c05c0044532f891
zh: cff254b44b3c7a2d
@@ -10,9 +10,9 @@ The Desktop shell replaces Electron's default application menu with a custom tem
## Decision
On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus.
On macOS the template declares `{ role: 'windowMenu' }` after the Edit menu, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. The [page-close decision](../architecture/2026-09-21-desktop-page-close-shortcuts.md) owns the custom File menu and close behavior.
Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role.
Electron supplies English defaults for the Window and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). Minimize and hide execute through Electron's roles without custom handlers.
## Alternatives considered
@@ -22,13 +22,13 @@ Electron supplies English defaults for the File, Window, and Edit roles. Explici
**Add a bare `{ role: 'close' }` item to the application submenu.** It avoids a File menu containing a single command, but places a window command among the app-wide Plugins, Updates, Hide, and Quit items where no macOS application puts it.
**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application, turning a window shortcut into an unrequested quit path.
**Declare the File and Window menus on every platform.** The same template declares Ctrl+W there; with one window, closing it invokes `window-all-closed` and quits the application. The menu-restoration task did not request this quit path; the page-close decision explicitly specifies the Desktop fallback.
**Recreate the main window on Dock activation regardless of other windows.** The plugin window can outlive the main window, so gating `activate` on `mainWindow` instead of `BrowserWindow.getAllWindows()` would let a Dock click always restore the main window. That changes window lifecycle beyond restoring the suppressed platform commands, so the existing `activate` condition stays.
## Consequences
macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale.
macOS retains native Window and application hide commands through four menu roles. Window and Edit retain Electron’s default English labels; application commands use the Desktop locale.
## Testing
@@ -10,9 +10,9 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该
## 决策
macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。
macOS 上的模板在 Edit 菜单之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。[页面关闭决策](../architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)负责自定义 File 菜单和关闭行为。
Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。
Electron 为 Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。最小化和隐藏通过 Electron 的 role 执行,没有自定义处理器。
## 考虑过的替代方案
@@ -22,13 +22,13 @@ Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签
**在应用子菜单里直接加一个 `{ role: 'close' }` 项。** 这样可以避免只有一个命令的 File 菜单,但会把窗口命令放到 Plugins、Updates、Hide、Quit 这些应用级命令中间,没有 macOS 应用这样排布。
**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用,把窗口快捷键变成用户没有要求的退出路径。
**在所有平台都声明 File 和 Window 菜单。** 同一份模板在那些平台会把 Ctrl+W 声明为关闭;应用只有一个窗口,关闭它会触发 `window-all-closed` 并退出应用。恢复菜单的任务没有要求这条退出路径;页面关闭决策明确规定了 Desktop 回退。
**让 Dock 激活时无论其他窗口是否存在都重建主窗口。** 插件窗口可以比主窗口存活更久,因此把 `activate` 的判断从 `BrowserWindow.getAllWindows()` 改为 `mainWindow` 能让点击 Dock 总可以恢复主窗口。但这超出了恢复被压掉的平台命令,属于窗口生命周期的行为变更,因此保留现有的 `activate` 判断。
## 影响
macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
macOS 通过四个菜单 role 保留原生 Window 和应用隐藏命令。Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。
## 测试
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-09-18-schedule-task-tab-restore.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.md
/agent-note-task-tab-recovery-after-a-reload:
en: d7edcc16307d350a
zh: 63301b138b2a203b
/agent-note-task-tab-recovery-after-a-reload/problem:
en: 244f309423fff24e
zh: ddd2ab66a3f4fd5b
/agent-note-task-tab-recovery-after-a-reload/decision:
en: 16e1bcb4a0860e91
zh: 587715df43d5c96f
/agent-note-task-tab-recovery-after-a-reload/alternatives-considered:
en: 2fb8e2e0688d22ac
zh: a584b183723aace7
/agent-note-task-tab-recovery-after-a-reload/consequences:
en: 20b7e4c551df0bc0
zh: c17426fb981f0b06
/agent-note-task-tab-recovery-after-a-reload/testing:
en: 3581ad83c52ee3b0
zh: fbffd886a46b76c6
@@ -0,0 +1,45 @@
# Agent Note: Task tab recovery after a reload
Status: implemented
English | [中文](2026-09-18-schedule-task-tab-restore.zh.md)
## Problem
The right Sidebar persists a Session's layout as tab records and not the navigation parameters an opener passed, per the [tab type and navigation decision](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md). A `scheduleTask` tab restored by a reload therefore had no task identity: its body could show only its loading feedback and its chip only the constant label, until the user opened the task again from the Session entry. Recovering content belongs to the tab type's provider, as the [layout persistence decision](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) established for terminals, and the Sidebar exposes no parameter store for a page type to reuse.
## Decision
The task tab page keeps a durable binding to the task it last showed and resolves a restored record from it.
The binding key is the Sidebar Session plus the layout record's own id. `dsh.sidebar-right.v1.<sessionId>` persists the tab records and the counter that mints their ids, so a record keeps its id across a reload and no record minted later in that layout reuses it. The record's `contentId` is not a usable key: every tab of one page kind is recorded under the same `sidebar://<kind>` address, and page deduplication is per pane, so two panes may each hold a task tab. Each entry also stores the `kind` and `contentId` of the record it was written for, and a read drops and ignores an entry whose pair differs from the record reading it.
One localStorage document per Session, keyed `dsh.schedule.task-tab.v1.<sessionId>`, holds one entry per tab id, mapping that page to the Session and id of its last task. No task content is stored. A write keeps only the entries whose tab the Session's committed layout still holds, read through `ctx.sidebarRight.tabsIn(sessionId)`; a Session whose layout is not adopted leaves its entries alone. An entry that a read succeeding after the tab appeared cannot resolve is removed, which leaves the tab with the unbound-tab notice, the same state as one that never had a binding.
The body writes the binding in an effect whenever a navigation carries this type's parameters, and reads it only while the record has no navigation parameters; parameters of another kind leave the record in its missing-task state. A recovered identity then follows the existing rules: the detail whenever the retained records hold the task, the catalog feedback until a read requested after the tab's baseline settles without the task, and the query-failure state with its retry when the read fails. A binding such a read cannot resolve shows the unbound-tab notice instead of the loading feedback, and never the missing-task notice, because that notice states that an opener named a task.
The body measures the answer from the catalog read request ordinal it observed for its current navigation, and asks for a read whenever the retained records leave it without a task and no read requested after that ordinal has succeeded, reporting the catalog feedback while that read is pending. The Sidebar navigates a tab again without remounting its body, so the navigation's revision moves the baseline; a read requested before the baseline never states that the tab's task is gone.
## Alternatives considered
**Key the binding by `contentId`.** Rejected: every tab of one page kind is recorded under the same address, and page deduplication is per pane, so two task tabs in two panes would share one binding and restore the same task.
**Persist navigation parameters in the Sidebar layout.** Rejected: it moves content parameters into the layout snapshot that the [navigation decision](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) keeps transient, and every future page type would inherit the format.
**Declare the kind `multiple: true` so every open mints a unique contentId.** Rejected: it changes what opening a task does — a second task opens a second tab instead of showing in the existing one — to obtain a key, and it cannot recover records restored before the change.
**Resolve the task from the catalog.** Rejected: the catalog holds no per-tab association, and the Session entry the tab was opened from is gone after a reload.
**Reuse the client store's localStorage persistence.** Rejected: `createSnapshotStore`'s `persist` option saves one whole store value under one name and rehydrates it when that store is created, so it needs a store per Session; the binding also has to reject reused ids and prune closed tabs on write, which a keyed document does in one place with no subscription for a body to re-render through.
**Store the task title beside the binding to confirm the recovery.** Rejected: a title is task content, it changes with an edit, and the settled catalog read already decides whether the bound id resolves.
## Consequences
A reloaded Session restores every task tab's detail and chip without reopening the task. The binding is window-local browser state: another browser, a cleared origin, or a tab the layout kit duplicated has none, and such a record states that the tab has no task instead of claiming the task is gone. A discarded layout snapshot can mint a tab id that a binding still names; the kind and contentId guard rejects an entry of another kind, and an entry of the same kind is overwritten by the next navigation of that tab or removed once its task stops resolving.
Entries of Sessions whose layouts are never adopted again remain until their next write; write-time pruning bounds every adopted Session, and no entry holds content.
## Testing
Unit tests cover writing on navigation, reading only without parameters, recovery into the detail and the chip, degradation of an unresolvable binding, committed-tab pruning, reused-id rejection, malformed stored documents, and storage that is absent or rejects writes.
@@ -0,0 +1,45 @@
# Agent Note: 刷新后任务标签页的恢复
Status: implemented
[English](2026-09-18-schedule-task-tab-restore.md) | 中文
## Problem
右 Sidebar 按 [标签类型与导航决策](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md) 只把 Session 布局持久化为标签记录,不持久化打开方传入的导航参数。因此刷新恢复出的 `scheduleTask` 标签没有任务身份:正文只能显示加载反馈,chip 只能显示固定文案,直到用户从 Session 入口重新打开该任务。按 [布局持久化决策](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md) 为终端确立的划分,恢复内容属于标签类型自身 provider 的职责,而 Sidebar 没有可供页面类型复用的参数存储。
## Decision
任务标签页保存到它最后显示任务的持久关联,并据此恢复刷新出的记录。
关联键是 Sidebar Session 加布局记录自身的 id。`dsh.sidebar-right.v1.<sessionId>` 持久化标签记录以及铸造其 id 的计数器,因此记录刷新后保持 id,该布局之后铸造的记录也不会复用它。记录的 `contentId` 不能作为键:同一种页面类型的所有标签都记录在同一个 `sidebar://<kind>` 地址下,而页面去重按 pane 进行,两个 pane 可以各有一个任务标签。每条记录还保存写入时该记录的 `kind` 和 `contentId`;读取时若这一对与读取它的记录不一致,就删除该条并视为无关联。
每个 Session 一个 localStorage 文档,键为 `dsh.schedule.task-tab.v1.<sessionId>`,按 tab id 保存一条记录,把该页面映射到其最后任务的 Session 和 id。不保存任何任务内容。写入时只保留 Session 已提交布局仍然持有的标签对应的记录,该布局通过 `ctx.sidebarRight.tabsIn(sessionId)` 读取;布局未被采用的 Session 保持其记录不变。标签页出现之后成功的一次读取无法解析的记录会被删除,使该标签显示未关联页签提示,与从未有关联的标签相同。
只要一次导航携带本类型的参数,正文就在 effect 中写入该关联;只有记录完全没有导航参数时才读取它,其他类型的参数让记录停留在任务缺失状态。恢复出的身份随后遵循既有的渲染规则:保留的记录包含该任务时即显示详情,判据之后请求的读取仍未给出该任务时显示目录反馈,读取失败时显示带重试的查询失败状态。这样的读取仍无法解析的关联显示未关联页签提示,而不是加载反馈,也绝不产生任务缺失提示,因为该提示的含义是打开方指名了一个任务。
正文以它为当前导航观察到的目录读取请求序号作为判据,并在保留的记录使标签页没有任务、且该序号之后没有读取成功时补发一次读取,该读取未落地期间显示目录反馈。Sidebar 会在不重新挂载正文的情况下对同一标签再次导航,因此以导航的 revision 移动判据;早于判据发出的读取永远不能断言该标签的任务已消失。
## Alternatives considered
**用 `contentId` 作为关联键。** 否决:同一种页面类型的所有标签都记录在同一地址下,且页面去重按 pane 进行,两个 pane 的任务标签会共用一个关联并恢复出同一个任务。
**把导航参数持久化进 Sidebar 布局。** 否决:这会把内容参数放进 [导航决策](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md) 保持为临时的布局快照,且之后每种页面类型都会继承该格式。
**把 kind 声明为 `multiple: true`,让每次打开都铸造唯一 contentId。** 否决:为了取得一个键而改变打开任务的行为——打开第二个任务会新开一个标签,而不是在已有标签中显示;而且它无法恢复改动之前已存在的记录。
**从目录解析任务。** 否决:目录不保存按标签的关联,而打开该标签的 Session 入口在刷新后已不存在。
**复用 client store 的 localStorage 持久化。** 否决:`createSnapshotStore` 的 `persist` 选项把一个 store 的整体值保存在一个键下,并在该 store 创建时回填,因此需要按 Session 各建一个 store;而该关联还必须拒绝复用的 id 并在写入时清理已关闭标签,这些由一份按键文档在一处完成,也不需要正文为其订阅重渲染。
**在关联旁保存任务标题以确认恢复。** 否决:标题属于任务内容,会随编辑变化,而已定论的目录读取本身就能判断绑定的 id 是否可解析。
## Consequences
刷新后的 Session 无需重新打开任务即可恢复每个任务标签的详情和 chip。该关联是窗口内的浏览器状态:其他浏览器、被清除的站点数据,或被布局工具包复制出的标签都没有它,这类记录会说明该页签未关联任务,而不会声称任务已不存在。被丢弃的布局快照可能铸造出关联仍然指名的 tab id;kind 与 contentId 守卫会拒绝其他类型的记录,而同类型记录会因该标签的下一次导航被覆盖,或在任务不再可解析时被删除。
布局不再被采用的 Session 的记录会保留到其下次写入;写入时的清理使每个已采用的 Session 有界,且任何记录都不含内容。
## Testing
单元测试覆盖:导航时写入、仅在无参数时读取、详情与 chip 的恢复、无法解析关联的降级、已提交标签的清理、复用 id 的拒绝、损坏的存储文档,以及存储缺失或拒绝写入的情况。
@@ -2,36 +2,30 @@
# 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-08-05-durable-web-schedule.md
/agent-note-durable-session-local-reminders:
en: c40fc2bc6dd1b469
zh: c3932a1c8c60579b
/agent-note-durable-session-local-reminders/problem:
en: 75d2d50c48475d22
zh: 293c507e9e516f29
/agent-note-durable-session-local-reminders/decision:
en: 7fd21223d6306a39
zh: b792a415d9dccba0
/agent-note-durable-session-local-reminders/decision/session-log-authority-and-tools:
en: 23a928cba17217d6
zh: 7995b144db6e4c70
/agent-note-durable-session-local-reminders/decision/explicit-absolute-time-boundary:
en: 2e1fe906d42245aa
zh: bd21d4a2dd7d8afc
/agent-note-durable-session-local-reminders/decision/bounded-fixed-rate-semantics:
en: 2ebada22d530b18a
zh: 90e7dd128c8ba54a
/agent-note-durable-session-local-reminders/decision/live-delivery-lifecycle:
en: e69281749cf18500
zh: 9ce38e39e486f2d6
/agent-note-durable-session-local-reminders/decision/read-only-web-catalog:
en: 6ea0fcb2512d1434
zh: ef9e430200677c9f
/agent-note-durable-session-local-reminders/alternatives-considered:
en: d86b5e8b0380c22a
zh: e6b6fce158608b08
/agent-note-durable-session-local-reminders/verification:
en: 716fce79e742ecfa
zh: 6abb1480f1b9e2d9
/agent-note-durable-session-local-reminders/consequences:
en: 7abf5e7d73b32153
zh: c178fb3f1f28dee4
/agent-note-durable-scheduled-reminders:
en: 7d312bc635f68bef
zh: f675a214bfe26dac
/agent-note-durable-scheduled-reminders/problem:
en: 85e09d88c1383aeb
zh: 2d90fcbf2fe5ad3c
/agent-note-durable-scheduled-reminders/decision:
en: a15c7c13f167e5ac
zh: 63bcc2ef60c2f344
/agent-note-durable-scheduled-reminders/decision/explicit-absolute-time-boundary:
en: 16cc539a319f99b4
zh: ae06872529f2227d
/agent-note-durable-scheduled-reminders/decision/bounded-fixed-rate-semantics:
en: 4a12a91087629614
zh: 4f23eee242eeadd3
/agent-note-durable-scheduled-reminders/decision/queue-admission-is-not-completion:
en: 1d88821105254303
zh: 08982a1534d8fd18
/agent-note-durable-scheduled-reminders/alternatives-considered:
en: f6d44a87797410ca
zh: 383ac9e712492c93
/agent-note-durable-scheduled-reminders/verification:
en: 113a6d381ff1b145
zh: 59f9693741adbbab
/agent-note-durable-scheduled-reminders/consequences:
en: 2121b1640094e601
zh: 9420468b80ff1155
@@ -1,93 +1,49 @@
# Agent Note: Durable Session-local reminders
# Agent Note: Durable scheduled reminders
Status: implemented
English | [中文](2026-08-05-durable-web-schedule.zh.md)
The [Host-owned scheduled messages decision](../architecture/2026-09-16-host-schedule-storage.md) owns task storage, timer activation, dispatch, delivery records, and the task catalog. This note owns the two timing decisions that survive it — the explicit absolute-time boundary and bounded fixed-rate arithmetic — and the queue-admission boundary they share.
## Problem
A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. A process-local timer or inbox item cannot provide that durability, while a global scheduler or private database introduces a second identity, persistence, and lifecycle system.
Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, absolute calendar input, and teardown make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and avoid spreading Schedule-specific presentation or time-zone state across unrelated components.
A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. Busy Agents, long waits, wall-clock changes, cold Sessions, and persistence failures make a simple timeout insufficient. Absolute calendar input and repeated wake-ups raise two timing questions that storage and activation choices do not answer: what an absolute time means when no recurring rule stores a zone, and how a fixed interval catches up after downtime without replaying every missed occurrence.
## Decision
The [Schedule guide](../../../../docs/user/guide/schedule.md) uses an overlay that explicitly loads `@deepseek-ai/dsh-time-context` and `@deepseek-ai/dsh-schedule`, and enables the Web bundle's otherwise-disabled `ui-schedule` row. The default Web startup graph remains inactive for Schedule. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate the runtime.
The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while live, does no external notification while cold, and processes an overdue reminder after it becomes live again. Due work waits until the Agent is fully idle, then enters the ordinary next-turn queue through `followup()`; it never steers the current turn and has no independent Web receipt ([conversational delivery](../../archived/simplification/2026-08-09-conversational-schedule-delivery.md)).
| Scenario | Durable fact | Live behavior | User-visible result |
| --- | --- | --- | --- |
| Create and manage | `schedule/change` create/delete in the original Session | Agent-scoped tools checkpoint before reads and after mutations | Stable id, UTC target, state, and `session-local` disclosure |
| Due while busy | Active create remains in the fold | Owner waits for idle maintenance, queues one follow-up, then appends dispatch | A later ordinary conversation turn |
| Several Every records are overdue | Each active record retains its earliest unaccepted anchor-aligned target | One decision selects each record's latest occurrence and advances it past now | One ordinary follow-up containing one occurrence per record |
| Process stopped or Session cold | Active create remains persisted | No timer or background scan; resume rebuilds the owner | Future target waits; overdue target is attempted |
| Fork | Parent events remain in the inherited prefix | Child fold starts at the exact `inheritedEventCount` | Parent work does not become active in the child |
### Session-log authority and tools
The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. Every dispatch stores its id and decision time so the fold advances that record directly past missed occurrences. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after the `inheritedEventCount` passed into projection initialization.
When `ctx.sessionProjections` exists, Schedule registers a strict unit that uses the same transition and publishes the complete active `ScheduleRecord[]`; the shared [projection state decision](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md) owns its initialization and restore contract. Corrupt durable input fails the existing read path rather than yielding a partial array. The browser-safe record vocabulary is exposed through the type-only `@deepseek-ai/dsh-schedule/client` subpath.
The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either strict RFC 3339 with `Z` or a numeric offset, or structured `{ date, time, time_zone }` with an explicit zone; its record is `{ id, kind: 'at', prompt, scheduledAt }`. `every_seconds` is a safe integer of at least 300 whose `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record stays aligned to its creation-plus-interval sequence. One-shot dispatch stores only the id; Every dispatch stores `id + acceptedAt`. Tool values derive `scheduled` or `overdue` and include `deliveryMode: 'session-local'`.
An Agent-scoped FIFO serializes management transactions and the live owner's due transaction from preflight through post-append barriers. Every tool read first awaits `ctx.sessions.flush(session)`. Create rejects input-shape failures before the FIFO when possible, preflights, allocates an id, appends, and checkpoints again. Delete validates its id before the FIFO, preflights before deciding whether it is active, and checkpoints again only after append. List and not-found delete never answer from an unconfirmed live suffix. Failed barriers return `persistence_uncertain` rather than guessing whether an eager write committed.
Every successful management preflight asks the live owner to recompute. A later list can therefore confirm a retained create after a previous post-append rejection and arm it without a private persistence-retry timer.
### Explicit absolute-time boundary
Natural-language interpretation and Schedule parsing are deliberately separate ([time-zone simplification](../simplification/2026-08-09-explicit-schedule-time-zone.md)). Each browser prompt carries its Host-validated IANA zone only on that durable user message. Time-context tells the model to assume that zone for otherwise-unqualified dates and times. Schedule neither imports that plugin nor stores a Session zone: the model must turn its interpretation into an offset-bearing RFC 3339 value or a local object with explicit `time_zone`.
Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A successful create stores only canonical UTC `scheduledAt`, not the original offset, local fields, or zone.
Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A structured local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A one-time `at` or `after` record stores only the canonical UTC `scheduledAt`, not the input offset, local fields, or zone. A `daily`, `weekly`, or `cron` record instead stores its normalized local rule with an explicit `timeZone` ([timing selectors](../../../../packages/schedule/schedule/README.md)).
### Bounded fixed-rate semantics
Every is a fixed-duration interval, not a calendar rule. The first target is creation time plus the interval. At a due decision, integer division selects the latest sequence point at or before the sampled wall clock and the first sequence point after it. The selected occurrence is presented once and the record advances directly to the future target, so a cold Session never accumulates a replay backlog and delayed model work never shifts the sequence.
Every is a fixed-duration interval, not a calendar rule. Its first target is creation time plus the interval. At a due decision, integer division selects the latest sequence point at or before the sampled wall clock and the first sequence point after it. The selected occurrence is presented once and the record advances directly to the future target, so a cold Session never accumulates a replay backlog and delayed model work never shifts the sequence. Every, Daily, Weekly, and Cron records overdue for one Session share one message, and each record contributes only its latest occurrence ([storage and dispatch](../architecture/2026-09-16-host-schedule-storage.md)).
All distinct overdue Every records participate in one batch, each with one latest occurrence and one shared `acceptedAt`. There is no cross-record cooldown, gate, quota, or retained batch timestamp. A five-minute minimum bounds wake and model-request frequency. If the next sequence point would exceed the four-digit-year storage range, dispatch terminates that record.
A five-minute minimum bounds wake and model-request frequency. There is no cross-record cooldown, gate, quota, or retained batch timestamp. If the next sequence point would exceed the four-digit-year storage range, dispatch terminates that record.
Calendar and Cron expressions are deliberately absent ([bounded recurrence simplification](../../archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md)); supporting them would add a time-zone-sensitive calendar language, evaluator dependency, validation surface, and tzdata replay policy unrelated to fixed-rate reminders.
### Queue admission is not completion
### Live delivery lifecycle
The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. Due one-shots have priority and are admitted one at a time; otherwise every overdue Every record enters one batch in target and creation order. If a turn or maintenance task owns the Agent, `runMaintenance()` rejects the claim; the records stay active and one `whenIdle()` wait triggers another attempt. A rejected preflight or contained framing/enqueue failure also leaves them active without starting a private retry timer.
The accepted path clears pending persistence and claims the true idle phase. It refolds the exact Session suffix, samples the decision clock, constructs fixed reminder framing with JSON-escaped values, synchronously queues one `followup()`, and appends dispatch before releasing maintenance. A one-shot appends an id-only terminal dispatch. A fixed-rate batch appends one `id + acceptedAt` transition per participating record. Waking input remains parked until release, so the message cannot be claimed before dispatch enters the log; afterward the owner checkpoints dispatch.
Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. Agent or plugin disposal cancels timers, stops new work, unwinds tool registrations, and awaits in-flight work without deleting durable records. A crash after follow-up admission but before durable dispatch can repeat the reminder after recovery; the design makes no exactly-once promise.
### Read-only Web catalog
The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. The catalog is a fixed `document.body` portal whose left edge follows the trigger when space permits and shifts left to retain a 16px viewport margin near the right edge. `useAnchoredPosition` owns measurement and resize, captured-scroll, panel-resize, and cleanup behavior; Schedule supplies the trigger and portal refs, bottom placement, a 5px gap, and the existing inside/outside dismissal boundary without adding a general popover abstraction.
Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. A crash after follow-up admission but before the durable receipt can repeat the reminder after recovery; the design makes no exactly-once promise.
## Alternatives considered
**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than Session-log state and conversation follow-ups.
**Store reminders in a private database or global scheduler.** This could run cold Sessions but requires a second identity map, startup scan, ownership lease, crash protocol, and notification policy.
**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than durable task state and conversation follow-ups.
**Persist a Session time zone and infer local `at`.** This spreads one interpretive default through Session core, Host create/fork, persistence formats, clients, and mismatch recovery. Request-local model guidance plus an explicit tool boundary deletes that coupling.
**Keep an independent durable Web receipt.** Dispatch is an internal queue fact, not the user's reminder. Rendering the ordinary assistant answer avoids a second delivery meaning and removes Schedule code from Host and client layers.
**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this design.
**Add a general recurring-rule engine.** Fixed-duration intervals need only anchor arithmetic. A shared recurrence abstraction, global admission gate, and calendar evaluator would enlarge replay and runtime state without serving the retained product behavior.
**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this Session-local scope.
**Adopt existing roots or register global tools.** Late adoption makes plugin load order activate unseen timers and exposes tools outside the supported root composition.
**Add a general recurring-rule engine for fixed intervals.** Fixed-duration intervals need only anchor arithmetic. A shared recurrence abstraction, global admission gate, and calendar evaluator would enlarge replay and runtime state without serving fixed-rate behavior. The cron selector is a separate wall-clock rule with its own Host-owned dialect and evaluation ([cron input](../../../../docs/subsystems/schedule.md#cron-wall-clock-input)).
## Verification
Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior, including the body portal, spacious left alignment, portal-inside pointer handling, outside dismissal, Escape, live empty, and timer cleanup. The shared primitive suite owns the positioning hook's resize, captured-scroll, panel-resize, and cleanup lifecycle. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one 900×900 Schedule-catalog smoke for overlay reachability, fixed portal placement, right-edge clamping, width and overflow, ordinary/search alarms, narrow dark layout, a light-theme browser screenshot, and one live empty update.
Package tests pin fixed-rate arithmetic, creation anchors, latest-only catch-up, batch selection, IANA validation, daylight-saving gaps and overlaps, and time bounds at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans.
## Consequences
- Reminder state survives restart through ordinary Session persistence without a new database or public service.
- Cold Sessions do no work and send no external notification; reopening one may deliver overdue work.
- Absolute input is deterministic without persistent Session-zone state or a dependency from Schedule to time-context.
- Users see normal conversation output; dispatch never overstates model success or acknowledgement.
- Opt-in Web users can inspect the complete active set and recognize cache-known active Sessions in ordinary or search rows without creating a second durable state, runtime signal, or delivery meaning.
- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation.
- Fixed-rate recurrence is bounded by a five-minute minimum, latest-only catch-up, and one batched occurrence per overdue record; calendar recurrence remains outside this product boundary.
- A fixed interval stays aligned to its creation anchor, presents one occurrence per overdue record, and cannot accumulate a replay backlog.
- Delivery never overstates model success or acknowledgment, and a crash between queue admission and the durable receipt can repeat a message.
@@ -1,93 +1,49 @@
# Agent Note: 持久、仅限 Session 内的提醒
# Agent Note: 持久定时提醒
Status: implemented
[English](2026-08-05-durable-web-schedule.md) | 中文
[Host 拥有的定时消息决策](../architecture/2026-09-16-host-schedule-storage.zh.md)拥有任务存储、timer 激活、投递、发送记录和任务目录。本记录拥有在该决策之下仍然成立的两个时间决策——显式绝对时间边界与有界固定速率计算——以及二者共有的队列准入边界。
## 问题
在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。进程本地 timer 或 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。
繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session、fork、持久化失败、绝对日历输入和资源释放,使简单 timeout 无法满足要求。设计必须区分持久记录与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并避免把 Schedule 专属的呈现或时区状态扩散到无关组件。
在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session 和持久化失败使简单 timeout 无法满足要求。绝对日历输入与重复唤醒带来两个计时问题,而存储与激活方式的选择并不能回答它们:当重复规则不保存时区时,绝对时间意味着什么;固定间隔在停机后如何追赶,而不重放每一个错过的发生时点。
## 决策
[Schedule 指南](../../../../docs/user/guide/schedule.zh.md)使用显式加载 `@deepseek-ai/dsh-time-context` 与 `@deepseek-ai/dsh-schedule`,并启用 Web bundle 中默认 disabled 的 `ui-schedule` row 的 overlay。默认 Web 启动图不会激活 Schedule。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他 host 都不会激活 runtime。
用户可见边界是 `session-local`:原 Session 只有在 live 时才会准时运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。到期工作会等待 Agent 完全 idle,再通过 `followup()` 进入普通的下一轮队列;它绝不会中途引导当前轮次,也没有独立 Web 回执([对话式交付](../../archived/simplification/2026-08-09-conversational-schedule-delivery.md))。
| 场景 | 持久事实 | live 行为 | 用户可见结果 |
| --- | --- | --- | --- |
| 创建与管理 | 原 Session 中的 `schedule/change` create/delete | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、状态与 `session-local` 说明 |
| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 idle maintenance,排入一个 follow-up,再追加 dispatch | 后续一个普通对话轮次 |
| 多条 Every 记录逾期 | 每条活动记录都保留最早一个尚未接受且与锚点对齐的目标 | 一次决策选择每条记录的最新发生时点,并将其推进到当前时刻之后 | 一个普通 follow-up,其中每条记录各有一个发生时点 |
| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标会被尝试 |
| fork | 父 event 留在继承前缀 | child fold 从精确 `inheritedEventCount` 开始 | 父工作不会在 child 中变为活动状态 |
### Session 日志权威与工具
版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。Every dispatch 会存储 id 与决策时点,使 fold 将该记录直接推进到错过的发生时点之后。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的 dispatch,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠传入 projection 初始化的 `inheritedEventCount` 位置及其后的 event。
`ctx.sessionProjections` 存在时,Schedule 会注册一个复用同一 transition 的严格单元,并发布完整的活动 `ScheduleRecord[]`;共享的 [projection state 决策](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)拥有其初始化与 restore 约定。损坏的持久输入会使既有读取路径失败,而不会产生部分数组。浏览器安全的记录词汇通过纯类型子路径 `@deepseek-ai/dsh-schedule/client` 暴露。
当前规则 union 接受非空提示词和恰好一个 selector。`after_seconds` 是正的安全整数 delay,其记录为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的值,也可以是带显式时区的结构化 `{ date, time, time_zone }`;其记录为 `{ id, kind: 'at', prompt, scheduledAt }`。`every_seconds` 是不小于 300 的安全整数,其 `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` 记录始终与从创建时刻加一个间隔开始的序列对齐。一次性 dispatch 只存储 id;Every dispatch 存储 `id + acceptedAt`。工具值派生 `scheduled` 或 `overdue`,并包含 `deliveryMode: 'session-local'`。
一个 Agent-scoped FIFO 会将管理事务与 live owner 的到期事务从 preflight 到 post-append barrier 全程串行化。每项工具读取都会先等待 `ctx.sessions.flush(session)`。create 会尽可能在进入 FIFO 前拒绝输入形状错误,随后执行 preflight、分配 id、追加记录并再次 checkpoint。delete 会在进入 FIFO 前验证 id,在判断其是否活动前执行 preflight,并且只在追加后再次 checkpoint。list 与 not-found delete 绝不会根据未经确认的 live 后缀作答。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。
每次成功的管理 preflight 也会要求 live owner 重新计算。因此,如果先前的 post-append 被拒绝,后续 list 可以确认保留的 create 并将其 arm,而无需私有的 persistence 重试 timer。
### 显式绝对时间边界
自然语言解释与 Schedule 解析被有意分开([时区简化](../simplification/2026-08-09-explicit-schedule-time-zone.zh.md))。每条浏览器提示词只在其对应的持久 user message 上携带由 Host 校验过的 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该时区。Schedule 既不导入该插件,也不存储 Session 时区:模型必须把其解释结果转换为带偏移量的 RFC 3339 值,或带显式 `time_zone` 的本地对象。
Schedule 会校验精确的日历形状、偏移量、时区名称,以及一个严格位于未来、年份为四位数的时点。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,不会存储原始偏移量、本地字段或时区。
Schedule 会校验精确的日历形状、偏移量、时区名称,以及严格位于未来、年份为四位数的时点。结构化本地时间落在夏令时缺口内会被拒绝;遇到重叠时选择第一次出现的较早时点。一次性 `at` 或 `after` 记录只存储规范化的 UTC `scheduledAt`,不存储输入的偏移量、本地字段或时区。`daily`、`weekly` 或 `cron` 记录则存储规范化后的本地规则及其显式 `timeZone`([时间选择器](../../../../packages/schedule/schedule/README.zh.md))。
### 有界固定速率语义
Every 是固定时长间隔,而不是日历规则。第一个目标是创建时刻加上一个间隔。作出到期决策时,整数除法会选出不晚于所采样墙钟的最新序列点,以及其后的第一个序列点。选中的发生时点只呈现一次,记录会直接推进到未来目标,因此 cold Session 绝不会积累回放任务,延迟执行的模型工作也绝不会使该序列漂移。
Every 是固定时长间隔,而不是日历规则。其首个目标是创建时刻加上该间隔。作出到期决策时,整数除法选出不晚于所采样墙钟的最新序列点,以及其后的第一个序列点。选中的发生时点只呈现一次,记录直接推进到未来目标,因此 cold Session 绝不会积累回放任务,延迟执行的模型工作也绝不会使该序列漂移。同一 Session 内到期的 Every、Daily、Weekly 和 Cron 记录共用一条消息,每条记录只贡献其最新一次发生时点([存储与投递](../architecture/2026-09-16-host-schedule-storage.zh.md))。
所有不同的逾期 Every 记录都会参与同一个批次,每条记录各自提供一个最新发生时点,并共享同一个 `acceptedAt`。系统不存在跨记录的冷却、门控、配额或保留的批次时间戳。至少 5 分钟的限制约束了唤醒与模型请求频率。如果下一个序列点会超出四位年份存储范围,dispatch 会终结该记录。
至少五分钟的下限约束唤醒与模型请求频率。系统不存在跨记录的冷却、门控、配额或保留的批次时间戳。如果下一个序列点会超出四位年份存储范围,dispatch 会终结该记录。
日历表达式与 Cron 表达式被有意排除([有界周期性简化](../../archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md));支持这些表达式需要增加时区敏感的日历语言、求值器依赖、校验范围和 tzdata 回放策略,而这些都与固定速率提醒无关。
### 队列准入不是完成
### Live 交付生命周期
Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都会重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。已到期的一次性提醒优先,每次准入一条;否则,所有逾期 Every 记录会按目标时间和创建顺序进入同一个批次。如果 Agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;这些记录保持活动,并由一次 `whenIdle()` wait 触发另一次尝试。被拒绝的 preflight 或被收容的 framing/入队失败同样会使其保持活动,但不会启动私有重试 timer。
获得准入的路径会刷新所有 pending persistence 并认领真正的 idle phase。它会重新折叠确切的 Session 后缀、采样 decision clock、用经过 JSON 转义的值构造固定提醒 framing、同步排入一个 `followup()`,并在释放 maintenance 前追加 dispatch。一次性提醒会追加只含 id 的终结 dispatch。固定速率批次会为每条参与记录追加一个 `id + acceptedAt` 转换。触发唤醒的 input 会保持 parked,直到 maintenance 释放,因此在 dispatch 进入日志前,消息不会被认领;随后 owner 会为 dispatch 执行 checkpoint。
dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。Agent 或插件 dispose 会取消 timer、停止新工作、撤销工具注册,并等待进行中的工作,且不会删除持久记录。follow-up 获得准入后、持久 dispatch 前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。
### 只读 Web 目录
Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。目录是挂到 `document.body` 的 fixed portal;空间足够时左边缘跟随触发按钮,靠近视口右侧时向左避让并保留 16px 边距。`useAnchoredPosition` 拥有测量以及 resize、捕获阶段 scroll、面板 resize 与清理行为;Schedule 提供触发器与 portal ref、bottom 放置、5px 间距和既有内外 dismissal 边界,不增加通用 popover 抽象。
dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。follow-up 获得准入后、持久回执前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。
## 已考虑的替代方案
**使用 `ctx.jobs`。** Task 拥有进程本地工作、结果和通知,而不是 Session 日志状态和对话 follow-up。
**把提醒存入私有数据库或全局 scheduler。** 这样可以运行 cold Session,却需要第二套身份映射、启动扫描、ownership lease、崩溃协议和通知策略。
**使用 `ctx.jobs`。** Task 拥有进程本地工作、结果和通知,而不是持久任务状态和对话 follow-up。
**持久化 Session 时区并推断本地 `at`。** 这会让一个解释默认值扩散到 Session core、Host create/fork、持久化格式、client 和不匹配恢复中。请求本地的模型指导与显式工具边界消除了这种耦合。
**保留独立的持久 Web 回执。** dispatch 是内部队列事实,而不是用户的提醒。渲染普通 assistant 回答既避免了第二种交付含义,也从 Host 与 client 层移除了 Schedule 代码。
**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了本设计范围。
**增加通用周期规则引擎。** 固定时长间隔只需要锚点运算。共享的周期抽象、全局准入门控和日历求值器会扩大回放与运行时状态,却不能服务于保留的产品行为。
**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了此 Session-local 范围。
**接管既有根或注册全局工具。** 晚接管会让插件加载顺序激活不可见的 timer,并把工具暴露到受支持的根组合之外。
**为固定间隔增加通用周期规则引擎。** 固定时长间隔只需要锚点运算。共享的周期抽象、全局准入门控和日历求值器会扩大回放与运行时状态,却不能服务于固定速率行为。cron 选择器是独立的钟表规则,拥有自己的 Host 所有方言与求值([cron 输入](../../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input))。
## 验证
包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为,包括 body portal、空间充足时的左对齐、portal 内指针处理、外部 dismissal、Escape、live empty 与 timer 清理。共享 primitive suite 拥有定位 hook 的 resize、捕获阶段 scroll、面板 resize 与清理生命周期。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 900×900 Schedule 目录 smoke 覆盖 overlay 可达性、fixed portal 定位、右侧钳制、宽度与 overflow、普通/搜索闹钟、窄屏暗色布局、浅色主题浏览器截图与一次 live empty 更新。
包测试以逐文件 100% coverage 固定固定速率计算、创建锚点、只追赶最新一次、批次选择、IANA 校验、夏令时缺口与重叠以及时间边界。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。
## 后果
- 提醒状态通过普通 Session persistence 跨重启存活,无需新数据库或公开 service。
- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 工作。
- 无需持久 Session 时区状态或从 Schedule 到 time-context 的依赖,绝对时间输入仍然具有确定性。
- 用户看到普通对话输出;dispatch 绝不会夸大模型成功或 acknowledgement。
- 显式启用 Schedule 的 Web 用户可以查看完整活动集合,并在普通行或搜索结果中辨认 cache 已知的活动 Session,而不会引入第二份持久状态、runtime 信号或第二种交付含义。
- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。
- 固定速率周期性受到至少 5 分钟、只追赶最新一次,以及每条逾期记录只在一个批次中贡献一个发生时点的约束;日历周期性仍在此产品边界之外。
- 固定间隔始终与其创建锚点对齐,每条逾期记录只呈现一个发生时点,且不会积累回放任务。
- 投递绝不会夸大模型成功或 acknowledgement;队列准入与持久回执之间发生崩溃可能使消息重复。
@@ -6,8 +6,8 @@
en: 7b69ac848815886a
zh: db7a9f4000fe5359
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/problem:
en: 03d0dad3061794af
zh: 4dbef64cd45c4064
en: 87965b88be0e831f
zh: d96767a2cef52269
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision:
en: da6d3f0677f0343b
zh: 8b5dcf7e1108edc9
@@ -15,8 +15,8 @@
en: 2f23fa55e1ce652c
zh: 335695fc654ab1db
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/the-decision-rule:
en: 3734096cc35b0c86
zh: 7b6f4ceaae602c87
en: 554d01427ebebee1
zh: 664c9bf0a9337a04
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting:
en: 00a91b4fb592e1e4
zh: dac2e4f0a3ee9ecf
@@ -24,11 +24,11 @@
en: c21a3e7a773860e5
zh: 566669e245ccdbaf
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/alternatives-considered:
en: bb8f951694de2aea
zh: d53960bf0ec052c7
en: f3dec242b2550529
zh: 0397fa07cc1806bb
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/consequences:
en: beda064bcd194a2a
zh: 8a3bb81d5edcd60a
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/testing:
en: 4b09d253309f2d4d
zh: f897ffad3a1b2fbe
en: 3f9750d67404ac92
zh: 80cb9aced02e2950
@@ -8,7 +8,7 @@ English | [中文](2026-09-02-in-history-system-prompt-replacement.zh.md)
Every system prompt change costs the whole provider prefix cache. The loop renders the prompt on every step; when the bytes differ — a plan-mode section entering or leaving, a skill or tool guidance section registering, an agent-scoped persona shadow, a changed `{{model}}` variable — the request's message 0 changes and the DeepSeek context cache misses from the first token. Long agentic sessions pay this repeatedly, and the [runtime-context snapshot design](../../archived/feature/2026-07-30-current-sandbox-policy-context.md) exists precisely because moving a changing fact out of the prompt was the only way to keep the prefix stable.
A DeepSeek model, recorded here as a model fact supplied for this work, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix, so a tool-set change still invalidates the cache. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm.
A DeepSeek model, recorded here as a model fact supplied for this work, removes that constraint: it accepts a `system` message at any position of the conversation and treats the latest one as the complete effective system prompt, replacing the leading one. Tool schemas remain part of the cached prefix; [dynamic tool updates](../architecture/2026-09-20-dynamic-tool-updates.md) can defer additions, while changed retained definitions still invalidate reuse. With that model the harness can append the new prompt after the cached history instead of rewriting message 0, and the prefix stays warm.
The harness has the representation for this because the [system prompt is surface node 0](../architecture/2026-09-02-system-prompt-as-surface-node.md): a prompt change is an operation on `system/message` surface nodes, and the choice between "replace the latest system node" and "append a new node" is a per-route decision.
@@ -33,7 +33,7 @@ The loop records the mode in the session: `RequestContext.systemPromptUpdate` jo
| `in-history` | non-empty rendering, a new series starts | log empty replacements for non-empty later system nodes, then rewrite the first system node if needed, even when the latest effective text is unchanged |
| any | the rendered prompt is empty | log empty replacements for non-empty later system nodes, then empty the head if needed; no prompt version remains in derived messages |
`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt.
`startsSeries` is true when the `agent/pre-step` decision declares `startsRequestSeries`, when the surface replace generation moved since the last request (a compaction or any other replacement), or when the visible tool-schema set changed on a route without `toolUpdate` support. Supported tool additions can accompany a prompt append without starting a series. A provider or model swap alone is not a series start for this rule: on a capable destination route the changed prompt is appended, which costs nothing because the route change already misses the cache. A series start already costs the cache, so consolidation keeps only the current prompt in model history. Logged per-node empty replacements remove later prompts from derived messages without a surface delete operation or any replacement of intervening conversation nodes. This also keeps compaction recovery from appending a system update after users already admitted by the failed attempt.
The first attempt admits the prompt after assembly, an accepted `agent/pre-step` decision, `step/start`, the `agent/request` waterfall, and `prepareCall()`. A rejected or empty first input opens no step. Neither async request phase commits the pending system prompt or accepted users, and cancellation during either commits neither. Every attempt synchronously reconciles the same rendered assembly after its own `agent/request` and `prepareCall()`, appends the accepted user batch only on the first attempt, logs header/context as needed, and derives and freezes the request before streaming through the same prepared call. Retries do not repeat assembly, `agent/pre-step`, or user admission. Reconciliation sees both pre-step compaction (`compaction-basic` with `auto: true`) and recovery compaction, and consolidates non-empty prompt text at the head when either starts a new series. Resume is series-continuing — the `resume` header is not a series start — so a prompt that changed across a restart is appended; the provider cache may still be warm across a process boundary.
@@ -69,7 +69,7 @@ Chat and Trajectory interpret the effective prompt through the pure `uiConversat
**Use the preceding request context for admission.** It describes the previous call, not the adapter bound after request middleware. It can select the wrong prompt representation on the first call, after resume, or after a route or capability change. Resolving before prompt and user commits also keeps cancellation from admitting unsent content. Rejected.
**Treat a provider or model swap as a series start.** It would fold the prompt into node 0 on every route change, matching the tools case. The header already records the change and the cache misses either way, so the extra rule bought nothing but a special case in the loop. Rejected.
**Treat a provider or model swap as a series start.** It would fold the prompt into node 0 on every route change, matching a tool change on an incapable route. The header already records the change and the cache misses either way, so the extra rule bought nothing but a special case in the loop. Rejected.
**Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected.
@@ -89,7 +89,7 @@ Lifecycle verification requires no event for an unchanged prompt and an appended
- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` covers capable-to-incapable routing with changed or unchanged text, incapable-to-capable routing, resumed-route admission, cancellation in request middleware or preparation, and a concurrent selection change while the prepared route stays bound. Retry-compaction cases shadow the latest prompt with or without an earlier surviving update and verify reuse of the admitted assembly, one user admission, and no extra series header on an unchanged retry. Clear cases on capable and incapable routes remove three active prompt versions, keep repeated requests and seeded resume empty without extra prompt events, and restore only the new text; log reconstruction and the pi converter retain no old instructions. Focused coverage of `src/agent.ts` and `src/runtime-context.ts` reaches 100% for statements, branches, functions, and lines.
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` pins the append on a continuing series, the re-baseline at a series start with or without surviving later nodes and with changed or unchanged effective text, the empty-prompt clearing of all active versions, and the replace-only behaviour without the capability.
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` pins the appended node under an inherited header with `request/context` carrying `systemPromptUpdate`, the series-start fold into node 0, the compaction-driven re-baseline, and the tool-schema change re-baseline under a `change` header that starts a series.
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` pins the appended node under an inherited header with `request/context` carrying `systemPromptUpdate`, the series-start fold into node 0, the compaction-driven re-baseline, and the tool-schema change re-baseline on a route without `toolUpdate`, under a `change` header that starts a series.
- `packages/llm/llm/tests/service.spec.ts`, `packages/llm/llm-deepseek/tests/adapter.spec.ts`, and `packages/test-support/llm-replay/tests/llm-replay.spec.ts` pin the declared mode on resolved model info and the load-time rejection of any other value.
- `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` pins newest/middle prompt removal, exact heuristic totals, surface ordering after head rewrites, extra source-event citations, dormant empties and fallback clears, immutable transitions, compact retained checkpoints, late registration, replay, and version invalidation.
- `packages/client/ui-conversation`, `ui-chat`, and `ui-trajectory` client specs pin the update card, the same-step header dedupe, the absent system change after an update, and the synthetic trajectory header.
@@ -8,7 +8,7 @@ Status: implemented
每一次系统提示词变更都要付出整个提供方前缀缓存的代价。循环在每个步骤渲染提示词;一旦字节不同——plan 模式片段进入或退出、某个 skill 或工具指引片段完成注册、agent 作用域的 persona 遮蔽、`{{model}}` 变量改变——请求的消息 0 随之改变,DeepSeek 上下文缓存从第一个 token 起失效。长时间的 agent 会话反复为此付费,而[运行时上下文快照设计](../../archived/feature/2026-07-30-current-sandbox-policy-context.md)之所以存在,正是因为把会变化的事实移出提示词是保持前缀稳定的唯一办法。
一个 DeepSeek 模型——在此按为本项工作提供的模型事实记录——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀,因此工具集变更仍会使缓存失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。
一个 DeepSeek 模型——在此按为本项工作提供的模型事实记录——移除了这一限制:它接受对话任意位置的 `system` 消息,并把最新一条视为完整的有效系统提示词,替换最前面那条。工具 schema 仍属于被缓存的前缀;[动态工具更新](../architecture/2026-09-20-dynamic-tool-updates.zh.md)可以延迟新增声明,而保留定义的变化仍使复用失效。有了这样的模型,harness 可以把新提示词追加到已缓存的历史之后而不是重写消息 0,前缀就能保持热态。
因为[系统提示词是 surface 第 0 号节点](../architecture/2026-09-02-system-prompt-as-surface-node.zh.md),harness 拥有实现这一点的表示:提示词变更是对 `system/message` surface 节点的操作,而「替换最新的系统节点」与「追加新节点」之间的选择是逐路由的决定。
@@ -33,7 +33,7 @@ Status: implemented
| `in-history` | 非空渲染文本,新序列开始 | 为非空的后续系统节点记录空内容替换,再按需重写首个系统节点,即使最新有效文本未变也执行 |
| 任意 | 渲染后的提示词为空 | 为非空的后续系统节点记录空内容替换,再按需清空头节点;派生消息中不保留任何提示词版本 |
`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、可见工具 schema 集合发生了变化。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。
`startsSeries` 在以下情况为真:`agent/pre-step` 决定声明了 `startsRequestSeries`、surface 的替换代数自上次请求以来发生了移动(压缩或任何其他替换)、不支持 `toolUpdate` 的路由上可见工具 schema 集合发生了变化。受支持的工具添加可以与提示词追加一起发生,无需开启新序列。仅 provider 或 model 切换对本规则不算序列开始:目标路由具备能力时,变更后的提示词被追加,这不花任何代价,因为路由变更本身已经使缓存未命中。序列开始已经付出了缓存代价,因此归并让模型历史只保留当前提示词。有日志记录的逐节点空内容替换会从派生消息中移除后续提示词,无需 surface 删除操作,也不替换其间的对话节点。这也使压缩恢复不会在失败尝试已接纳的用户消息之后追加系统更新。
首次尝试在组装、被接纳的 `agent/pre-step` 决策、`step/start`、`agent/request` waterfall 与 `prepareCall()` 之后才接纳提示词。被拒绝或为空的首次输入不打开步骤。两个异步请求阶段都不提交待处理的系统提示词与已接纳用户消息,在任一阶段取消都不会提交这两者。每次尝试都在各自的 `agent/request` 与 `prepareCall()` 之后同步协调同一份已渲染组装结果、仅在首次尝试追加已接纳用户批次、按需记录 header/context、派生并冻结请求,再通过同一个已准备调用发起流式请求。重试不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 压缩(`auto: true` 的 `compaction-basic`)与恢复压缩,并在任一种压缩开启新序列时将非空提示词文本归并到头部。恢复属于序列延续——`resume` header 不是序列开始——因此跨重启发生变化的提示词被追加;提供方缓存在进程边界之后可能仍是热的。
@@ -69,7 +69,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释
**用先前的请求上下文决定准入。** 它描述上一次调用,而非请求中间件之后绑定的适配器。在首次调用、恢复之后、路由或能力变更之后,它可能选错提示词表示。在提交提示词与用户消息之前解析,还能防止取消时接纳未发送的内容。被否决。
**把 provider 或 model 切换视为序列开始。** 它会在每次路由变更时把提示词折回第 0 号节点,与 tools 的情形一致。header 已经记录了该变更,缓存无论如何都会未命中,因此这条额外规则除了在循环中多一个特例之外没有任何收益。被否决。
**把 provider 或 model 切换视为序列开始。** 它会在每次路由变更时把提示词折回第 0 号节点,与不具备能力的路由上的工具变更情形一致。header 已经记录了该变更,缓存无论如何都会未命中,因此这条额外规则除了在循环中多一个特例之外没有任何收益。被否决。
**仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。
@@ -89,7 +89,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释
- `packages/core/agent-loop/tests/system-prompt-admission.spec.ts` 覆盖文本变化或未变时从具备能力切换到不具备能力的路由、反向路由切换、恢复时的路由准入、请求中间件或准备阶段取消,以及已准备路由保持绑定时并发选择发生变化。重试压缩用例覆盖遮蔽最新提示词后有或没有更早更新存活的情况,并验证复用已接纳的组装结果、用户消息仅接纳一次,以及未变的后续重试不会多记序列 header。具备和不具备能力路由的清除用例会移除三个生效提示词版本,验证重复请求与带 seed 的恢复保持为空且不多记提示词事件,并仅恢复新文本;日志重建与 pi 转换器都不保留旧指令。`src/agent.ts` 与 `src/runtime-context.ts` 的聚焦覆盖率在语句、分支、函数和行四项均达到 100%。
- `packages/core/agent-loop/tests/system-prompt-projection.spec.ts` 钉住序列延续时的追加、序列开始时无论是否存在后续存活节点、有效文本是否变化都执行的重新基线化、空提示词对所有生效版本的清除,以及不具备能力时只做替换的行为。
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` 钉住继承 header 下追加的节点及携带 `systemPromptUpdate` 的 `request/context`、序列开始时折回第 0 号节点、由压缩驱动的重新基线化,以及在开启序列的 `change` header 下由工具 schema 变更驱动的重新基线化。
- `packages/core/agent-loop/tests/request-reconstruction.spec.ts` 钉住继承 header 下追加的节点及携带 `systemPromptUpdate` 的 `request/context`、序列开始时折回第 0 号节点、由压缩驱动的重新基线化,以及在不支持 `toolUpdate` 的路由上、开启序列的 `change` header 下由工具 schema 变更驱动的重新基线化。
- `packages/llm/llm/tests/service.spec.ts`、`packages/llm/llm-deepseek/tests/adapter.spec.ts` 与 `packages/test-support/llm-replay/tests/llm-replay.spec.ts` 钉住已解析模型信息上声明的模式,以及加载时对任何其他值的拒绝。
- `packages/llm/token-meter/tests/context-breakdown-projection.spec.ts` 钉住最新与中间提示词移除、精确启发式总量、头部改写后的 surface 顺序、额外来源引用、休眠空节点与回退清空、不可变转换、紧凑保留检查点、延迟注册、重放和版本失效。
- `packages/client/ui-conversation`、`ui-chat` 与 `ui-trajectory` 的客户端测试钉住更新卡片、同一步骤 header 的去重、更新之后不存在系统变更,以及合成的轨迹 header。
@@ -9,8 +9,8 @@
en: f1aeaa7df97a744f
zh: 039b2cccd9a45198
/agent-note-desktop-mandatory-update-client-and-blocking-window/decision:
en: fc0fa9c713fb74e8
zh: 783f643486eeccaa
en: 7d8ece211be36774
zh: d25c3ebac6420187
/agent-note-desktop-mandatory-update-client-and-blocking-window/alternatives-considered:
en: 136afb04a37db4a1
zh: 2b049e0d9fd8aa27
@@ -10,9 +10,11 @@ A local business server does not deliver remote minimum-version policy. The Desk
## Decision
Native update overlays own their parent-visibility subscription until closure. On macOS, hiding a parent hides its child windows without closing them; showing the parent does not restore those children. The overlay waits for both document readiness and a visible parent, then restores itself on subsequent parent show events. Keeping this in the shared overlay preserves ordinary and mandatory presentation without coupling update policy to onboarding or clearing an active block.
The shell polls the guest protocol in the [API proposal](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.md), independently of business traffic. Configuration and application identity are embedded at packaging; packaged applications ignore environment overrides. The [Desktop README](../../../../apps/desktop/README.md) owns configuration fields and defaults. Packaging requires the policy origin selected by the updater deployment before artifact preparation or signing. Test uses isolated Feishu authentication; production remains anonymous. The test login window opens detached DevTools on F12 while retaining its sandbox, navigation allowlist, and memory-only Session. Only unpackaged development can omit policy configuration. This prevents a missing policy setting from silently producing a release without mandatory checks or a test build querying production policy.
A coordinator owns one immutable installed-client identity, one in-flight request, a deadline, interval jitter, and bounded failure backoff. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided.
A coordinator owns one in-flight request, a deadline, interval jitter, and bounded failure backoff. The request identifies the installed client with the same five Platform client headers as account requests — platform, client version, locale, UTC offset in seconds, and an intentionally empty bundle id — and adds architecture, update channel, and bundled runtime version. Platform, architecture, update channel, and bundled runtime version are fixed at packaging, while the locale and UTC offset are sampled from the shell for each request and the version is the shared client build version rather than the Electron bundle version. Manual checks bypass scheduling but join in-flight work. Disposal aborts the request, awaits settlement, and prevents late publication. A flattened `40005` establishes blocking independently of optional title, detail, and download-page fields; localized fallback text and a retry action remain available, while an absent or unapproved page is not offered. Transport, JSON, business-code, or HTTP failures cannot clear a known block. Only a valid fresh no-force response clears it. Same-version offline persistence is not implemented because its product behavior remains undecided.
User-initiated ordinary checks trigger an independent policy refresh without waiting for or presenting its failures. A confirmed block alone cancels ordinary dialogs and transfers presentation to the mandatory dialog. Test authentication waits until an active ordinary dialog finishes; cancellation and failure leave the updater result intact. Concurrent callers reuse the active ordinary or authentication operation instead of creating competing windows.
@@ -10,9 +10,11 @@ Status: implemented
## 决策
原生更新浮层在关闭前持有父窗口可见性监听。在 macOS 上,隐藏父窗口会隐藏但不会关闭其子窗口;重新显示父窗口不会恢复这些子窗口。浮层等待文档就绪且父窗口可见,并在后续父窗口显示事件中恢复自身。由公共浮层处理这一行为,既保持普通更新和强更的展示,也不将更新策略与 onboarding 耦合或清除有效阻塞。
壳独立于业务流量轮询[接口提案](../../proposed/feature/2026-09-08-desktop-mandatory-update-api.zh.md)中的游客协议。配置和应用身份在打包时嵌入;打包应用忽略环境覆盖。[Desktop README](../../../../apps/desktop/README.zh.md)负责配置字段与默认值。打包在准备产物或签名前要求提供由 updater 部署环境选定的策略源站。测试环境使用隔离的飞书鉴权;正式环境保持匿名。测试登录窗口按 F12 会打开独立的 DevTools,同时保留沙箱、导航允许列表和仅驻内存的 Session。只有未打包开发模式可以省略策略配置。这避免因遗漏策略配置而静默产出没有强更检查的发布包,或让测试包查询正式环境策略。
协调器拥有一份不可变的已安装客户端身份、单一在途请求、截止时间、间隔抖动和有上限的失败退避。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
协调器持有单一在途请求、截止时间、间隔抖动和有上限的失败退避。请求用与账号请求相同的五个 Platform 客户端请求头标识已安装客户端——平台、客户端版本、语言、以秒为单位的 UTC 偏移,以及有意保持为空的 bundle id——并额外携带架构、更新通道和内置运行时版本。平台、架构、更新通道和内置运行时版本在打包时固定;语言与 UTC 偏移在每次请求时从壳采样,版本取自共享的客户端构建版本而非 Electron 打包版本。手动检查绕过调度,但复用在途请求。dispose(资源释放)中止请求、等待结算,并阻止迟到发布。扁平化 `40005` 建立阻塞不依赖可选的标题、详情和下载页面字段;本地化兜底文案与重试操作仍可使用,缺失或未获批准的页面地址不会展示。传输、JSON、业务码或 HTTP 错误都不能清除已知阻塞。只有新的有效无需强更响应才能清除它。同版本离线持久化未实现,因为其产品行为尚未确定。
用户发起常规检查时会触发独立的策略刷新,但不会等待或展示策略失败。只有已确认的强更决定会取消常规弹窗,并把展示移交给强更弹窗。测试环境鉴权会等待当前常规弹窗结束;取消和失败会保留 updater 结果。并发调用方复用当前常规操作或鉴权操作,不创建相互竞争的窗口。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-16-desktop-onboarding.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-16-desktop-onboarding.md
/agent-note-device-local-desktop-onboarding:
en: 84a5519665d72899
zh: beaadffa0e139459
/agent-note-device-local-desktop-onboarding/problem:
en: 50c7c883a2115b7e
zh: 60533d618a3b2da7
/agent-note-device-local-desktop-onboarding/decision:
en: e228d2a308f8ad8a
zh: c5eee26119226f32
/agent-note-device-local-desktop-onboarding/alternatives-considered:
en: 82debfeba02ecfdb
zh: c15d8fcc012058e3
/agent-note-device-local-desktop-onboarding/consequences:
en: 65d1dbaf6b39d57a
zh: fa5b562c059b3203
@@ -0,0 +1,33 @@
# Agent Note: Device-local Desktop onboarding
Status: implemented
English | [中文](2026-09-16-desktop-onboarding.zh.md)
## Problem
Account users need an introduction to credit and display choices after native authentication. The native credential entry already has a separate startup responsibility; repeating browser onboarding or treating missing account data as a new user would couple presentation to authentication failures.
## Decision
The [account UI](../../../../packages/client/ui-settings-account/README.md#desktop-onboarding) mounts one shell overlay only when the Desktop preload marker exists, an account credential is stored, and the local introduction is unfinished. Host settings own versioned progress and completion for the installation, without a Platform completion flag or per-account history. Restart resumes the saved step; signing out hides the introduction without erasing it. A signed-out installation with configured model credentials records API-key completion after applying standard process, detailed usage, and enabled developer tools. Account sign-in discards a failed API-key completion write so retry cannot bypass the required welcome page.
Welcome requires an explicit start. The credit page always appears, including for positive balances. Only a ready balance with no positive wallet triggers the additional continue-without-credit confirmation; failed or pending queries never imply zero. Returning from native recharge retains the credit page while the account owner refreshes details. Closing the application during recharge retains the credit step.
Purpose and process selections persist as drafts. Office-only completion selects compact presentation. Development or both purposes require a process choice. Skipping any supported step selects standard process with compact usage and developer tools off. Completion applies Chat and shared developer-tool preferences before saving the done marker so a refused preference write cannot silently finish onboarding. Compact, standard, and detailed persist their corresponding `ui-chat.transcriptView` values.
The existing [native welcome decision](../architecture/2026-09-08-desktop-welcome-window-material.md) owns credential entry, IPC restrictions, and window material. Desktop suppresses automatic Web credential onboarding while retaining explicit API-key editing. Onboarding creates no workspace, demonstration task, or Session event. Static Figma illustrations use transparent, palette-compressed 3× PNG assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration. The account package owns the full-window overlay and its control styling; shared primitives retain generic behavior. Brand headings load a bundled, licensed Montserrat WOFF2 font through the Web entry. Choices retain focus during writes, reduced-motion preferences disable animation, and the application remains inert until the exit overlay unmounts. The confirmation dialog blurs its source page instead of sampling another backdrop in the translucent window.
## Alternatives considered
**Cloud or per-account completion.** The current release requires device-local progress and has no account completion API. Reusing login state as completion would conflate successful authentication with finished setup.
**Skipping credit for positive balances or waiting for recharge confirmation.** The introduction explains billing independently of wallet state. Waiting for a balance query would turn return navigation into a network-dependent action without establishing whether a payment completed.
**Duplicating preference controls.** Onboarding writes work details, usage, and developer tools through their existing owners.
## Consequences
An existing API-key installation without an onboarding completion marker also receives these defaults on its first upgraded launch, replacing any prior Chat display, usage, and developer-tools preferences. Installations already marked complete retain their preferences.
One installation's completion applies across account changes, and another installation runs its own introduction. Preference and completion writes are ordered but are not one transaction; retry can reapply the same preference after a failed completion write. Completed installations do not reapply preferences on subsequent launches. The [controller tests](../../../../packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts) and [UI tests](../../../../packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx) own state and interaction coverage; production authorization and payment behavior remain separate integration validation.
@@ -0,0 +1,33 @@
# Agent Note: 设备本地 Desktop 引导
Status: implemented
[English](2026-09-16-desktop-onboarding.md) | 中文
## 问题
账号用户在完成原生认证后,需要了解额度并选择展示方式。原生凭证入口已经承担独立的启动职责;重复运行浏览器引导,或将缺失的账号数据当成新用户,会把展示流程与认证故障耦合。
## 决策
[账号 UI](../../../../packages/client/ui-settings-account/README.zh.md#desktop-onboarding)仅在存在 Desktop preload 标记、已存储账号凭证且本地引导未完成时,挂载一个 shell 覆盖层。Host 设置持有当前安装的版本化进度与完成状态,不使用 Platform 完成标记或逐账号历史。重启恢复已保存的步骤;退出登录会隐藏引导,但不清除进度。未登录且已配置模型凭证的安装会记录 API Key 完成状态,并应用标准过程、详细用量和开启开发者工具的配置。账号登录时丢弃失败的 API Key 完成写入,避免重试绕过必需的欢迎页。
欢迎页必须显式开始。额度页始终出现,包括余额为正的情况。仅在余额已就绪且没有正余额钱包时,才额外确认是否继续而不充值;查询失败或等待中绝不表示余额为零。从原生充值页面返回后保留额度页,由账号属主在后台刷新资料。充值期间关闭应用会保留额度步骤。
用途和过程选择会作为草稿持久化。仅选办公用途时采用简洁展示并完成。选择开发或两种用途时必须选择过程详情。任意可跳过步骤均采用标准过程、简洁用量和关闭开发者工具。完成时先应用 Chat 和共享开发者工具偏好,再保存完成标记,避免偏好写入被拒绝后静默完成引导。compact、standard 和 detailed 各自持久化为对应的 `ui-chat.transcriptView` 值。
既有[原生欢迎决策](../architecture/2026-09-08-desktop-welcome-window-material.zh.md)负责凭证入口、IPC 限制和窗口材质。Desktop 抑制自动 Web 凭证引导,保留显式 API Key 编辑。引导不创建工作区、示例任务或 Session 事件。静态 Figma 插图使用透明、调色板压缩的 3× PNG 资源;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊。账号包负责整窗覆盖层及其控件样式,共享基础组件保留通用行为。品牌标题通过 Web 入口加载附带许可的 Montserrat WOFF2 字体。保存选择时保留焦点,减少动态效果偏好会禁用动画,应用在退出覆盖层卸载前保持不可交互。确认弹窗模糊源页面,避免在半透明窗口内再次采样背景。
## 考虑过的替代方案
**云端或逐账号完成状态。** 当前版本要求设备本地进度,且没有账号完成 API。将登录状态用作完成标记,会混淆认证成功与设置完成。
**正余额时跳过额度页,或等待充值确认。** 引导独立于钱包状态说明计费。等待余额查询会使返回导航依赖网络,却无法确定是否完成支付。
**重复实现偏好控制。** 引导通过现有属主写入工作过程、用量与开发者工具配置。
## 后果
尚无引导完成标记的已有 API Key 安装,在升级后首次启动时也会应用这些默认值,覆盖之前的 Chat 展示、用量和开发者工具偏好;已经标记完成的安装保留原偏好。
同一安装的完成状态适用于切换后的账号,另一安装则运行自己的引导。偏好与完成状态按顺序写入,但不属于同一事务;完成标记写入失败后,重试可能再次应用同一偏好。已完成引导的安装不会在后续启动时重新应用偏好。[控制器测试](../../../../packages/client/ui-settings-account/tests/onboarding-state.client.spec.ts)与 [UI 测试](../../../../packages/client/ui-settings-account/tests/desktop-onboarding.client.spec.tsx)负责状态和交互覆盖;生产授权与支付行为仍需独立的集成验证。
@@ -10,7 +10,7 @@
zh: b53c1a932d547f55
/agent-note-shared-developer-tool-settings/decision:
en: 3fc14af35b025970
zh: 0e6047f33b7e91db
zh: 8359262a5c5d3805
/agent-note-shared-developer-tool-settings/alternatives-considered:
en: df1b80e5b8d2a178
zh: 0136d16fc44d57bf
@@ -12,7 +12,7 @@ Status: implemented
设置域内的 `ctx.configForms.developerTools` 偏好对象使用共享条目表单,提供一个已接受的 `ui-settings.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换 agent 模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。
插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。
@@ -9,11 +9,11 @@
en: 952ca70e83872f88
zh: 471b4e39dba2039a
/agent-note-first-use-default-workspace/decision:
en: 7f51354b3082f290
zh: 8f6ce1c14f11f888
en: 5f70a77db26ed9e2
zh: 8f6bfb84385315d4
/agent-note-first-use-default-workspace/alternatives-considered:
en: 65c93ecfbbcf22a9
zh: c88ed47852200752
en: 58523eb415e53e2a
zh: 714ddd81ef7d3262
/agent-note-first-use-default-workspace/consequences:
en: ab39d4e9302a2641
zh: 8921bb8451ef2d0e
@@ -14,7 +14,9 @@ Startup waits for complete Workspace and Session baselines. When both lists are
The [Workspace registry](../../../../packages/workspace/workspace/README.md#first-use-workspace) owns eligibility and directory preparation. All live Sessions, persisted headers, and archived identities count, including entries absent from the sidebar. The operation shares the registry mutation queue and rechecks Session history after directory preparation because Sessions can start independently.
The Client resolves the initial directory name and title from its language at startup. The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md).
The Host controller resolves the Documents location; the registry receives a directory resolver with no locale dependency. The resolver runs inside the mutation queue only for eligible creation, so repeated requests reuse the durable Workspace without another OS lookup. A durable Workspace id records successful initialization independently of that name. Renaming, changing language, restarting, or deleting the registration cannot initialize another default. The marker commits with the registration, so a failed registration can retry. Directory contents remain subject to the existing [metadata-only deletion policy](2026-07-27-workspace-registration-deletion.md).
The naming half of this decision is superseded: [language-neutral default Workspace naming](2026-09-23-language-neutral-default-workspace-naming.md) fixes the directory name and stored title and localizes only the on-screen label.
Ineligible first use returns no Workspace without a failure dialog. Preparation failure offers the existing folder picker; startup does not retry on later list notifications. A successful registration survives Session creation or prompt failure. Selecting either the default or a manually picked Workspace never submits a message.
@@ -23,7 +25,7 @@ Ineligible first use returns no Workspace without a failure dialog. Preparation
- Creating only when sending avoids unused directories but requires a separate local draft, transfer into a Session, and coordination of the first submission. Startup creation accepts the early filesystem effect and uses ordinary Session input throughout.
- Falling back to `<home>/Documents` when system lookup is unavailable would choose an unverified Documents location; deployments that need another directory use the explicit Host override.
- Inferring first use from visible sidebar rows would ignore archived, hidden, and cwd-less Sessions.
- Using the localized path as the initialization marker would allow language changes or deletion to create another default.
- Using the initialization directory's path as the initialization marker would allow renaming, relocation, or deletion to create another default.
- Changing a Session's cwd after creation would change the meaning of its recorded tools and attachments.
## Consequences
@@ -14,7 +14,9 @@ Status: implemented
[Workspace registry](../../../../packages/workspace/workspace/README.zh.md#first-use-workspace)负责资格判断和目录准备。所有 live Session、持久化 header 和已归档身份都计入判断,包括侧栏中不可见的条目。操作共用 registry 修改队列,并在目录准备后重新检查 Session 历史,因为 Session 可以独立启动。
Client 在启动时按其语言解析初始目录名和标题。Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。
Host 控制器解析 Documents 位置;注册表接收不依赖 locale 的目录解析器。解析器仅在允许创建时于变更队列内运行,因此重复请求直接复用持久化的 Workspace,无需再次查询操作系统目录。持久化 Workspace id 独立于名称记录初始化成功。改名、切换语言、重启或删除登记均不能再次初始化默认工作区。标记与登记一起提交,因此登记失败可以重试。目录内容仍遵循已有的[仅删除元数据策略](2026-07-27-workspace-registration-deletion.zh.md)。
本决策中与命名相关的部分已被取代:[语言中立的默认工作区命名](2026-09-23-language-neutral-default-workspace-naming.zh.md)固定目录名与存储标题,只本地化屏幕上的标签。
不符合首次使用条件时不返回 Workspace,也不显示失败弹窗。准备失败时提供现有文件夹选择器;后续列表通知不会触发启动流程重试。成功的登记会在 Session 创建或提示词发送失败后保留。选中默认或手动选择的 Workspace 均不会提交消息。
@@ -23,7 +25,7 @@ Client 在启动时按其语言解析初始目录名和标题。Host 控制器
- 仅在发送时创建可以避免未使用的目录,但需要独立的本地草稿、向 Session 转移草稿及首次提交协调逻辑。启动时创建接受提前写入文件系统的副作用,始终使用普通 Session 输入。
- 系统查询不可用时回退到 `<home>/Documents`,会选择未经确认的 Documents 位置;需要其他目录的部署使用显式 Host 覆盖配置。
- 根据侧栏可见行推断首次使用,会忽略已归档、隐藏和无 cwd 的 Session。
- 将本地化路径作为初始化标记,会允许切换语言或删除后再次创建默认工作区。
- 将初始化目录的路径作为初始化标记,会允许改名、迁移或删除后再次创建默认工作区。
- 在 Session 创建后更改 cwd,会改变其已记录工具和附件的含义。
## Consequences
@@ -9,11 +9,11 @@
en: 7926983089aebab2
zh: 366e32db00674a83
/agent-note-archiving-a-session-with-running-work/decision:
en: ba02c1b57e6ca76b
zh: 2908fcda98c4734e
en: 23adfbe72d671d6c
zh: 6fec976acfa54720
/agent-note-archiving-a-session-with-running-work/alternatives-considered:
en: c9d0a5c8d9924ad6
zh: f848ba8fa3c0d030
en: 708268e1fe4b4b75
zh: 64a8f6d6f333e090
/agent-note-archiving-a-session-with-running-work/consequences:
en: 977e6f36e62c0618
zh: 706de6bc6bb06282
en: cb3fdefd60483d62
zh: 87d3cead6dc8c37c
@@ -14,7 +14,7 @@ Archive never hides running work. The registry owns the rule and declares one ca
- **Refuse by default.** `archiveSession(sessionId)` rejects a non-empty activity answer with `WorkspaceActiveSessionError`; the API maps it to `workspace/session-active`, whose details carry the families with item ids and labels. A caller that did not ask to stop work never hides it, whichever client or SDK it is.
- **Stop on request, after the write.** `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event: the durable archive set is what the pre-step gate reads, so every wake a stop induces — a cancelled child's settlement, a queued follow-up — is already blocked. The stops are issued and never awaited to settlement, so the Remote returns once every provider's request is out and the user's row hides at once; a stop that fails is a logged warning, not a reason to undo the archive.
- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active reminder as a management delete in its transaction queue — the same durable change the `schedule_delete` tool records, between the same two persistence barriers — answering from its owned runtime's fold of the live log rather than from the projection registry, which is a Client view. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped.
- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped.
- **Archived means no model step.** The API Session Controller's `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage.
- **The refusal is the confirmation.** The sidebar sends the plain archive first; a quiet Session archives without a dialog as before. The `workspace/session-active` refusal opens the stop-and-archive dialog, whose list comes from the Host's answer rather than a client-side mirror of running state, so the dialog names exactly what the Host will stop. Confirming resends with `stopActivity`; the notice offers undo, which restores the Session without resuming the stopped work.
@@ -32,10 +32,10 @@ Archive never hides running work. The registry owns the rule and declares one ca
**Report the families from the API Session Controller.** The controller sees every live Agent, the job registry, and the lineage fields on Session headers, so it could enumerate turns, children, and jobs itself — but then it would know each owner's vocabulary (descriptors and labels, the parent-cause cancel, job ownership). The Agent registry, the job seam, and the Subagent runtime already own that vocabulary, so each family lives with its owner; the controller keeps only the lineage step gate, which reads the archive set and header fields alone. The gate cannot move into `dsh-workspace` in turn: the owners now depend on its types, so the workspace package cannot depend on the Agent event vocabulary without a cycle.
**Refuse cold Sessions with active reminders.** A cold reminder fires only after a resume; an archived Session cannot be opened from the sidebar, and a resume through another operation meets the pre-step gate, so nothing runs hidden; only live Sessions report the `schedule` family.
**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently, and its due row then dispatches into a turn the gate ends as `blocked`, consuming the occurrence without a model step ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)).
## Consequences
An archived Session is guaranteed idle: it either was quiet, or its work was stopped the way the user stops it, and no step runs for it until it is restored. The pin-and-archive decision's "archive needs no confirmation" holds for quiet Sessions only; the running case asks. Stop settlement is best effort: a job that ignores kill or a child that ignores cancel is logged, and the pre-step gate keeps the Session from spending model requests meanwhile. The activity check and the archive write are two steps, so a turn starting between them is hidden, and every step whose pre-step precedes the write still runs with its tool calls, until the first step after the write is blocked. Two residues follow from the write-first order and the gate. A crash between the archive write and the Schedule plugin's delete barrier leaves an archived Session whose reminders are still recorded; unarchiving shows them again, as if the stop had never been requested. A cold archived Session reports no reminders, and an operation that resumes its Agent — rename, model, or queue edits address a Session without unarchiving it — re-arms them: a due one-shot then dispatches its follow-up into a turn the gate ends as `blocked`, so that occurrence is consumed without a model step and is not replayed after unarchive.
An archived Session is guaranteed idle: it either was quiet, or its work was stopped the way the user stops it, and no step runs for it until it is restored. The pin-and-archive decision's "archive needs no confirmation" holds for quiet Sessions only; the running case asks. Stop settlement is best effort: a job that ignores kill or a child that ignores cancel is logged, and the pre-step gate keeps the Session from spending model requests meanwhile. The activity check and the archive write are two steps, so a turn starting between them is hidden, and every step whose pre-step precedes the write still runs with its tool calls, until the first step after the write is blocked. Two residues follow from the write-first order and the gate. A crash between the archive write and the Schedule plugin's stop leaves an archived Session whose stored tasks are still armed; unarchiving shows them again, as if the stop had never been requested, and when such a row's target passes the Schedule runtime selects it on its own timer and resolves the Session's Agent itself, so a one-shot dispatches its follow-up into a turn the gate ends as `blocked` and that occurrence is consumed without a model step and is not replayed after unarchive. Admission itself reports the stored tasks of a Session without a live Agent, so such a Session is refused before that residue can start.
Peer products surveyed for this choice: Codex and Kimi Code stop then archive; Claude Code stops by starving the remote session of writes; Reasonix and MiniMax refuse a busy Session, MiniMax after a bounded cancel; Pi has no archive but aborts before every session switch; OpenCode neither refuses nor stops. Every product that stops does so through its ordinary cancel path so the log ends regularly.
@@ -14,7 +14,7 @@
- **默认拒绝。** `archiveSession(sessionId)` 对非空的活动答案以 `WorkspaceActiveSessionError` 拒绝;API 把它映射为 `workspace/session-active`,details 携带各族及各项的 id 与名称。没有要求停止工作的调用方绝不会把工作藏起来,无论它是哪个客户端或 SDK。
- **按请求停止,先写后停。** `archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件:pre-step 门禁读取的正是持久化的归档集合,因此停止所引发的每一次唤醒——被取消子代理的结算、排队的 follow-up——都已被拦下。停止请求只发出、从不等待收敛,因此 Remote 在每个提供方的请求发出后即返回,用户的行随即隐藏;停止失败只是一条 warn 日志,不是撤销归档的理由。
- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件把每条活动提醒作为其事务队列中的管理删除来删除——与 `schedule_delete` 工具相同的持久化变更,处于同样的两道持久化屏障之间——答案来自其归属 runtime 对活日志的 fold,而不是投影注册表这个 Client 视图。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。
- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。
- **已归档即不跑模型步。** API Session Controller 的 `agent/pre-step` 监听器拒绝为已归档 Session 或其子代理子孙提出的步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。
- **拒绝即确认。** 侧栏先发普通归档;静止的 Session 一如既往无对话框直接归档。`workspace/session-active` 拒绝会打开"停止并归档"对话框,其清单来自宿主的答案而非客户端对运行状态的镜像,因此对话框列出的正是宿主将要停止的内容。确认后带 `stopActivity` 重发;提示提供撤销,撤销只恢复 Session,不会让被停止的工作继续。
@@ -32,10 +32,10 @@
**由 API Session Controller 报告各族。** 控制器看得到每个活 Agent、任务注册表和 Session header 上的血缘字段,本可以自己枚举回合、子代理和任务——但那样它就得认识每个 owner 的词汇(描述符与名称、父级原因的取消、任务归属)。Agent 注册表、任务接缝与 Subagent runtime 本来就拥有这些词汇,所以每一族都跟着自己的 owner;控制器只保留只读归档集合与 header 字段的血缘步骤门禁。门禁也不能反过来搬进 `dsh-workspace`:各 owner 现在依赖它的类型,workspace 包再依赖 Agent 的事件词汇就会成环。
**拒绝带活动提醒的冷 Session。** 冷提醒只在 resume 后才触发;已归档的 Session 无法从侧栏打开,经其他操作 resume 也会撞上 pre-step 门禁,所以没有东西在隐藏运行;只有活 Session 报告 `schedule` 族。
**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。
## 后果
已归档的 Session 保证空闲:它要么本来就静止,要么其工作已按用户停止的方式停止,且在恢复之前不会为它跑任何一步。置顶与归档决策里的"归档无需确认"仅对静止 Session 成立;运行中的情形会先询问。停止的收敛是尽力而为:无视 kill 的任务或无视 cancel 的子代理会被记录,pre-step 门禁在此期间阻止该 Session 消耗模型请求。活动检查与归档写入是两步,其间开始的回合会被隐藏,pre-step 先于该写入的每一步连同其工具调用照常执行,直到写入之后的第一步被阻断。先写后停与门禁留下两处残余。在归档写入与 Schedule 插件的删除屏障之间崩溃,会留下一个提醒仍被记录的已归档 Session;取消归档时它们再次出现,与从未请求过停止时一样。冷的已归档 Session 不报告提醒,而恢复其 Agent 的操作——重命名、模型、队列编辑都按 Session 寻址而不取消归档——会重新武装它们:到期的一次性提醒把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发因此在没有模型步的情况下被消耗,取消归档后也不会重放。
已归档的 Session 保证空闲:它要么本来就静止,要么其工作已按用户停止的方式停止,且在恢复之前不会为它跑任何一步。置顶与归档决策里的"归档无需确认"仅对静止 Session 成立;运行中的情形会先询问。停止的收敛是尽力而为:无视 kill 的任务或无视 cancel 的子代理会被记录,pre-step 门禁在此期间阻止该 Session 消耗模型请求。活动检查与归档写入是两步,其间开始的回合会被隐藏,pre-step 先于该写入的每一步连同其工具调用照常执行,直到写入之后的第一步被阻断。先写后停与门禁留下两处残余。在归档写入与 Schedule 插件的停止之间崩溃,会留下一个已存储任务仍被武装的已归档 Session;取消归档时它们再次出现,与从未请求过停止时一样;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent,一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发因此在没有模型步的情况下被消耗,取消归档后也不会重放。准入本身会报告没有活 Agent 的 Session 的已存储任务,因此这类 Session 在残余发生之前就被拒绝。
为此决策调研的同行产品:Codex 与 Kimi Code 先停止再归档;Claude Code 通过让远端会话无法写入来停止;Reasonix 与 MiniMax 拒绝忙碌的 Session,MiniMax 在有界取消之后才拒绝;Pi 没有归档但在每次切换会话前 abort;OpenCode 既不拒绝也不停止。每一个会停止的产品都走自己的常规取消路径,以便日志正常收尾。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-22-archive-admission-reads-stored-tasks.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-22-archive-admission-reads-stored-tasks.md
/agent-note-archive-admission-reads-the-stored-tasks:
en: 8be4b1d210d5d8bf
zh: e9124b48467818b3
/agent-note-archive-admission-reads-the-stored-tasks/problem:
en: 093de39b40f7cc97
zh: 50c712bde174bc96
/agent-note-archive-admission-reads-the-stored-tasks/decision:
en: afbf818442c0794a
zh: 4749e63d9757bbbb
/agent-note-archive-admission-reads-the-stored-tasks/alternatives-considered:
en: 099360f2c97a50ea
zh: eac4746c4602f152
/agent-note-archive-admission-reads-the-stored-tasks/consequences:
en: cb2883b34c2c73fa
zh: 9bb1489c9711cf99
@@ -0,0 +1,27 @@
# Agent Note: Archive admission reads the stored tasks
Status: implemented
English | [中文](2026-09-22-archive-admission-reads-stored-tasks.zh.md)
## Problem
Archive admission asks every provider what still runs for a Session before the archive is written, and a stop-archive then asks those providers to end it ([archiving a Session with running work](2026-09-21-archive-stops-running-session-work.md)). The Schedule provider answered from its owned runtime's fold of the live Session log, so only a Session with a live Agent reported the `schedule` family, and the stop deleted through the management `delete` path.
Host tasks are stored in the Schedule domain document and outlive their Session's Agent ([the storage decision](../architecture/2026-09-16-host-schedule-storage.md)), so that fold no longer exists: `ScheduleRuntime.activeRecords()` is gone, and the stored task table is the only place a due reminder can be seen. A cold Session whose stored rows were still armed therefore reported nothing, archived without the confirmation dialog the running case asks for, and nobody was told which reminder the Host would keep.
## Decision
The Schedule provider answers archive admission from the stored Host task rows, and a stop deletes them. `workspace/session-activity` reads `list({ sessionId })` — the stored rows whose binding is that Session and whose status is active — reports them as the `schedule` family, one item per task with its stored id and title, and prepends that family to `next()` so every other provider keeps its entries. `workspace/session-stop` removes those rows directly inside one slot of the same serialized queue the tools use, so a stop is ordered behind a create whose write is still in flight and cannot miss the row that create is about to commit; it attempts every row, because a row left armed would still fire, and it notifies observers for the rows that landed before the first failure reaches the caller. Session liveness is never consulted: an idle or cold Session whose stored rows are armed refuses the archive, and the confirmation dialog names exactly those reminders. Both listeners are registered in one effect named `schedule.archiveAdmission()`, so unloading the plugin withdraws them together.
## Alternatives considered
**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently, and its due row then dispatches into a turn the gate ends as `blocked`, consuming the occurrence without a model step. The admission answer costs one table read either way.
**Delete each row through the public management `delete` path.** Re-entering the serialized queue from inside the slot that already holds it deadlocks, so the stop removes the rows itself: the same durable change, with one notification and one timer re-drive per stop instead of one per row.
**Keep a runtime-side mirror of the Session's active tasks.** A mirror duplicates the authoritative document, drifts after a restart, and disappears with the Agent — the exact case admission must still answer for.
## Consequences
Admission costs one stored-table read, and a Session with no live Agent can refuse the archive: a Session that looks quiet may now ask for confirmation because its stored reminders are armed. A stop deletes rows one at a time — the domain table offers no batch delete — so a failure partway leaves the earlier deletes committed; observers hear about those, every row was attempted, and the first failure still reaches the caller. One residue follows. A crash between the archive write and the stop leaves stored rows armed in an archived Session: unarchiving shows them again, and when such a row's target passes the Schedule runtime selects it on its own timer and resolves the Session's Agent itself — that resolution is ungated, and only `agent/pre-step` refuses the model step — so a one-shot dispatches a follow-up into a turn the gate ends as `blocked` and that occurrence is consumed without a model step. The seam itself is unchanged: the workspace package still knows no Schedule vocabulary, and this provider owns only reminders.
@@ -0,0 +1,27 @@
# Agent Note:归档准入读取已存储的任务
状态:implemented
[English](2026-09-22-archive-admission-reads-stored-tasks.md) | 中文
## 问题
归档准入在写入归档前询问每个提供方:该 Session 还有什么在跑;随后「停止并归档」再要求它们结束这些工作([归档仍有工作在跑的 Session](2026-09-21-archive-stops-running-session-work.zh.md))。Schedule 提供方原先回答的是其归属 runtime 对活会话日志的 fold,因此只有带活 Agent 的 Session 才报告 `schedule` 族,停止也走管理 `delete` 路径。
宿主任务存储在 Schedule 领域文档中,且比其 Session 的 Agent 活得更久([存储决策](../architecture/2026-09-16-host-schedule-storage.zh.md)),因此那个 fold 已不存在:`ScheduleRuntime.activeRecords()` 已经移除,已存储的任务表是到期提醒唯一能被看见的地方。存储行仍被武装的冷 Session 因此什么也不报告,归档时不会弹出运行态才会弹出的确认对话框,也没有人被告知宿主会继续保留哪条提醒。
## 决策
Schedule 提供方从已存储的宿主任务行回答归档准入,并由停止删除这些行。`workspace/session-activity` 读取 `list({ sessionId })`——绑定到该 Session 且状态为活动的已存储行——把它们作为 `schedule` 族报告,每条任务一项、携带其已存储 id 与 title,并把该族前插到 `next()` 的结果之前,使其他提供方保留各自条目。`workspace/session-stop` 在与工具相同的串行队列的一个槽位里直接删除这些行,因此停止会排在写入尚未完成的创建之后,不会漏掉那个创建即将提交的行;它会尝试每一行,因为留下的行仍会触发;对已经落地的行先通知观察者,再把首个失败交给调用方。是否存活从不参与判断:存储行仍被武装的空闲或冷 Session 会拒绝归档,确认对话框会准确列出这些提醒。两个监听器注册在同一个名为 `schedule.archiveAdmission()` 的 effect 中,卸载插件即一并撤回。
## 已考虑的备选
**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发。两种做法在准入上都只需一次读表。
**每行都走公开的管理 `delete` 路径删除。** 在已经持有该槽位的串行队列内部再次进入队列会自锁,因此停止自己删行:同样的持久化变更,每次停止一次通知、一次计时器重算,而不是每行各一次。
**在 runtime 侧保留该 Session 活动任务的镜像。** 镜像会重复权威文档、在重启后漂移、并随 Agent 一起消失——而这正是准入必须仍能回答的情形。
## 后果
准入的代价是一次读已存储的表,而没有活 Agent 的 Session 也能拒绝归档:看起来安静的 Session 现在可能因为其已存储提醒仍被武装而要求确认。停止逐行删除——领域表不提供批量删除——因此删到一半失败时,先前删除已提交;观察者会被告知这些,每一行都已被尝试,首个失败仍交给调用方。由此剩下一处残余:在归档写入与停止之间崩溃,会在已归档的 Session 里留下仍被武装的已存储行;取消归档时它们再次出现;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent——这一步没有门禁,只有 `agent/pre-step` 拒绝模型步——一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发在没有模型步的情况下被消耗。接缝本身未变:workspace 包仍然不认识 Schedule 词汇,而这个提供方只拥有提醒。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-23-language-neutral-default-workspace-naming.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-23-language-neutral-default-workspace-naming.md
/agent-note-language-neutral-default-workspace-naming:
en: f9065db27c1cd7f9
zh: 3dd1a223a6fcc6c6
/agent-note-language-neutral-default-workspace-naming/problem:
en: 35967bed9e895460
zh: 9cf4da88466ff37c
/agent-note-language-neutral-default-workspace-naming/decision:
en: 72fd5b65bbee5434
zh: 08a4967a1530e686
/agent-note-language-neutral-default-workspace-naming/alternatives-considered:
en: 556df0ed81444162
zh: 5eee5029659419c3
/agent-note-language-neutral-default-workspace-naming/consequences:
en: 5f710ca9cf60d514
zh: 9ea7bdaf8925fd44
@@ -0,0 +1,36 @@
# Agent Note: Language-neutral default Workspace naming
Status: implemented
English | [中文](2026-09-23-language-neutral-default-workspace-naming.zh.md)
## Problem
[First-use default Workspace](2026-09-20-default-workspace.md) let the Client pick both the directory name and the stored title from its startup language: Chinese installations created `默认工作区`, English ones `Default workspace`, everything else `default-workspace`. The name a reader sees and the name written to disk were the same string, so one product decision carried two incompatible requirements. A path is addressed by shell commands, tool arguments, `@path` references, session logs, and backups, and must not depend on which language happened to be active when the installation started. A label is read, and should be in the reader's language. Switching language afterwards satisfied neither: the path was already fixed, and so was the label derived from it.
## Decision
The directory name and the stored title are one fixed, language-neutral string; only the on-screen label follows the reader's language.
`DEFAULT_WORKSPACE_DIRECTORY` (`default-workspace`) is owned by the [Workspace controller](../../../../packages/api/workspace-controller/README.md#first-use-workspace) and published from its own `./default-workspace` subpath — a pure fold with no imports, so client bundles inline it rather than requesting a module-table row the package does not publish. The Host joins it under `<Documents>/deepseek-harness`, and the registry titles the Workspace after the canonical directory's final segment, so the automatic title *is* that same name with no second source to keep in step.
`workspace.initializeDefault` therefore takes no request at all; `WorkspaceInitializeDefaultRequest` and its name validation are deleted, and the registry's resolver returns a path instead of a path and title. No language reaches the Host, so nothing there can disagree with the Client about naming.
Browser consumers label one Workspace through `workspaceDisplayTitle(title, localizedDefault)`: a title still equal to the automatic one reads as the localized default name, and every other title reads verbatim. The name lives in the `common` locale namespace (`workspace.defaultName`) because two packages show it — the Workspace browser resolves it once at the store read so rows, hover card, search meta, and the rename and delete dialogs all inherit it, and the Conversation workspace chip resolves it for the hero.
A rename is what stops a row following the language, and needs no stored flag: the dialog is seeded with the label on screen while the *stored* title decides whether confirming saves. The two differ for a Workspace still carrying its automatic title, so confirming the untouched prefill is a real rename that pins that name. Self-exclusion in the duplicate check moved to Workspace identity for the same reason — the seeded label equals the row's own displayed title without being a conflict with itself.
## Alternatives considered
- Marking the never-renamed state on `WorkspaceView` (a boolean the Host derives from `defaultWorkspaceId` plus the automatic title) removes the false positive below, but widens a shared transport type to carry presentation state the Host has no other use for. Comparing the stored title is the same test with no wire surface.
- Localizing the title inside the Client Workspace store would cover every consumer at one point, but a store that returns product copy owes the locale revision a re-projection and puts copy outside the `t` seat the i18n gate checks.
- Keeping the localized directory name and adding a separate stored title preserves the old path spellings, but leaves every installation's path decided by its first language — the defect this note exists to remove.
- Renaming an existing localized directory on language change would break recorded session `cwd`s, tool arguments, and `@path` references for a cosmetic gain.
## Consequences
Installations created before this change keep their existing localized directory and title. Nothing renames or relocates them; their titles no longer match the automatic name, so they read verbatim — which is what a user who never renamed a Chinese installation already saw.
A Workspace whose stored title is exactly `default-workspace` is labeled as the default even when it is not the registry's default — a folder of that name adopted from the picker, or a rename to that literal. Nothing but the label depends on the distinction. The Workspace browser's client-side duplicate-title check compares localized titles, so it reports a conflict for a second Workspace named the localized default while the Host, comparing stored titles, would allow it; refusing two rows that read identically is the better answer, and the Host still owns the authoritative uniqueness check.
Two *Session* labels derived from a working directory keep showing the raw path segment, because they name a directory rather than a Workspace: the Session Controller's `displayTitleOf` falls back to the cwd basename for a Session with no durable title, and the sidebar's search-result metadata falls back the same way for a Session with no owning Workspace. Both read `default-workspace` where they previously read the localized name. Routing them through `workspaceDisplayTitle` would spread the automatic-title test into Session naming for a case the Session title generator almost always fills in first.
@@ -0,0 +1,36 @@
# Agent Note: 语言中立的默认工作区命名
Status: implemented
[English](2026-09-23-language-neutral-default-workspace-naming.md) | 中文
## Problem
[首次使用默认工作区](2026-09-20-default-workspace.zh.md)让 Client 按启动语言同时决定目录名和存储标题:中文安装环境创建 `默认工作区`,英文创建 `Default workspace`,其他语言创建 `default-workspace`。读者看到的名称与写入磁盘的名称是同一个字符串,因此一个产品决策承担了两项互不兼容的要求。路径会被 shell 命令、工具参数、`@path` 引用、会话日志和备份寻址,不应取决于安装环境启动时恰好使用哪种语言。标签是给人读的,应当使用读者的语言。事后切换语言两者都无法满足:路径已经固定,由它派生的标签也已固定。
## Decision
目录名与存储标题是同一个固定的、语言中立的字符串;只有屏幕上的标签跟随读者语言。
`DEFAULT_WORKSPACE_DIRECTORY`(`default-workspace`)由 [Workspace 控制器](../../../../packages/api/workspace-controller/README.zh.md#first-use-workspace)拥有,并通过其独立的 `./default-workspace` 子路径导出——它是一个没有任何 import 的纯折叠函数,因此 Client 包会内联它,而不是去请求本包并未发布的模块表条目。Host 将它拼接在 `<Documents>/deepseek-harness` 下,注册表则以规范目录的最后一段作为工作区标题,因此自动标题*就是*同一个名称,不存在需要同步的第二个来源。
因此 `workspace.initializeDefault` 完全不接受请求参数;`WorkspaceInitializeDefaultRequest` 及其名称校验被删除,注册表的解析器返回路径而不是路径加标题。语言不会传到 Host,因此那里不可能与 Client 在命名上产生分歧。
浏览器消费方通过 `workspaceDisplayTitle(title, localizedDefault)` 为单个工作区加标签:标题仍等于自动标题时显示为本地化的默认名称,其他标题一律原样显示。该名称放在 `common` 语言命名空间(`workspace.defaultName`),因为有两个包展示它——Workspace 浏览区在读取 store 时解析一次,行、悬浮卡片、搜索结果元信息以及重命名与删除对话框都继承该结果;Conversation 的工作区 chip 则为 hero 自行解析。
让某一行不再跟随语言的动作就是重命名,无需额外存储标记:对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是*存储*标题。对于仍保留自动标题的工作区两者不同,因此直接确认未改动的初值就是一次真实的重命名,会把该名称固定下来。重名检查中排除自身也因此改为按工作区身份判断——初值标签与该行自己显示的标题相同,但这并不构成与自身的冲突。
## Alternatives considered
- 在 `WorkspaceView` 上标记"从未重命名"状态(由 Host 依据 `defaultWorkspaceId` 与自动标题推导的布尔值)可以消除下述误判,但会让一个共享的传输类型多承载一项 Host 本身别无用途的展示状态。比较存储标题是同一项判断,且不增加 wire 表面。
- 在 Client Workspace store 内部本地化标题可以一处覆盖所有消费方,但返回产品文案的 store 需要在语言修订变化时重新投影,而且会把文案放到 i18n 门禁检查的 `t` 座位之外。
- 保留本地化目录名并另存一个标题可以保住旧路径拼写,但每个安装环境的路径仍由其首个语言决定——正是本笔记要消除的缺陷。
- 在语言切换时重命名已有的本地化目录,会为了外观收益破坏已记录的会话 `cwd`、工具参数和 `@path` 引用。
## Consequences
本次改动之前创建的安装环境保留其既有的本地化目录和标题。不会有任何重命名或迁移;它们的标题不再与自动名称相同,因此原样显示——这与从未重命名过中文安装环境的用户已经看到的结果一致。
存储标题恰好为 `default-workspace` 的工作区会按默认工作区显示,即使它并非注册表的默认工作区——例如从选择器采用的同名文件夹,或被重命名为该字面值。除标签之外没有其他行为依赖该判断。Workspace 浏览区在 Client 侧的重名检查比较的是本地化标题,因此当第二个工作区被命名为本地化默认名称时它会报冲突,而按存储标题比较的 Host 会允许;拒绝两行显示完全相同的名称是更好的答案,权威的唯一性检查仍由 Host 拥有。
有两处由工作目录派生的 *Session* 标签仍显示原始路径片段,因为它们命名的是目录而非工作区:Session Controller 的 `displayTitleOf` 在 Session 没有持久标题时回退到 cwd 最后一段,侧栏搜索结果的元信息在 Session 没有所属工作区时同样回退。两处此前显示本地化名称,现在显示 `default-workspace`。把它们接入 `workspaceDisplayTitle` 会把自动标题判断扩散进 Session 命名,而这种情形几乎总会先被 Session 标题生成填上。
@@ -48,6 +48,7 @@ The command orchestration shape is:
pnpm run build:
tsc -b tsconfig.host.json
tsdown --env.DSH_BUILD_FACE host
pnpm --filter @deepseek-ai/dsh-desktop run bundle
tsc -b tsconfig.client.json
tsdown --env.DSH_BUILD_FACE client
pnpm run build:web
@@ -48,6 +48,7 @@ Status: implemented
pnpm run build:
tsc -b tsconfig.host.json
tsdown --env.DSH_BUILD_FACE host
pnpm --filter @deepseek-ai/dsh-desktop run bundle
tsc -b tsconfig.client.json
tsdown --env.DSH_BUILD_FACE client
pnpm run build:web
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-22-desktop-main-bundle-after-workspace-tsdown.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-22-desktop-main-bundle-after-workspace-tsdown.md
/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass:
en: 47f6a531ef05a7be
zh: 18dc33bcd916e2b5
/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/problem:
en: dc5156548e545037
zh: 72010884a2a27877
/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/decision:
en: 9b3c9c696207b13e
zh: 4996ad87dd07d839
/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/alternatives-considered:
en: a15cfe689823ae57
zh: 1d691cd335bdcb87
/agent-note-bundle-the-desktop-main-process-after-the-workspace-tsdown-pass/consequences:
en: e46c28e2ba972cfb
zh: 70d4076a89e0b2ec
@@ -0,0 +1,29 @@
# Agent Note: Bundle the Desktop main process after the workspace tsdown pass
Status: implemented
English | [中文](2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md)
## Problem
A macOS Desktop package built from a fresh worktree crashed at launch with `ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`. The packaging log showed why: rolldown had reported `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`, and the same for `@deepseek-ai/dsh-app-boot` and `@deepseek-ai/dsh-deepseek-account`. The three packages are workspace devDependencies of `apps/desktop` that the main-process bundle must inline, because electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`.
The root tsdown config builds every matched workspace member concurrently (`Promise.all` over the resolved configs in tsdown 0.22, with no dependency ordering), and `apps/desktop` was one of those members. Its bundle resolved the three packages through their `exports` to `lib/index.js` files that other concurrent builds had not written yet. On a checkout that had been built before, the stale `lib/` outputs existed and the bundle inlined them, which is why the defect surfaced only on a clean tree; `clean: false` in both configs kept it hidden. The broken bundle was also smaller (189 KB instead of 315 KB) and the packaging run passed every existing check, because the runtime payload smoke exercises the dsh runtime, not the Electron main process.
## Decision
`apps/desktop` leaves the root tsdown `workspace` list. The root `build:lib:host` script runs the Host tsdown pass and then `pnpm --filter @deepseek-ai/dsh-desktop run bundle`, so the main bundle resolves workspace `lib/` outputs that already exist. The package's own `build` script keeps the same `tsc -b` then `bundle` order for local use. A standalone run of the Desktop config produces byte-identical output to the former workspace-mode run: the config was already self-contained, and the root plugins contributed nothing to it.
The Desktop config also asserts the invariant the crash violated. [`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) is a rolldown plugin on every Desktop bundle whose `moduleParsed` hook fails the build when a module imports a bare specifier the packaged application cannot resolve, whether through a static import, a dynamic `import()`, or a `require()` call: the main bundle may import `electron`, Node builtins, and the manifest's `dependencies`; a sandboxed preload only `electron`, `events`, `timers`, and `url`, the modules Electron's sandbox `require` polyfill resolves. The hook reads each module's import records, where an external import keeps its bare specifier and a bundled one carries an absolute id; chunk metadata (`imports`/`dynamicImports`) omits external `import()` and `require()` targets and cannot serve the check. The check names the actual requirement rather than the symptom, so it also rejects a workspace package moved out of `dependencies` or a broken `exports` entry, and a rejected bundle writes no output.
## Alternatives considered
**Declare the three packages as runtime `dependencies` so electron-builder ships them in the asar.** This is how `@deepseek-ai/cordis` and `@deepseek-ai/dsh-api-gateway` reach the asar today, and it removes the ordering problem. `dsh-app-boot` would bring a large dependency tree into `app.asar/node_modules`, duplicating the copy already inside the packed dsh runtime and enlarging the payload the runtime file policy must screen.
**Fail on rolldown's `UNRESOLVED_IMPORT` warning** through `inputOptions.onLog` or `failOnWarn`. It catches only the unresolved case and would not have covered a dependency misdeclared as a devDependency, which produces a resolved, inlined bundle when the output exists and an external import when it does not. The packaged-imports check covers both with one rule.
**Order the workspace build by dependency graph.** tsdown offers no such ordering, and every other member either inlines nothing from the workspace or externalizes declared `dependencies`, which need no ordering. One extra step for the one package that needs it is smaller than a general scheduler.
## Consequences
A clean checkout, including CI and a fresh worktree, produces the same Desktop main bundle as a long-lived one. A missing or misdeclared runtime import now fails `pnpm run build` and every packaging command with the importing module and the offending specifiers, before signing and notarization spend their time. The Host build gains one sequential step of about a second on a development machine. `docs/development.md` lists the desktop bundle step in the root build order, `scripts/wine-windows-gates.sh` mirrors it, and the [Desktop README](../../../../apps/desktop/README.md#bundled-workspace-dependencies) owns the import rule.
@@ -0,0 +1,29 @@
# Agent Note:Desktop 主进程在工作区 tsdown 阶段之后再打 bundle
Status: implemented
[English](2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) | 中文
## 问题
从全新 worktree 打出的 macOS Desktop 安装包启动即崩:`ERR_MODULE_NOT_FOUND: Cannot find package '@deepseek-ai/dsh-home-paths' imported from app.asar/lib/main.js`。打包日志给出了原因:rolldown 报告了 `[UNRESOLVED_IMPORT] Could not resolve '@deepseek-ai/dsh-home-paths' … treating it as an external dependency`,`@deepseek-ai/dsh-app-boot` 与 `@deepseek-ai/dsh-deepseek-account` 也是同样情况。这三个包是 `apps/desktop` 的工作区 devDependencies,主进程 bundle 必须把它们内联,因为 electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`。
根 tsdown 配置并发构建所有匹配到的 workspace 成员(tsdown 0.22 对解析出的配置做 `Promise.all`,没有任何依赖排序),而 `apps/desktop` 是其中一员。它的 bundle 通过这三个包的 `exports` 解析到 `lib/index.js`,而其他并发构建此时还没有写出这些文件。在此前构建过的检出目录里,陈旧的 `lib/` 产物存在,bundle 会把它们内联,因此缺陷只在干净树上出现;两份配置里的 `clean: false` 一直掩盖着它。坏的 bundle 也更小(189 KB 而不是 315 KB),并且打包流程通过了所有已有检查,因为运行时载荷 smoke 检验的是 dsh 运行时,不是 Electron 主进程。
## 决策
`apps/desktop` 退出根 tsdown 的 `workspace` 列表。根 `build:lib:host` 脚本先跑 Host tsdown 阶段,再执行 `pnpm --filter @deepseek-ai/dsh-desktop run bundle`,让主进程 bundle 解析的是已经存在的工作区 `lib/` 产物。该包自己的 `build` 脚本为本地使用保留同样的先 `tsc -b` 再 `bundle` 的顺序。单独运行 Desktop 配置得到的产物与此前 workspace 模式下的产物逐字节一致:该配置本来就是自足的,根配置的插件没有为它贡献任何内容。
Desktop 配置同时断言这次崩溃所违反的不变量。[`desktop-bundle-imports`](../../../../apps/desktop/scripts/desktop-bundle-imports.mjs) 是挂在每个 Desktop bundle 上的 rolldown 插件,其 `moduleParsed` 钩子在某个模块以静态 import、动态 `import()` 或 `require()` 导入了打包应用无法解析的裸说明符时让构建失败:主进程 bundle 可以导入 `electron`、Node 内置模块与清单的 `dependencies`;沙箱 preload 只能导入 `electron`、`events`、`timers` 与 `url`,即 Electron 沙箱 `require` polyfill 能解析的模块。该钩子读取每个模块的导入记录,其中外部导入保留裸说明符、被打包的导入带有绝对 id;chunk 元数据(`imports`/`dynamicImports`)不包含外部的 `import()` 与 `require()` 目标,无法用于这项检查。这项检查陈述的是真正的要求而非症状,因此它同样拒绝被移出 `dependencies` 的工作区包或损坏的 `exports` 条目,且被拒绝的 bundle 不写出任何产物。
## 备选方案
**把这三个包声明为运行时 `dependencies`,让 electron-builder 装进 asar。** `@deepseek-ai/cordis` 与 `@deepseek-ai/dsh-api-gateway` 今天就是这样进入 asar 的,这也消除了顺序问题。但 `dsh-app-boot` 会把一棵很大的依赖树带进 `app.asar/node_modules`,与 packed dsh 运行时内已有的副本重复,并扩大运行时文件策略必须筛查的载荷。
**通过 `inputOptions.onLog` 或 `failOnWarn` 让 rolldown 的 `UNRESOLVED_IMPORT` warning 直接失败。** 它只覆盖未解析这一种情况,覆盖不了被误声明为 devDependency 的依赖:产物存在时它会被解析并内联,不存在时才留成外部导入。打包导入检查用一条规则同时覆盖两者。
**按依赖图给 workspace 构建排序。** tsdown 不提供这种排序,而其他每个成员要么不从工作区内联任何内容,要么把已声明的 `dependencies` 留成外部导入,都不需要排序。为唯一需要它的包多加一步,比通用调度器更小。
## 后果
干净的检出目录(包括 CI 与全新 worktree)产出的 Desktop 主进程 bundle 与长期使用的检出目录一致。缺失或误声明的运行时导入现在会让 `pnpm run build` 和每条打包命令在签名与公证耗时之前失败,并报出导入方模块与违规说明符。Host 构建多出一个串行步骤,在开发机上约一秒。`docs/development.md` 在根构建顺序中列出了 desktop bundle 步骤,`scripts/wine-windows-gates.sh` 与之保持一致,导入规则由 [Desktop README](../../../../apps/desktop/README.zh.md#bundled-workspace-dependencies) 负责。
@@ -15,8 +15,8 @@
en: 6c82820724930bb5
zh: 5f6a0bbfd6fb6b16
/agent-note-desktop-mandatory-update-api/proposal/request:
en: d015126512123574
zh: a77e66d4f1808402
en: fb66b137a600f3a6
zh: 83f5c74a1716d57c
/agent-note-desktop-mandatory-update-api/proposal/no-mandatory-update:
en: 4814b1bd7002d6ed
zh: 3f0d77beb046e2fc
@@ -24,8 +24,8 @@
en: d3ae240665143695
zh: 486115cd2fdf35ed
/agent-note-desktop-mandatory-update-api/proposal/errors-and-policy-matching:
en: 5da5e6a38a739201
zh: 5af5743f9f162dd4
en: ce7ab9adf3cbc840
zh: d6f9734ab54b8f33
/agent-note-desktop-mandatory-update-api/proposal/policy-publication-and-current-decisions:
en: d4d70e1541b2c2d1
zh: a1b6e62a05b239be
@@ -28,21 +28,23 @@ The query itself must return the complete mandatory response; do not rely on int
GET /api/v0/check_client_update?scenario=launch
x-client-platform: desktop-win
x-client-version: 0.1.3-rc.2
x-client-bundle-id: com.deepseek.dsh
x-client-locale: zh-CN
x-client-bundle-id:
x-client-locale: zh_CN
x-client-timezone-offset: 28800
x-client-arch: x64
x-client-update-channel: nightly
x-client-bundled-dsh-version: 0.1.3-rc.2
```
All listed headers are required for Desktop. Values describing installed software come from the application and its release metadata, not editable UI fields.
All listed headers are required on every Desktop request; a required header may still carry an empty value, as `x-client-bundle-id` does. The values describing installed software come from the application and its release metadata, not editable UI fields.
| Header | Meaning and allowed values |
|---|---|
| `x-client-platform` | `desktop-win` or `desktop-mac` |
| `x-client-version` | Full Desktop SemVer, retaining prerelease identifiers; initially equal to bundled dsh |
| `x-client-bundle-id` | Application identity, for example `com.deepseek.dsh`; distinguishes Harness from Chat |
| `x-client-locale` | UI locale, for example `zh-CN`; selects localized content, not region |
| `x-client-version` | Shared client build version, inlined as `DSH_CLIENT_VERSION` at packaging; full SemVer retaining prerelease identifiers, initially equal to the bundled dsh version |
| `x-client-bundle-id` | Platform uses this field to identify the Chat application; the field does not apply to Harness, so Desktop sends it empty |
| `x-client-locale` | `zh_CN` or `en_US`, taken from the active UI language's primary subtag; selects localized content, not region |
| `x-client-timezone-offset` | Whole-second offset from UTC, positive east of Greenwich, sampled from the shell for every request |
| `x-client-arch` | Windows `x64`; macOS `x64` or `arm64` |
| `x-client-update-channel` | Initially always `nightly`, independent of version suffix |
| `x-client-bundled-dsh-version` | Full bundled dsh version from release metadata |
@@ -102,7 +104,7 @@ Do not add `mode`, `force_update`, `show_key`, `target_version`, button copy, or
Missing required headers, invalid SemVer, and unsupported platform/architecture/channel combinations return explicit parameter errors, not no-force success or a mandatory policy. Prefer the existing business meanings of `biz_code = 1` for a missing version and `biz_code = 2` for an invalid version; final error allocation belongs to the backend. Service failures must not masquerade as success because success may remove an existing block.
Match application identity, platform, architecture, Desktop version, bundled dsh version, and channel using server-owned ranges and precedence. Use complete SemVer, not lexical sorting or truncated prerelease values. Initially the two versions are equal and channel is fixed Nightly; independent revisions and channel switching are deferred. Do not require a downgrade or stop returning `40005` merely because a client has queried or displayed it before.
Match platform, architecture, Desktop version, bundled dsh version, and channel using server-owned ranges and precedence. Use complete SemVer, not lexical sorting or truncated prerelease values. Initially the two versions are equal and channel is fixed Nightly; independent revisions and channel switching are deferred. Do not require a downgrade or stop returning `40005` merely because a client has queried or displayed it before.
### Policy publication and current decisions
@@ -28,21 +28,23 @@ GET /api/v0/check_client_update
GET /api/v0/check_client_update?scenario=launch
x-client-platform: desktop-win
x-client-version: 0.1.3-rc.2
x-client-bundle-id: com.deepseek.dsh
x-client-locale: zh-CN
x-client-bundle-id:
x-client-locale: zh_CN
x-client-timezone-offset: 28800
x-client-arch: x64
x-client-update-channel: nightly
x-client-bundled-dsh-version: 0.1.3-rc.2
```
以上 header 对 Desktop 均为必填。描述已安装软件的值来自应用与发布元数据,不来自可编辑 UI 字段。
以上 header 在每次 Desktop 请求中均为必填;必填 header 的取值可以为空,`x-client-bundle-id` 即如此。描述已安装软件的值来自应用与发布元数据,不来自可编辑 UI 字段。
| Header | 含义与取值 |
|---|---|
| `x-client-platform` | `desktop-win` 或 `desktop-mac` |
| `x-client-version` | 完整 Desktop SemVer,保留预发布标识;一期等于内置 dsh |
| `x-client-bundle-id` | 应用身份,例如 `com.deepseek.dsh`,区分 Harness 与 Chat |
| `x-client-locale` | UI 语言,例如 `zh-CN`;选择本地化内容,不代表区域 |
| `x-client-version` | 共享的客户端构建版本,在打包时内联为 `DSH_CLIENT_VERSION`;完整 SemVer,保留预发布标识,一期等于内置 dsh 版本 |
| `x-client-bundle-id` | Platform 用该字段标识 Chat 应用;该字段对 Harness 不适用,因此 Desktop 发送空值 |
| `x-client-locale` | `zh_CN` 或 `en_US`,取自当前 UI 语言的主语言子标签;选择本地化内容,不代表区域 |
| `x-client-timezone-offset` | 与 UTC 的整秒偏移,东为正,每次请求时从壳采样 |
| `x-client-arch` | Windows 为 `x64`;macOS 为 `x64` 或 `arm64` |
| `x-client-update-channel` | 一期固定 `nightly`,独立于版本后缀 |
| `x-client-bundled-dsh-version` | 发布元数据中的完整内置 dsh 版本 |
@@ -102,7 +104,7 @@ x-client-bundled-dsh-version: 0.1.3-rc.2
缺少必填 header、非法 SemVer 或不支持的平台/架构/通道组合返回明确参数错误,不能伪装为无强制成功或强制策略。优先沿用 `biz_code = 1` 表示缺少版本、`biz_code = 2` 表示版本非法的既有业务含义;最终错误码分配由后端负责。服务异常不得伪装成成功,因为成功可能解除已有阻断。
按应用身份、平台、架构、Desktop 版本、内置 dsh 版本与通道匹配,由服务端维护版本范围和优先级。使用完整 SemVer,不按字符串排序或截断预发布部分。一期两个版本相等、通道固定 Nightly,独立修订与通道切换延后。不得要求降级,也不得仅因客户端曾查询或展示过就停止返回 `40005`。
按平台、架构、Desktop 版本、内置 dsh 版本与通道匹配,由服务端维护版本范围和优先级。使用完整 SemVer,不按字符串排序或截断预发布部分。一期两个版本相等、通道固定 Nightly,独立修订与通道切换延后。不得要求降级,也不得仅因客户端曾查询或展示过就停止返回 `40005`。
### 策略发布与当前判定
+1
View File
@@ -43,6 +43,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT |
| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
| [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT |
| [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) | ISC |
| [`@lexical/history`](https://github.com/facebook/lexical) | MIT |
| [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT |
| [`@lexical/text`](https://github.com/facebook/lexical) | MIT |
+1 -2
View File
@@ -1,5 +1,4 @@
# Opt-in Schedule patch over the shipped Web composition. The owner observes
# only roots published after this overlay loads.
# Opt-in host Schedule service and its Session task catalog.
- insert:
- id: time-context
+1
View File
@@ -129,6 +129,7 @@
"@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*",
"@deepseek-ai/dsh-sandbox-local": "workspace:*",
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-sdk-client": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*",
"@deepseek-ai/dsh-session-log-deepseek": "workspace:*",
@@ -1,11 +1,11 @@
{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"turn/start","data":{"turn":1}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":1,"stream":[],"message":{"role":"assistant","content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-6","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[5]}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-6","role":"tool","toolCallId":"unknown-outcome-call","isError":true,"source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[5]}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"interrupted"}}}
{"type":"session/end-seed","data":{}}
@@ -16,10 +16,10 @@
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["I will verify the external state before deciding whether to retry the side-effecting operation."]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
@@ -1,7 +1,7 @@
{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"turn/start","data":{"turn":1}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Start a background job."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -14,15 +14,15 @@
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"list-once","name":"list_agents","args":["{}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"list-once"},"toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [inactive] — descriptorless child"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"step/start","data":{"turn":2,"step":2}}
{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The catalogued subagent is ready. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The catalogued subagent is ready. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
@@ -1,7 +1,7 @@
{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"turn/start","data":{"turn":1}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"sandbox/mode","data":{"mode":"read-only"}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"}},"reason":"initial"}}
@@ -14,16 +14,17 @@
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":"{{tools}}"},"reason":"resume"}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","exit_plan_mode","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","write"]},"reason":"resume","startsSeries":true}}
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"exit_plan_mode"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Tighten this session to read-only.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"delegate-write","name":"subagent","args":["{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}
{"type":"subagent/catalog","data":{"version":0,"childId":"{{sessionId}}","childCreatedAt":0,"mode":"one-shot","label":"Delegated write probe"}}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"tool/result","data":{"turn":2,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"delegate-write"},"toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false,"id":"{{sessionId}}"}},"sourceEventSeqs":[23],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":2,"step":1}}
{"type":"step/start","data":{"turn":2,"step":2}}
{"type":"assistant/message","data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["The delegated child was denied by the sandbox. PARENT_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
@@ -1,7 +1,7 @@
{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"turn/start","data":{"turn":1}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
@@ -14,11 +14,11 @@
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
@@ -1,7 +1,7 @@
{"type":"session","version":3,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"session","version":4,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"turn/start","data":{"turn":1}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: CLAUDE.md\n\nOld CLAUDE rule.\n\nInstructions from: AGENTS.md\n\nOld AGENTS rule.\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"CLAUDE.md\",\"AGENTS.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"b525eb8a6d3660b732dad4b0aff1b7c63ab32890"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"3113bd093ae91976207dcef7390bdc0b2bfcfa10"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
@@ -14,11 +14,11 @@
{"type":"turn/start","data":{"turn":2}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["RESUME_DONE"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
@@ -0,0 +1,121 @@
/** Real provider cache usage across registry additions through the built SDK profile. */
import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { describe, expect, it, onTestFinished } from 'vitest'
const fixturePath = fileURLToPath(new URL('./fixtures/dynamic-tool-cache.mjs', import.meta.url))
const dshBin = fileURLToPath(new URL('../../../lib/bin.js', import.meta.url))
const sampleTool = 'cache_sample'
const sampleLabelGuidance = 'Place each generated sample label on its own line.'
// Provider cache blocks may leave a short uncached suffix of an unchanged request.
const cacheSuffixTokens = 256
interface ObservedRequest {
stage: 'initial' | 'added' | 'removed'
turn: number
step: number
body: {
system?: string
messages: { role: string; content: { type: string; text?: string; tool?: { name: string } }[] }[]
tools?: { name: string; defer_loading?: true }[]
}
}
function usageFor(request: ObservedRequest, events: SessionEvent[]): TokenUsage {
const settled = events.find(event => event.type === 'assistant/message'
&& event.data.turn === request.turn && event.data.step === request.step)
if (settled?.type !== 'assistant/message' || settled.data.usage === undefined) {
throw new Error(`Missing provider usage for turn ${request.turn}, step ${request.step}`)
}
return settled.data.usage
}
function totalInput(usage: TokenUsage): number {
return usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
}
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with real DeepSeek', () => {
it.each([false, true])('keeps the preceding conversation cached after a tool addition (prompt update: %s)', { retry: 0 }, async (updatePrompt) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-tool-cache-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
const sampleLabel = `CACHE_SAMPLE_${randomUUID()}`
const evidencePath = join(root, 'requests.jsonl')
const callsPath = join(root, 'calls.txt')
const patch = join(root, 'dynamic-tool-cache.patch.yml')
await writeFile(patch, JSON.stringify([
{ id: 'llm-deepseek', config: {
apiKeyEnv: 'DEEPSEEK_API_KEY', thinking: 'disabled', maxTokens: 1024,
retryPolicy: { maxRetries: 0 },
models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }],
} },
{ id: 'tools', config: { mode: 'native' } },
{ id: 'session-log-deepseek', disabled: true },
{ id: 'plugin-package-inventory-deepseek', disabled: true },
{ insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] },
]))
const harness = new DeepSeekHarness({
dshBin, profile: 'sdk', patches: [patch], dshHome: join(root, 'home'),
cwd: root, processCwd: root, provider: 'deepseek-official', model: 'deepseek-flash',
env: { ...process.env, DSH_TELEMETRY_DISABLED: '1', DSH_PERMISSION_MODE: 'danger-full-access' },
initializeTimeoutMs: 30_000,
})
onTestFinished(async () => { await harness.close() })
await harness.start()
const session = harness.session()
const events: SessionEvent[] = []
const run = async (prompt: string) => {
const result = await session.run(prompt)
events.push(...result.events)
return result.finalResponse
}
await run('Do not call tools. Reply READY.')
// Unique user history makes hits on shared profile instructions insufficient.
const records = Array.from({ length: 96 }, (_, index) => `record-${index}: ${randomUUID()}`).join('\n')
await run(`Keep these reference records. Do not call tools. Reply READY.\n${records}`)
await run('Keep the reference records. Do not call tools. Reply READY.')
expect(await run(`Use cache_tool_control with action add. After it returns, call ${sampleTool} exactly once and report the generated sample label. This is synthetic example data for this run. Call these separately in order.`)).toContain(sampleLabel)
expect(await readFile(callsPath, 'utf8')).toBe('add\nsample\n')
expect(await run('Use cache_tool_control with action remove. After it returns, reply REMOVED and do not call the sample tool.')).toContain('REMOVED')
expect(await readFile(callsPath, 'utf8')).toBe('add\nsample\nremove\n')
const requests = (await readFile(evidencePath, 'utf8')).trim().split('\n').map(line => JSON.parse(line) as ObservedRequest)
expect(events.filter(event => event.type === 'assistant/attempt')).toHaveLength(0)
expect(requests).toHaveLength(events.filter(event => event.type === 'assistant/message').length)
const [initial, history, warm] = requests
const addedIndex = requests.findIndex(request => request.stage === 'added')
const before = requests[addedIndex - 1]
const added = requests[addedIndex]
const removed = requests.find(request => request.stage === 'removed')
if (!initial || !history || !warm || !before || !added || !removed) throw new Error('Missing cache transition requests')
const historyInput = totalInput(usageFor(history, events))
const precedingInput = totalInput(usageFor(before, events))
const cached = usageFor(added, events).cacheReadTokens ?? 0
process.stdout.write(`SDK tool addition cache: ${JSON.stringify({ updatePrompt, historyInput, precedingInput, cached, deficit: precedingInput - cached, removedCached: usageFor(removed, events).cacheReadTokens })}\n`)
expect(historyInput - totalInput(usageFor(initial, events))).toBeGreaterThan(1024)
expect(usageFor(warm, events).cacheReadTokens ?? 0).toBeGreaterThanOrEqual(historyInput - cacheSuffixTokens)
expect(added.turn).toBe(before.turn)
expect(added.step).toBe(before.step + 1)
expect(before.body.system).toBe(initial.body.system)
expect(added.body.system).toBe(initial.body.system)
expect(added.body.messages.slice(0, before.body.messages.length)).toEqual(before.body.messages)
expect(added.body.messages.some(message => message.role === 'system'
&& message.content.some(block => block.type === 'text' && block.text?.includes(sampleLabelGuidance)))).toBe(updatePrompt)
expect(cached, 'the first post-addition request must reuse the preceding conversation input').toBeGreaterThanOrEqual(precedingInput - cacheSuffixTokens)
expect(initial.body.tools?.map(tool => tool.name)).toEqual(['cache_tool_control'])
expect(added.body.tools?.slice(0, initial.body.tools?.length)).toEqual(initial.body.tools)
expect(added.body.tools?.find(tool => tool.name === sampleTool)).toMatchObject({ name: sampleTool, defer_loading: true })
expect(added.body.messages.flatMap(message => message.content)).toContainEqual({ type: 'tool_addition', tool: { type: 'tool_reference', name: sampleTool } })
expect(removed.body.tools?.map(tool => tool.name)).toEqual(['cache_tool_control'])
expect(removed.body.messages.flatMap(message => message.content).some(block => block.type === 'tool_addition')).toBe(false)
expect(events.filter(event => event.type === 'developer/message').map(event => event.data.message.content)).toEqual([
[{ type: 'tool-addition', toolName: sampleTool }],
[{ type: 'tool-removal', toolName: sampleTool }],
])
})
})
@@ -0,0 +1,68 @@
/** SDK fixture for native registry changes and unmodified DeepSeek request observation. */
import { appendFile } from 'node:fs/promises'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'sdk-dynamic-tool-cache-fixture'
export const inject = ['tools', 'systemPrompt', 'deepseekLlmApiExtensions']
/** Observe each request and let the model add or remove a scoped tool. */
export function apply(ctx, config) {
const states = new Map()
ctx.on('session/event', (session, event) => {
const state = states.get(session.id)
if (state !== undefined && event.type === 'step/start') {
state.turn = event.data.turn
state.step = event.data.step
}
})
ctx.effect(() => ctx.deepseekLlmApiExtensions.register('dynamic_tool_cache_observer', {
async prepare(request) {
if (request.purpose !== undefined) return undefined
const state = states.get(request.sessionId)
if (state === undefined) throw new Error('The SDK request has no observed agent.')
await appendFile(config.evidencePath, `${JSON.stringify({ ...state, body: request.body })}\n`)
return undefined
},
}))
ctx.on('agent/created', ({ agent }) => {
const scope = agent.ctx
const state = { stage: 'initial', turn: 0, step: 0 }
states.set(agent.id, state)
scope.effect(() => () => { states.delete(agent.id) })
scope.effect(() => scope.tools.restrict({ allow: [] }))
let guidance = ''
scope.systemPrompt.section({ name: 'sdk:dynamic-tool-guidance', order: 400, text: () => guidance })
let removeTool
scope.effect(() => scope.tools.register(defineTool({
name: 'cache_tool_control',
description: 'Add or remove the temporary cache_sample tool. It becomes available on the model step after add returns.',
parameters: { action: { type: 'string', enum: ['add', 'remove'], required: true } },
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
async execute({ action }) {
if (action === 'add') {
if (removeTool !== undefined) throw new Error('The sample tool is already registered.')
if (config.updatePrompt) guidance = 'Place each generated sample label on its own line.'
removeTool = scope.effect(() => scope.tools.register(defineTool({
name: 'cache_sample',
description: 'Read a randomly generated sample label created for this example run.',
parameters: {},
output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] },
async execute() {
await appendFile(config.callsPath, 'sample\n')
return config.sampleLabel
},
})))
state.stage = 'added'
await appendFile(config.callsPath, 'add\n')
return 'Tool added. Call cache_sample on the next model step to obtain the generated sample label.'
}
if (removeTool === undefined) throw new Error('The sample tool is not registered.')
await removeTool()
removeTool = undefined
state.stage = 'removed'
await appendFile(config.callsPath, 'remove\n')
return 'Tool removed. The sample tool is unavailable.'
},
})))
})
}
@@ -5,7 +5,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { get } from 'node:http'
import { request as httpRequest } from 'node:http'
import type { IncomingHttpHeaders } from 'node:http'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import ts from 'typescript'
@@ -18,6 +18,7 @@ const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
/** Live process whose IPC observer reports independently collected runtime state. */
interface DefaultWeb {
root: string
url: string
request: (command: 'roster' | 'mount-experimental' | 'mount-experimental-entry') => Promise<RuntimeRoster>
}
@@ -26,13 +27,19 @@ interface DefaultWeb {
* Boot the built Web profile under plain Node and dispose it to quiescence after an assertion callback.
* @param test - owning Vitest case, including its timeout, cancellation, and cleanup hooks.
* @param inspect - assertions against the running process and its ephemeral loopback URL.
* @param options - additional profile patches and test data prepared before startup.
*/
export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWeb) => Promise<void>): Promise<void> {
export async function withDefaultWeb(
test: TestContext,
inspect: (app: DefaultWeb) => Promise<void>,
options: { patches?: readonly string[]; prepare?: (root: string) => Promise<void> } = {},
): Promise<void> {
const root = await mkdtemp(join(tmpdir(), 'dsh-web-default-isolation-'))
let removal: Promise<void> | undefined
const removeRoot = (): Promise<void> => removal ??= rm(root, { recursive: true, force: true })
test.onTestFinished(removeRoot)
try {
await options.prepare?.(root)
await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' }) + '\n')
for (const relative of ['runtime-roster.ts', 'fixtures/runtime-roster-observer.ts']) {
const source = await readFile(new URL(relative, import.meta.url), 'utf8')
@@ -53,7 +60,10 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe
const launch = resolveExampleLaunch({
srcBin: join(repoRoot, 'apps/cli/src/bin.ts'),
mode: 'lib',
configArgs: ['--profile', 'web', '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open'],
configArgs: [
'--profile', 'web', ...options.patches?.flatMap(path => ['--patch', path]) ?? [],
'--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open',
],
env: {
NODE_OPTIONS: undefined,
NODE_PATH: undefined,
@@ -145,7 +155,7 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe
return /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1]
}, { timeout: test.task.timeout }).toBeDefined()
const url = /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)![1]!
await inspect({ url, request })
await inspect({ root, url, request })
} finally {
const result = await close()
test.signal.removeEventListener('abort', abort)
@@ -167,13 +177,26 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe
* @param headers - optional authentication cookie.
* @returns complete response after its stream ends.
*/
export function webGet(url: string | URL, signal: AbortSignal, headers: Record<string, string> = {}): Promise<{
status: number | undefined
headers: IncomingHttpHeaders
text: string
}> {
export function webGet(
url: string | URL, signal: AbortSignal, headers: Record<string, string> = {},
): ReturnType<typeof webRequest> {
return webRequest(url, signal, { method: 'GET', headers })
}
/**
* Send one public-protocol request directly to the test-owned loopback process.
* @param url - Exact URL of the owned process.
* @param signal - Owning test cancellation signal.
* @param options - HTTP method, headers, and optional serialized request body.
* @returns Complete response after its stream ends.
*/
export function webRequest(
url: string | URL,
signal: AbortSignal,
options: { method: 'GET' | 'POST'; headers?: Record<string, string>; body?: string },
): Promise<{ status: number | undefined; headers: IncomingHttpHeaders; text: string }> {
return new Promise((resolve, reject) => {
const request = get(url, { headers, agent: false, signal }, (response) => {
const request = httpRequest(url, { method: options.method, headers: options.headers, agent: false, signal }, (response) => {
response.setEncoding('utf8')
let text = ''
response.on('data', (chunk: string) => { text += chunk })
@@ -181,5 +204,6 @@ export function webGet(url: string | URL, signal: AbortSignal, headers: Record<s
response.once('end', () => { resolve({ status: response.statusCode, headers: response.headers, text }) })
})
request.once('error', reject)
request.end(options.body)
})
}
@@ -0,0 +1,120 @@
/** Built CLI, public Remote requests, and persisted task files; seeded receipts are not delivery evidence. */
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule'
import { expect, it } from 'vitest'
import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts'
const overlay = fileURLToPath(new URL('../../../../config/examples/schedule/cordis.yml', import.meta.url))
const id = 'retained-remote-task' as DailyScheduleRecord['id']
const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId']
const record: DailyScheduleRecord = {
id, kind: 'daily', title: 'Retained instruction', prompt: 'Retained instruction\nwith a second line',
time: '09:00:00.125', timeZone: 'Asia/Shanghai', scheduledAt: '2099-01-01T01:00:00.125Z',
}
const lastDelivery = {
scheduledAt: '2026-01-01T01:00:00.125Z', deliveredAt: '2026-01-01T01:00:01.250Z', messageId: 'saved-receipt',
}
const task = {
sessionId, record, status: 'active', lastDelivery,
deliveryHistory: { earlierRecordsUnavailable: false, records: [{ ...lastDelivery, prompt: record.prompt }] },
}
const taskPath = (root: string) => join(root, 'home', 'storages', 'schedule.json')
async function seed(root: string, bytes: string): Promise<void> {
await mkdir(join(root, 'home', 'storages'), { recursive: true })
await writeFile(taskPath(root), bytes)
}
function encoded(value: unknown): string {
return `${JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks: { [id]: value } } }, null, 2)}\n`
}
async function connect(url: string, signal: AbortSignal) {
const auth = await webGet(url, signal)
const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0]
if (cookie === undefined) throw new Error('Owned Web process did not issue its authentication cookie')
return async (method: string, args: Record<string, unknown>) => {
const rpcId = randomUUID()
const response = await webRequest(new URL(`/api/${method}`, url), signal, {
method: 'POST', headers: { cookie, 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args } }),
})
expect(response.status, response.text).toBe(200)
const envelope = JSON.parse(response.text) as { type?: unknown; rpcId?: unknown; result?: unknown }
expect(envelope).toMatchObject({ type: 'server-response', rpcId })
return envelope.result
}
}
it('edits a retained task through built Remote bindings and preserves its identity and seeded history after cold startup', async (test) => {
let persisted = encoded(task)
let updated = record
const historyRequest = { request: { sessionId, id, limit: 20 } }
const expectedHistory = {
ok: true,
value: {
id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false,
earlierRecordsPruned: false, retention: { days: 30, records: 200 },
},
}
await withDefaultWeb(test, async ({ root, url }) => {
const rpc = await connect(url, test.signal)
expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...record, sessionId, status: 'active', lastDelivery }] })
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
const target = new Date(Date.now() + 12 * 60 * 60 * 1_000)
target.setUTCMilliseconds(125)
updated = { ...record, time: target.toISOString().slice(11, 23), timeZone: 'UTC', scheduledAt: target.toISOString() }
const change = { kind: 'daily', daily: { time: updated.time, time_zone: 'UTC' } }
expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } }))
.toEqual({ ok: true, value: { id, updated: true, record: updated } })
persisted = await readFile(taskPath(root), 'utf8')
expect(JSON.parse(persisted)).toEqual({
unit: { name: 'schedule', version: 1 },
global: null,
tables: { tasks: { [id]: { ...task, record: updated } } },
})
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } }))
.toEqual({ ok: true, value: { id, updated: false, code: 'schedule_conflict' } })
expect(await rpc('schedule/update', { request: { sessionId: 'other-owner', id, expected: updated, change } }))
.toEqual({ ok: true, value: { id, updated: false, code: 'schedule_not_found' } })
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
}, { patches: [overlay], prepare: root => seed(root, persisted) })
await withDefaultWeb(test, async ({ root, url }) => {
const rpc = await connect(url, test.signal)
expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...updated, sessionId, status: 'active', lastDelivery }] })
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
expect(await rpc('schedule/update', { request: {
sessionId, id, expected: updated, change: { kind: 'daily', daily: { time: updated.time, time_zone: 'Etc/UTC' } },
} })).toEqual({ ok: true, value: { id, updated: false, record: updated } })
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
}, { patches: [overlay], prepare: root => seed(root, persisted) })
})
it('creates and idempotently adopts the explicit Session id bound to seeded task data without touching its task file', async (test) => {
const persisted = encoded(task)
const expectedList = { ok: true, value: [record] }
const expectedHistory = {
ok: true,
value: {
id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false,
earlierRecordsPruned: false, retention: { days: 30, records: 200 },
},
}
await withDefaultWeb(test, async ({ root, url }) => {
const rpc = await connect(url, test.signal)
const create = { request: { sessionId, cwd: root } }
const expectedCreate = { ok: true, value: { sessionId, agentPreset: 'standard' } }
expect(await rpc('session/create', create)).toEqual(expectedCreate)
expect(await rpc('session/create', create)).toEqual(expectedCreate)
expect(await rpc('schedule/list', { request: { sessionId } })).toEqual(expectedList)
expect(await rpc('schedule/history', { request: { sessionId, id, limit: 20 } })).toEqual(expectedHistory)
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
}, { patches: [overlay], prepare: root => seed(root, persisted) })
})
@@ -13,10 +13,12 @@ class Account extends DeepSeekAccount {
ended = Promise.withResolvers<undefined>()
reading = Promise.withResolvers<undefined>()
finished = Promise.withResolvers<undefined>()
read: () => Promise<PlatformSession | null> = async () => ({ origin: 'https://platform.example', token: 'test' })
read: () => Promise<PlatformSession | null> = async () => ({ origin: 'https://platform.example', userId: null, token: 'test' })
override async getState() { return state }
override async getProfile() { return null }
override async getBalance() { return null }
override async getUnnotifiedBonuses() { return null }
override async ackBonusNotified() { return false }
override async startSignIn() { return state }
override async cancelSignIn() { return state }
override async signOut() { return state }
@@ -51,7 +53,7 @@ it('clears removed credentials and publishes the replacement provider session',
delivered = Promise.withResolvers<undefined>()
await ctx.plugin((scope) => {
const replacement = new Account(scope)
replacement.read = async () => ({ origin: 'https://platform.example', token: 'replacement' })
replacement.read = async () => ({ origin: 'https://platform.example', userId: null, token: 'replacement' })
})
await delivered.promise
expect(sessions.at(-1)?.token).toBe('replacement')
@@ -95,7 +97,7 @@ it('suppresses a credential read that completes during provider disposal', async
await account.reading.promise
const disposed = provider.dispose()
await cleared.promise
pending.resolve({ origin: 'https://platform.example', token: 'stale' })
pending.resolve({ origin: 'https://platform.example', userId: null, token: 'stale' })
await disposed
expect(sessions).toEqual([null])
await account.finished.promise
+18 -18
View File
@@ -3,23 +3,23 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
/deepseek-harness-desktop:
en: 907e8bc5ebef2678
zh: f23af881b27cad94
en: 45561fc0dd3a1b20
zh: f4b03355d4bd0693
/deepseek-harness-desktop/key-technical-decisions:
en: 47ec880441223749
zh: 3be7731db888b4a4
en: 069fdfd72d2ea26a
zh: 76ae549d43c99bb4
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: c2eb16f568cd8554
zh: 686d64af17b10375
en: 31cbb7452be8e1ad
zh: 502ec556e2ec301e
/deepseek-harness-desktop/installation-ownership:
en: 3fe85101acf17b0a
zh: 70cd207cadc85b94
en: 8307baf6c5b691d0
zh: 86e46b9552017ff6
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
en: 0d11af8639977a6d
zh: 86b086b12d2210d3
/deepseek-harness-desktop/develop:
en: 99f5299ebcf69b97
zh: 0411074daee0cd79
en: 9b9b362a9865e763
zh: c748901168e2db8c
/deepseek-harness-desktop/develop/startup-onboarding:
en: f01d0dbafe8b1613
zh: 91ad92f5d7e505c4
@@ -36,8 +36,8 @@
en: 7d72469be4975afe
zh: 2f644b582cb581be
/deepseek-harness-desktop/package/upload-updates:
en: 7828da306d42ed2e
zh: 0b8bc61c5f612dcb
en: c64d7fe2fe2534b7
zh: 2188e1ae99ab4b33
/deepseek-harness-desktop/package/unsigned-windows-test-installer:
en: cfd5780dd26a8e46
zh: f7a16b1ede793b7b
@@ -48,11 +48,11 @@
en: 8efe3498dbe8686d
zh: a88c16f20a9430f3
/deepseek-harness-desktop/updates:
en: 8ce8647a3ff94b9a
zh: 93250420c9d6f4ce
en: acbd12b8fc5ec5fd
zh: 031e15f931479f27
/deepseek-harness-desktop/updates/mandatory-update-policy:
en: b99d35abf25d5347
zh: ed250334da3f149d
en: 2f789a4949fafb12
zh: d8b28c01cfee11b7
/deepseek-harness-desktop/updates/local-updater-qualification:
en: bf34de891411c618
zh: aa6923fd5060174b
@@ -60,8 +60,8 @@
en: 0b225f914142ef0b
zh: 9ffd07768d37824e
/deepseek-harness-desktop/known-limitations:
en: e084ae80b7ebfaf1
zh: f20ba012b4330a3f
en: 5b77213c86bbcfb6
zh: b34bf21773bb852b
/deepseek-harness-desktop/dev-note:
en: 8e26b460ba31f21b
zh: fbcd0307b3867125
+17 -7
View File
@@ -10,9 +10,9 @@ Desktop’s local native directory flow opens an Electron folder dialog attached
Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations.
Embedded Platform documents use a dedicated non-persistent WebContentsView session. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar.
Embedded Platform documents use a persistent WebContentsView partition keyed by a hash of the Platform origin and stable account ID. localStorage preferences, including dismissed notices, survive view closure and application restart; different accounts and origins use separate storage. The Host supplies the account ID from its latest successful profile read; while no ID is known, the document opens in a disposable partition that keeps no preference across opens. Opening a persistent partition clears cookies, filesystem, IndexedDB, Cache Storage, the HTTP and shader caches, service workers, and HTTP authentication before the document loads; a disposable partition clears all of its storage. Closing a view destroys the document, removes its request interceptors, and schedules the same cleanup, so authentication left by an unclean exit is cleared before the next document loads. The next open and application shutdown await that cleanup, and an update install waits for it before the installer takes over the exit. A failed cleanup rejects that open and blocks the same account until a later cleanup succeeds; other accounts keep opening. Sign-out destroys the document but retains account-scoped preferences for the next sign-in. A document already open in a disposable partition stays mounted when the account ID arrives for the same credential; the next open uses the account-scoped partition. The [storage decision](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.md) describes the retention policy. Host sends account credentials through private Node IPC; account RPC and the Harness renderer receive no token. Before page scripts execute, the Platform preload performs one synchronous IPC to read the prepared main-process credential. It exposes displayMode, synchronous getAuthToken() and getLocale() getters, and onLocaleChange(listener), which returns an unsubscribe function. Both getters read preload memory without further IPC. The bootstrap includes the resolved Desktop language (`zh_CN` or `en_US`); Settings language changes update the preload cache and notify the open Platform document without reloading. Platform applies this language before its first render and does not persist it as a browser preference. The main-process handler performs only sender validation and an in-memory read; it never waits for Host, disk, or network. Trusted-page initialization failures retain embedded mode and make the getter throw, preventing browser-credential fallback. Only the owned Platform main frame on the configured issuer origin can initialize. Sign-out, credential replacement, Host shutdown, and view closure destroy the document. Cross-origin document navigation is blocked. HTTPS links requesting a new window open in the system browser without the embedded session or token; other schemes and URL credentials are rejected. Native views occupy the viewport below the Account feature’s return bar.
Desktop Host Platform API requests use the same `x-client-platform` mapping as update-policy requests. The account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package).
Desktop Host Platform API requests and update-policy requests identify the installed client with the same Platform client headers: platform, client version, locale, timezone offset in seconds, and a bundle id that is intentionally empty. Account operations take the calling UI's identity per call; the account provider owns [API-only header configuration](../../packages/credentials/deepseek-account-platform/README.md#use-this-package). Update policy additionally reports architecture, update channel, and bundled runtime version.
A server-expired account credential returns to Welcome when no official API key is configured; an available API key keeps the workspace open. Explicit sign-out follows the same rule. Both Welcome and the workspace display the localized expiry notice.
@@ -24,10 +24,16 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics.
The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Update overlays block product shortcuts and editing-key delivery to the parent window and its browser guests from creation until the last overlay closes. Every overlay opening or closing invalidates pending chord state. The main process shares one overlay owner between update dialogs and shortcut input through explicit creation and input-state capabilities. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice.
The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release.
### Bundled workspace dependencies
electron-builder copies only the manifest's `dependencies` into `app.asar/node_modules`, so the Electron main bundle `lib/main.js` inlines its workspace devDependencies and leaves `electron`, Node builtins, and those `dependencies` as its only bare imports; a sandboxed preload may leave only `electron`, `events`, `timers`, and `url`, the modules its `require` polyfill resolves. The main bundle resolves the inlined packages from their `lib/` output, so the root `build:lib:host` bundles `apps/desktop` after the concurrent workspace tsdown pass, and [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) fails any Desktop bundle whose static, dynamic, or `require()` import the packaged application cannot resolve. Without that check an import rolldown could not resolve ships as an external specifier and fails at launch with `ERR_MODULE_NOT_FOUND`. The [bundle-order decision](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.md) records the alternatives.
Signed Windows packaging scans the primary runtime and application production dependencies for PE content, including files without a conventional extension. The final scan covers the entire unpacked application. Directory links, malformed `MZ` files and non-PE `.exe`, `.dll` or `.pyd` files stop packaging; data files beginning with `MZ` are also rejected unless they contain a valid PE header. It preserves valid vendor signatures and signs unsigned code before sealing runtime hashes or executing smoke checks. Public-key inspection uses batches of 32 files with at most four processes; hardware-token signing remains serial, and each new signature must match the configured certificate and carry a timestamp. Hardware signing and verification failures stop the run; isolated timestamp requests follow the bounded policy below. Electron-builder preserves copied runtime executables only after signature and exact-byte verification. A final PE-signature audit and a fresh-cache ASAR payload and Host smoke must pass before the release completion record is written. Development, preparation-only and unsigned builds do not use the hardware token and can be blocked by Windows code-integrity policy; no build mode disables that policy. Passing smoke checks does not establish compatibility with every enterprise policy.
Desktop carries independent Python, Node.js and pnpm distributions. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml and XlsxWriter with their complete dependencies. The `load_workspace_dependencies` tool installs this payload offline on first use under `$DSH_HOME/dsh-runtimes/dsh-primary-runtime` (normally `~/.dsh/dsh-runtimes/dsh-primary-runtime`) and returns absolute interpreter, pnpm script and library paths plus `pythonDistributions`, the bundled distribution names and versions. The version report excludes user-installed additions. Office tasks prefer these libraries unless user or workspace instructions select another environment. Execute the pnpm script with the returned Node executable. The returned Node library directory is reserved for bundled libraries, not pnpm's global installation directory.
@@ -68,9 +74,9 @@ The product UI retains Web actions, including "Open In..." through the shared au
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. 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.
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 application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
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.
### Runtime and plugin activation
@@ -92,7 +98,6 @@ 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.
## 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.
@@ -113,6 +118,7 @@ pnpm run start:desktop
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
### Startup onboarding
@@ -259,7 +265,7 @@ Mac packaging reads three tuning fields from `.env.macos`:
The proxy fields are independent and reject URL credentials, paths, queries and fragments. Empty values preserve inherited networking. An explicit download proxy replaces child-process proxy variables and bypasses local hosts only; it does not change system settings. Windows and standalone `release:pack` retain their existing concurrency defaults. Company proxy addresses belong in the ignored local file; see internal documentation for addresses.
Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy.
Apple tooling uses the active macOS network service's HTTP/HTTPS proxies. Configured notarization routing checks proxy reachability, saves that service's settings, enables the proxy around both artifact lanes, and restores the saved settings after both lanes settle. An originally disabled proxy with an empty server and port zero is restored by disabling it; the temporary server and port may remain stored but inactive. Directory-only builds apply it to App notarization after the signed directory build. This temporarily affects other applications and requires permission to change system proxies; PAC, auto-discovery, SOCKS and authenticated proxy configurations must be disabled first. Packaging and recovery acquire the same per-user POSIX file lock before reading recovery data or changing proxies; process exit releases ownership, while the lock file remains in place. The lock loads `@deepseek-ai/node-addon-system/flock` at first use rather than at script start, so `check:package` and the packaging entry load on a checkout that has not built `native/system`; when the host addon binary or the entry's JavaScript is missing at lock time, the loader runs `pnpm run build:native-system` and `pnpm --dir native/system run build:ts` before locking, so recovery works on such a checkout as well. This prevents overlapping proxy transactions across checkouts; other users and network-setting tools must not change these settings concurrently. SIGINT/SIGTERM wait for active work before restoration. After forced termination or a restoration error, stop any remaining notarization processes and run `pnpm --dir apps/desktop run restore:mac-proxy`; the saved record remains until restoration succeeds. Configuration checks validate URL syntax without changing system settings or contacting the proxy.
### Unsigned Windows test installer
@@ -348,6 +354,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
## Updates
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its background blur, input interception, and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur.
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
@@ -375,7 +383,7 @@ The [mandatory client decision](../../.agents/notes/implemented/feature/2026-09-
| `maxBackoffMs` | Maximum failed-request interval including jitter; default `3600000`, at least `intervalMs` |
| `jitter` | Random additional interval fraction; default `0.2`, allowed range `0` through `1` |
Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, exact shell and bundled dsh versions, application ID, locale, and fixed Nightly. It uses no business login credentials or installation ID.
Durations are integers from 1000 through 2147483647 milliseconds. Startup and scheduled polling are independent of business requests; foreground/resume checks respect the next due time, while manual checks bypass it and join any request in flight. The client sends the installed platform, architecture, DSH_CLIENT_VERSION, bundled dsh version, current locale and UTC offset, an empty bundle ID, and fixed Nightly. It uses no business login credentials or installation ID.
With `feishu-test`, an HTTP 401 JSON response containing `error.code: "UNAUTHENTICATED"` offers login during user-initiated checks and the packaged application's initial startup check, without waiting for the local backend. A localized explanation identifies the test build, the need for Feishu authentication, and that login neither downloads nor installs updates. Confirmation closes the explanation before opening a sandboxed window at the configured origin’s root, not a response-provided login URL. Concurrent checks reuse the entire confirmation/login operation and focus its existing window. Press F12 in the test login window to open detached DevTools for diagnosis. Cancellation does not trigger repeated prompts from periodic or foreground checks; users can retry manually.
@@ -414,6 +422,8 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i
- The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate.
- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime.
The app-only `dshOnboarding.hasApiKey()` preload method returns the welcome backend’s current API-key presence boolean; native login and onboarding share credential discovery, and only the owned application main frame may invoke it.
Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Successful browser sign-in switches Welcome to the workspace without activating the application; the completion page’s dsh://open link brings it to the foreground. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available.
When an expired account returns to Welcome, the main process retains a one-time notification until the renderer requests it over window-owned IPC. Reloading Welcome does not repeat the notice; manual sign-out and cold startup do not create it.
+21 -9
View File
@@ -10,9 +10,9 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件
Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。
内嵌 Platform 文档使用独立且不持久化的 WebContentsView 会话。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。
Platform 内嵌文档使用持久化 WebContentsView 分区,分区名由 Platform 来源和稳定账号 ID 的哈希决定。localStorage 中的页面偏好(包括已关闭的通知)在关闭视图和重启应用后保留;不同账号和来源使用独立存储。账号 ID 来自 Host 最近一次成功的资料读取;尚无该 ID 时,文档在一次性分区中打开,该分区不跨次保留偏好。打开持久分区会先清理 Cookie、文件系统、IndexedDB、Cache Storage、HTTP 与着色器缓存、Service Worker 及 HTTP 认证状态;一次性分区则清理其全部存储。关闭视图会销毁文档、移除请求拦截器并安排相同的清理,因此异常退出遗留的认证会在下一个文档加载前被清除。下次打开和应用退出都会等待该清理完成,更新安装也会在安装器接管退出前等待。清理失败会使该次打开失败,并在后续清理成功前阻止同一账号打开;其他账号不受影响。退出登录会销毁文档,但保留账号偏好供下次登录使用。同一凭证下账号 ID 迟到时,已以一次性分区打开的文档保持挂载;下次打开使用账号分区。[存储决策](../../.agents/notes/implemented/architecture/2026-09-22-platform-browser-storage.zh.md)说明保留策略。Host 通过私有 Node IPC 发送账号凭证;账号 RPC 和 Harness 渲染进程不接收 token。Platform preload 在页面脚本执行前通过一次同步 IPC 读取主进程中已准备的凭证。它暴露 displayMode、同步的 getAuthToken() 和 getLocale() getter,以及返回取消订阅函数的 onLocaleChange(listener)。两个 getter 都只读取 preload 内存,不再调用 IPC。bootstrap 包含 Desktop 已解析的语言(`zh_CN` 或 `en_US`);Settings 语言变更会更新 preload 缓存并通知已打开的 Platform 文档,无需重载。Platform 在首屏渲染前应用该语言,且不将其持久化为浏览器偏好。主进程处理器仅校验调用来源并读取内存,不等待 Host、磁盘或网络。可信页面初始化失败时保留内嵌模式,由 getter 抛错,避免回退到浏览器凭证。只有受控 Platform 页面中、位于所配置签发来源的主 frame 能完成初始化。退登、凭证替换、Host 关闭及视图关闭都会销毁文档。跨来源文档导航被阻止。请求新窗口的 HTTPS 链接在系统浏览器中打开,不携带内嵌会话或 token;其他协议及带 URL 凭证的链接被拒绝。原生视图占据 Account 功能返回栏下方的视口。
Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-client-platform` 映射。账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。
Desktop Host 的 Platform API 请求与更新策略请求用相同的 Platform 客户端请求头标识已安装客户端:平台、客户端版本、语言、以秒为单位的时区偏移,以及有意保持为空的 bundle id。账号操作按调用逐次传入调用界面的身份;账号 provider 管理[仅 API 使用的请求头配置](../../packages/credentials/deepseek-account-platform/README.zh.md#use-this-package)。更新策略额外上报架构、更新通道和内置运行时版本。
账号凭据被服务端判定失效后,未配置官方 API key 时返回 Welcome;有可用 API key 时保持工作区打开。主动退出登录遵循相同规则。Welcome 和工作区均显示本地化的登录失效提示。
@@ -24,10 +24,18 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。
macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。更新蒙层从创建到最后一个蒙层关闭期间阻挡父窗口及其浏览器 guest 的产品快捷键和编辑按键递送。每次打开或关闭蒙层都会作废待完成的组合键状态。主进程通过显式的创建能力和输入状态读取能力,让更新对话框与快捷键输入共享同一个蒙层管理实例。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。
macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。
<a id="bundled-workspace-dependencies"></a>
### 内置工作区依赖
electron-builder 只把清单中的 `dependencies` 复制进 `app.asar/node_modules`,因此 Electron 主进程 bundle `lib/main.js` 内联其工作区 devDependencies,裸导入只剩 `electron`、Node 内置模块与这些 `dependencies`;沙箱 preload 只能留下 `electron`、`events`、`timers` 与 `url`,即其 `require` polyfill 能解析的模块。主进程 bundle 从被内联包的 `lib/` 产物解析它们,所以根 `build:lib:host` 在并发的工作区 tsdown 阶段之后才为 `apps/desktop` 打 bundle,并由 [`desktop-bundle-imports`](scripts/desktop-bundle-imports.mjs) 让任何静态、动态或 `require()` 导入无法在打包应用内解析的 Desktop bundle 直接失败。没有这项检查时,rolldown 无法解析的导入会作为外部说明符进入产物,并在启动时以 `ERR_MODULE_NOT_FOUND` 失败。[bundle 顺序决策](../../.agents/notes/implemented/process/2026-09-22-desktop-main-bundle-after-workspace-tsdown.zh.md)记录了备选方案。
Windows 签名打包按 PE 文件内容扫描第一方运行时和应用生产依赖,包括没有常规扩展名的文件。最终扫描覆盖整个解包应用。目录链接、格式错误的 `MZ` 文件以及非 PE 的 `.exe`、`.dll` 或 `.pyd` 文件会使打包停止;以 `MZ` 开头的数据文件也会被拒绝,除非包含有效 PE 头。它保留有效的上游签名,并在记录运行时哈希或执行冒烟检查前为未签名代码补签。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程;硬件令牌签名仍串行执行,每个新签名必须匹配配置的证书且带时间戳。硬件签名或验签失败会停止本轮执行;独立的时间戳请求遵循下文的有界重试规则。electron-builder 只有在验签和逐字节比对通过后,才保留复制后运行时可执行文件的签名。写入发布完成记录前,必须通过最终 PE 签名检查,以及使用全新缓存的 ASAR 载荷和 Host 冒烟检查。开发、仅准备和未签名构建不使用硬件令牌,可能被 Windows 代码完整性策略阻止;任何构建模式都不会关闭该策略。冒烟检查通过不代表兼容所有企业策略。
Desktop 携带独立的 Python、Node.js 和 pnpm 分发包。Python 包含 numpy、pandas、python-docx、python-pptx、openpyxl、Pillow、lxml、XlsxWriter 及其完整依赖。`load_workspace_dependencies` 工具首次使用时,将该产物离线安装到 `$DSH_HOME/dsh-runtimes/dsh-primary-runtime`(通常为 `~/.dsh/dsh-runtimes/dsh-primary-runtime`),并返回解释器、pnpm 脚本和库目录的绝对路径,以及记录内置分发包名称与版本的 `pythonDistributions`。版本报告不包含用户自行安装的包。Office 任务默认使用这些库,用户或工作区指令指定其他环境时遵循其要求。pnpm 脚本通过返回的 Node 可执行文件运行。返回的 Node 库目录为随包交付的库预留,不是 pnpm 的全局安装目录。
@@ -58,7 +66,7 @@ Welcome 加载共享 Toast 的配色和阴影变量,挂载在 body 下的通
Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
@@ -68,9 +76,9 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
macOS 上自定义菜单保留 Electron 的标准 Window 菜单及应用隐藏命令,包括 Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
### 运行时与插件激活
@@ -92,7 +100,6 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
## 开发
开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
@@ -113,6 +120,7 @@ pnpm run start:desktop
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
### 启动引导
@@ -259,7 +267,7 @@ Mac 打包从 `.env.macos` 读取三个调优字段:
两个代理字段互相独立,拒绝 URL 中的凭证、路径、查询参数和片段。空值沿用继承的网络设置。显式下载代理会替换子进程的代理变量,仅绕过本地主机;它不修改系统设置。Windows 与独立 `release:pack` 保持现有并发默认值。公司代理地址仅写入 Git 忽略的本地文件;具体地址参见内部文档。
Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。
Apple 工具使用 macOS 当前活动网络服务的 HTTP/HTTPS 代理。配置公证代理后,打包会检查代理可达性、保存该服务的设置,在两条产物任务期间启用代理,并在两条任务均结束后恢复原设置。对于原本关闭、服务器为空且端口为零的代理,恢复时仅关闭代理;临时服务器和端口可能保留,但不生效。仅生成目录的打包会在签名目录构建完成后的 App 公证期间启用代理。这会临时影响其他应用,并要求修改系统代理的权限;必须先禁用 PAC、自动发现、SOCKS 及需要认证的代理配置。打包和恢复在读取恢复记录或修改代理前获取同一个用户级 POSIX 文件锁;进程退出会释放锁的持有权,锁文件保留。该锁在首次使用时才加载 `@deepseek-ai/node-addon-system/flock`,而不是在脚本启动时加载,因此 `check:package` 和打包入口在未构建 `native/system` 的 checkout 上也能加载;加锁时若宿主 addon 二进制或入口的 JavaScript 缺失,加载器会先运行 `pnpm run build:native-system` 和 `pnpm --dir native/system run build:ts` 再加锁,因此恢复命令在这样的 checkout 上同样可用。这会阻止不同 checkout 的代理事务重叠;其他用户及网络设置工具不得同时修改这些设置。SIGINT/SIGTERM 会等待活动任务结束后恢复。强制终止或恢复失败后,先停止残留公证进程,再运行 `pnpm --dir apps/desktop run restore:mac-proxy`;保存的记录会保留到恢复成功。配置检查仅验证 URL 语法,不修改系统设置或连接代理。
### 未签名 Windows 测试安装包
@@ -348,6 +356,8 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
## 更新
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放背景模糊、输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
@@ -375,13 +385,13 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
| `maxBackoffMs` | 含抖动的失败请求最大间隔;默认 `3600000`,不小于 `intervalMs` |
| `jitter` | 随机增加的间隔比例;默认 `0.2`,范围为 `0` 至 `1` |
时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、完整壳与内置 dsh 版本、应用 ID、语言和固定 Nightly。不使用业务登录凭据或安装 ID。
时长必须是 1000 至 2147483647 毫秒的整数。启动与定时轮询独立于业务请求;前台/恢复检查遵守下次到期时间,手动检查绕过该时间并复用在途请求。客户端发送已安装平台、架构、DSH_CLIENT_VERSION、内置 dsh 版本、当前语言与 UTC 偏移、空 bundle ID 和固定 Nightly。不使用业务登录凭据或安装 ID。
启用 `feishu-test` 时,包含 `error.code: "UNAUTHENTICATED"` 的 HTTP 401 JSON 响应会在用户主动检查和打包应用首次启动检查时提供登录入口,不等待本地后端就绪。本地化说明指出这是测试版、需要飞书鉴权,且登录不会下载或安装更新。确认后先关闭说明,再打开配置源站根路径的沙箱窗口,不使用响应中的登录 URL。并发检查复用整个确认/登录流程,并聚焦已有窗口。在测试环境登录窗口按 F12 可打开独立的 DevTools 进行排查。取消后,定时或前台检查不会反复弹窗;用户可手动重试。
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
@@ -414,6 +424,8 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd
- 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。
- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。
仅向应用提供的 `dshOnboarding.hasApiKey()` preload 方法返回欢迎后端当前的 API Key 存在状态布尔值;原生登录与引导共用凭证发现逻辑,且只有受管理的应用主 frame 可以调用。
登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。浏览器登录成功后,Welcome 切换到工作区但不激活应用;完成页的 dsh://open 链接负责将客户端置前。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。
账号失效并返回 Welcome 时,主进程保留一次性通知,直到渲染器通过所属窗口的 IPC 领取。重新加载 Welcome 不会重复提示;主动退登和冷启动不会生成该通知。
+5 -2
View File
@@ -8,7 +8,8 @@
"main": "lib/main.js",
"scripts": {
"test:installer": "node scripts/test-windows-installer.mjs",
"build": "tsc -b && tsdown",
"build": "tsc -b && pnpm run bundle",
"bundle": "tsdown",
"dev": "tsx scripts/dev.ts",
"start": "tsx scripts/dev.ts --skip-build",
"test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs",
@@ -70,6 +71,8 @@
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"vite": "^6.0.0",
"@deepseek-ai/dsh-client-ui-theme": "workspace:*"
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
"@deepseek-ai/dsh-atomic-write": "workspace:*"
}
}
@@ -0,0 +1,46 @@
import type { Rolldown } from 'tsdown'
/** Bare specifiers one Desktop bundle may leave for the runtime to resolve. */
export interface BundleImportPolicy {
/** Package names present at runtime, matched by package name so subpaths pass. */
readonly packages: ReadonlySet<string>
/**
* Whether Node builtins resolve at runtime: true for the Electron main
* process; false for sandboxed preloads, whose `require` polyfill offers only
* the modules listed in `packages`.
*/
readonly nodeBuiltins: boolean
}
/**
* Package name of a bare import specifier.
* @param specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`.
* @returns The package name without its subpath.
*/
export function importPackageName(specifier: string): string
/**
* Whether a module id rolldown reports is a bare specifier, i.e. an import the
* bundle leaves external. Bundled modules carry absolute ids; virtual modules
* start with `\0`.
* @param id - Module id from `importedIds` or `dynamicallyImportedIds`.
* @returns True for a bare specifier.
*/
export function isBareSpecifier(id: string): boolean
/**
* Bare imports the packaged application cannot resolve.
* @param imports - External specifiers a module imports statically, dynamically, or through `require()`.
* @param policy - What this bundle may leave external.
* @returns Offending specifiers in import order, each once.
*/
export function unpackagedImports(imports: readonly string[], policy: BundleImportPolicy): string[]
/**
* Rolldown plugin that fails a bundle whose external imports the packaged
* application cannot resolve. The failure names the importing module and every
* offending specifier, and no output is written.
* @param policy - What this bundle may leave external.
* @returns The plugin.
*/
export function packagedImportsPlugin(policy: BundleImportPolicy): Rolldown.Plugin
@@ -0,0 +1,90 @@
/**
* Keep every bare import a Desktop bundle leaves to the runtime resolvable
* inside the packaged application.
*
* electron-builder copies only the manifest's `dependencies` into
* `app.asar/node_modules`, so a bare import of anything else bundles without
* complaint and fails at launch with `ERR_MODULE_NOT_FOUND`. The main-process
* bundle inlines its workspace devDependencies; when one of their `lib/`
* outputs is missing at bundle time, rolldown reports `UNRESOLVED_IMPORT` as a
* warning and keeps the specifier as an external import, which is exactly the
* broken artifact. This check fails the bundle instead of the installed
* application.
*
* The check runs in `moduleParsed`, where rolldown reports every import record
* of a module: static imports, dynamic `import()`, and `require()` calls. A
* bundled import resolves to an absolute module id; an external one keeps its
* bare specifier. Chunk metadata (`imports`/`dynamicImports`) omits external
* `import()` and `require()` targets, so it cannot serve this check.
*/
import { isBuiltin } from 'node:module'
import { isAbsolute, relative } from 'node:path'
/**
* Bare specifiers one Desktop bundle may leave for the runtime to resolve.
* @typedef {object} BundleImportPolicy
* @property {ReadonlySet<string>} packages - Package names present at runtime, matched by package name so subpaths pass.
* @property {boolean} nodeBuiltins - Whether Node builtins resolve at runtime (true for the Electron main process; false for sandboxed preloads, whose `require` polyfill offers only the modules listed in `packages`).
*/
/**
* Package name of a bare import specifier.
* @param {string} specifier - Bare specifier as rolldown reports it, such as `@scope/name/subpath`.
* @returns {string} The package name without its subpath.
*/
export function importPackageName(specifier) {
const segments = specifier.split('/')
return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]
}
/**
* Whether a module id rolldown reports is a bare specifier, i.e. an import the
* bundle leaves external. Bundled modules carry absolute ids; virtual modules
* start with `\0`.
* @param {string} id - Module id from `importedIds` or `dynamicallyImportedIds`.
* @returns {boolean} True for a bare specifier.
*/
export function isBareSpecifier(id) {
return !id.startsWith('.') && !id.startsWith('\0') && !isAbsolute(id)
}
/**
* Bare imports the packaged application cannot resolve.
* @param {readonly string[]} imports - External specifiers a module imports statically, dynamically, or through `require()`.
* @param {BundleImportPolicy} policy - What this bundle may leave external.
* @returns {string[]} Offending specifiers in import order, each once.
*/
export function unpackagedImports(imports, policy) {
const offending = new Set()
for (const specifier of imports) {
if (policy.nodeBuiltins && isBuiltin(specifier)) continue
if (policy.packages.has(importPackageName(specifier))) continue
offending.add(specifier)
}
return [...offending]
}
/**
* Rolldown plugin that fails a bundle whose external imports the packaged
* application cannot resolve. The failure names the importing module and every
* offending specifier, and no output is written.
* @param {BundleImportPolicy} policy - What this bundle may leave external.
* @returns {import('tsdown').Rolldown.Plugin} The plugin.
*/
export function packagedImportsPlugin(policy) {
const allowed = [...policy.packages].join(', ') + (policy.nodeBuiltins ? ', and Node builtins' : '')
return {
name: 'desktop-packaged-imports',
moduleParsed(info) {
const external = [...info.importedIds, ...info.dynamicallyImportedIds].filter(isBareSpecifier)
const offending = unpackagedImports(external, policy)
if (offending.length === 0) return
this.error(
`desktop bundle: ${relative(process.cwd(), info.id)} imports ${offending.join(', ')}, which the packaged application does not ship. `
+ `This bundle may leave only ${allowed} as bare imports; anything else must be bundled, `
+ 'which requires its lib/ output to exist before this bundle runs (pnpm run build:lib:host).',
)
},
}
}
+2 -1
View File
@@ -64,7 +64,7 @@ async function launchElectron(): Promise<void> {
const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222)
const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230)
const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home'))
const userData = join(DEVELOPMENT_ROOT, 'electron-user-data')
const userData = resolve(process.env.DSH_DESKTOP_USER_DATA_DIR ?? join(DEVELOPMENT_ROOT, 'electron-user-data'))
const environment: NodeJS.ProcessEnv = {
...process.env,
DSH_HOME: home,
@@ -74,6 +74,7 @@ async function launchElectron(): Promise<void> {
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1',
}
console.log(`desktop development: DSH_HOME=${home}`)
console.log(`desktop development: userData=${userData}`)
console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`)
if (process.platform === 'darwin') {
const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData,
+94
View File
@@ -0,0 +1,94 @@
/** Load the POSIX flock entry on first use so packaging entry scripts start on a checkout that has not built `native/system`. */
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
type FlockEntry = typeof import('@deepseek-ai/node-addon-system/flock')
const REPOSITORY_ROOT = resolve(import.meta.dirname, '..', '..', '..')
/** Root package script that compiles the host addon, then the `native/system` script that emits the entry's JavaScript. */
const BUILD_COMMANDS: readonly (readonly string[])[] = [
['run', 'build:native-system'],
['--dir', 'native/system', 'run', 'build:ts'],
]
const BUILD_COMMAND_TEXT = BUILD_COMMANDS.map(args => `pnpm ${args.join(' ')}`).join(' && ')
/** Loader steps; tests replace the module import, the addon probe, and the build. */
export interface FlockEntryLoaderSteps {
/** Import `@deepseek-ai/node-addon-system/flock`; rejects with `ERR_MODULE_NOT_FOUND` while its `lib/` is unbuilt. */
readonly importEntry: () => Promise<FlockEntry>
/** Whether every binary the host platform package declares in `prebuilds.json` exists. */
readonly hostAddonBuilt: () => boolean
/** Compile the host addon and emit the entry's JavaScript; rejects when a build fails. */
readonly build: () => Promise<void>
}
function isModuleNotFound(error: unknown): boolean {
return (error as NodeJS.ErrnoException | undefined)?.code === 'ERR_MODULE_NOT_FOUND'
}
/**
* Create a memoized loader that imports the flock entry, building `native/system` once when its entry or host addon is missing.
* @param steps - Module import, addon probe, and build used by the loader.
* @returns Loader that resolves the same entry on every call; a failed load is retried by the next call.
*/
export function createFlockEntryLoader(steps: FlockEntryLoaderSteps): () => Promise<FlockEntry> {
let loading: Promise<FlockEntry> | undefined
const load = async (): Promise<FlockEntry> => {
let entry: FlockEntry | undefined
try {
entry = await steps.importEntry()
} catch (error) {
if (!isModuleNotFound(error)) throw error
}
if (entry !== undefined && steps.hostAddonBuilt()) return entry
process.stderr.write(`desktop package: flock addon is not built; running ${BUILD_COMMAND_TEXT}\n`)
await steps.build()
return steps.importEntry()
}
return () => {
loading ??= load().catch((error: unknown) => { loading = undefined; throw error })
return loading
}
}
function hostAddonBuilt(): boolean {
const { platform, arch } = process
// flock itself rejects other platforms; the probe only reports missing builds for supported ones.
if (platform !== 'darwin' && platform !== 'linux') return true
const entryManifest = createRequire(import.meta.url).resolve('@deepseek-ai/node-addon-system/package.json')
let prebuilds: string
try {
prebuilds = createRequire(entryManifest).resolve(`@deepseek-ai/node-addon-system-${platform}-${arch}/prebuilds.json`)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') throw error
return false
}
const manifest = JSON.parse(readFileSync(prebuilds, 'utf8')) as { readonly binaries: readonly { readonly path: string }[] }
return manifest.binaries.every(binary => existsSync(join(dirname(prebuilds), binary.path)))
}
async function buildNativeSystem(): Promise<void> {
const pnpmEntry = process.env.npm_execpath
if (pnpmEntry === undefined || pnpmEntry === '') {
throw new Error(`desktop package: flock addon is not built; run ${BUILD_COMMAND_TEXT} or invoke this script through a pnpm package command`)
}
for (const args of BUILD_COMMANDS) {
const child = spawn(process.execPath, [pnpmEntry, ...args], { cwd: REPOSITORY_ROOT, stdio: 'inherit' })
const [code, signal] = await once(child, 'close') as [number | null, NodeJS.Signals | null]
if (code !== 0) throw new Error(`desktop package: pnpm ${args.join(' ')} exited with ${String(code ?? signal)}`)
}
}
/**
* Load `@deepseek-ai/node-addon-system/flock`, building the host addon and the entry's JavaScript through pnpm when either is missing.
* @returns The flock entry; rejects when a build fails or the script did not run under pnpm.
*/
export const loadFlockEntry = createFlockEntryLoader({
importEntry: () => import('@deepseek-ai/node-addon-system/flock'),
hostAddonBuilt,
build: buildNativeSystem,
})
@@ -5,7 +5,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } f
import { connect } from 'node:net'
import { homedir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { tryLockExclusive } from '@deepseek-ai/node-addon-system/flock'
import { loadFlockEntry } from './flock-entry.ts'
interface ProxyState {
readonly enabled: boolean
@@ -50,6 +50,7 @@ const operations: ProxyOperations = {
}
async function withProxyLock<T>(lock: string, action: () => Promise<T>): Promise<T> {
const { tryLockExclusive } = await loadFlockEntry()
mkdirSync(dirname(lock), { recursive: true, mode: 0o700 })
// Keep this inode across transactions: unlinking it would allow two independent locks.
const fd = openSync(`${lock}.flock`, 'a', 0o600)
+8 -7
View File
@@ -2,7 +2,7 @@
import { randomUUID } from 'node:crypto'
import WebSocket from 'ws'
import { parseRemoteStreamServerMessage, REMOTE_STREAM_MUX_PATH } from '@deepseek-ai/dsh-api-gateway/stream-protocol'
import type { AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
import type { AccountClientMetadata, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
/** Authenticated unary caller shared with native onboarding. */
export type AccountInvoke = (request: { namespace: string; method: string; args: Record<string, unknown> }) => Promise<unknown>
@@ -57,12 +57,12 @@ function validateBrowserDestination(value: string): void {
export interface DesktopAccountBackend {
/** @returns current account snapshot. */
state(): Promise<AccountView>
/** @param locale - active desktop UI language. @returns new or already-running login attempt. */
start(locale: string): Promise<AccountView>
/** @param client - this window's identity and current language. @returns new or already-running login attempt. */
start(client: AccountClientMetadata): Promise<AccountView>
/** @param id - attempt to cancel. @returns cancellation or completed commit state. */
cancel(id: SignInAttemptId): Promise<AccountView>
/** @returns state after local sign-out. */
signOut(): Promise<AccountView>
/** @param client - this window's identity. @returns state after local sign-out. */
signOut(client: AccountClientMetadata): Promise<AccountView>
/**
* @param listener - state recipient.
* @param failed - stream failure recipient.
@@ -83,8 +83,9 @@ export function desktopAccountBackend(origin: string, invoke: AccountInvoke, coo
const call = async (method: string, args: Record<string, unknown> = {}): Promise<AccountView> =>
accountView(await invoke({ namespace: 'account', method, args }))
return {
state: () => call('getState'), start: locale => call('startSignIn', { locale, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: () => call('signOut'),
state: () => call('getState'),
start: client => call('startSignIn', { client, callbackOrigin: new URL(origin).origin, loginSource: 'desktop' }),
cancel: attemptId => call('cancelSignIn', { attemptId }), signOut: client => call('signOut', { client }),
watch(listener, failed, expired) {
let closed = false
let socket: WebSocket | undefined
+6 -2
View File
@@ -9,6 +9,7 @@ interface GuestLease {
readonly partition: string
attached: boolean
guest?: WebContents
releaseInput?: () => void
}
/** Owns workspace storage partitions independently from individual tab guests. */
@@ -51,6 +52,7 @@ export class DesktopBrowserGuests {
const lease = this.leases.get(key)
if (lease === undefined) return
if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window')
lease.releaseInput?.()
this.leases.delete(key)
const guest = lease.guest
if (guest !== undefined && !guest.isDestroyed()) {
@@ -63,8 +65,9 @@ export class DesktopBrowserGuests {
/**
* Install attachment checks before the application document can create a webview.
* @param window - primary application window.
* @param attachInput - attaches native input after guest ownership is verified and returns its disposer.
*/
bind(window: BrowserWindow): void {
bind(window: BrowserWindow, attachInput: (guest: WebContents, name: DesktopBrowserLeaseId) => () => void): void {
const owner = window.webContents
owner.on('will-attach-webview', (event, preferences, params) => {
const id = typeof params.src === 'string' && params.src.startsWith('about:blank#')
@@ -102,7 +105,8 @@ export class DesktopBrowserGuests {
}
lease.guest = guest
attachedLease = id
guest.once('destroyed', () => { this.leases.delete(id) })
lease.releaseInput = attachInput(guest, id)
guest.once('destroyed', () => { lease.releaseInput?.(); this.leases.delete(id) })
})
guest.setWindowOpenHandler(({ url, postBody }) => {
const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease)
+29
View File
@@ -0,0 +1,29 @@
/** Desktop client identity for one Platform account call. */
import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types'
/**
* Read the client build version inlined by the Desktop build.
* @returns the version embedded in this application build.
* @throws Error when the build carries no client version, instead of reporting a guessed one.
*/
export function desktopClientVersion(): string {
const version = process.env.DSH_CLIENT_VERSION
if (version === undefined || version === '') {
throw new Error('desktop account: this application build carries no DSH_CLIENT_VERSION')
}
return version
}
/**
* Sample the Desktop client identity for one Platform request.
* @param locale - current resolved Desktop language.
* @returns this call's build version, the raw active language, and the UTC offset in whole seconds east.
*/
export function desktopClientMetadata(locale: string): AccountClientMetadata {
return {
version: desktopClientVersion(),
locale,
// Date.getTimezoneOffset reports minutes west of UTC; Platform wants seconds east.
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
}
}
+2
View File
@@ -45,6 +45,8 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
if (session === null) return true
if (typeof session !== 'object' || !('origin' in session) || !('token' in session)
|| typeof session.origin !== 'string' || typeof session.token !== 'string' || session.token.length === 0) return false
if (!('userId' in session) || (session.userId !== null
&& (typeof session.userId !== 'string' || session.userId.length === 0))) return false
if ('embeddedPageDist' in session && typeof session.embeddedPageDist !== 'string') return false
if ('requestHeaders' in session && (typeof session.requestHeaders !== 'object' || session.requestHeaders === null
|| Array.isArray(session.requestHeaders)
+11
View File
@@ -1,12 +1,21 @@
/** Typed preload operations exposed only by the Electron shell. */
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { IpcMainInvokeEvent } from 'electron'
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
/** IPC channel names kept private to the desktop application bundle. */
export const DESKTOP_IPC = {
shortcutsInput: 'dsh-desktop:shortcuts-input',
shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window',
shortcutsGet: 'dsh-desktop:shortcuts-get',
shortcutsEdit: 'dsh-desktop:shortcuts-edit',
shortcutsChanged: 'dsh-desktop:shortcuts-changed',
shortcutsRecording: 'dsh-desktop:shortcuts-recording',
boot: 'dsh-desktop:boot',
enterWorkspace: 'dsh-desktop:enter-workspace',
onboardingActive: 'dsh-desktop:onboarding-active',
onboardingApiKey: 'dsh-desktop:onboarding-api-key',
bootFailed: 'dsh-desktop:boot-failed',
browserAcquire: 'dsh-desktop:browser-acquire',
browserRelease: 'dsh-desktop:browser-release',
@@ -62,6 +71,8 @@ export interface DesktopUpdatePresentation {
export interface DshDesktopProductApi {
readonly protocolVersion: 1
readonly browser: DesktopBrowserBridge
readonly keyboard: DesktopKeyboardApi
readonly shortcuts: DesktopShortcutsApi
readonly updates: {
status(): Promise<DesktopUpdatePresentation>
open(): Promise<void>
+27
View File
@@ -0,0 +1,27 @@
/** Device-local shortcut preferences in Electron userData; writes share one serialized coordinator. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { ShortcutPersistence } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { ShortcutConfigSnapshot, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/protocol'
/**
* Open the device configuration with atomic replacement.
* @param userData - Electron-owned userData directory, never a Renderer-supplied path.
* @param platform - local input platform.
* @param publish - updates the native binding index and trusted product page after commit.
* @returns the single-writer transaction coordinator.
*/
export function desktopKeybindings(userData: string, platform: ShortcutPlatform,
publish: (snapshot: ShortcutConfigSnapshot) => void): ShortcutPersistence {
const path = join(userData, 'keybindings.json')
return new ShortcutPersistence({
read: async () => {
try { return await readFile(path, 'utf8') } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
throw error
}
},
write: raw => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }),
}, 'desktop', platform, false, publish)
}
+261
View File
@@ -0,0 +1,261 @@
/** Product-window preference IPC and native menu interception during physical-key dispatch/recording. */
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions, type WebContents } from 'electron'
import { bindingKey, effectiveShortcuts, presentBinding, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { NormalizedBinding, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
import { desktopKeybindings } from './keybindings.ts'
import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
/**
* Install application-owned configuration handlers and attach each product window's input lifecycle.
* @param getWindow - current product window.
* @param userData - Electron-resolved device preference directory.
* @param platform - local device platform.
* @param updateMenu - rebuild the application menu when the close accelerator or availability changes.
* @param overlayInput - Current shell-owned input blocking state for the product window.
* @returns menu construction, editor key delivery, window attachment, and teardown operations.
*/
export function installDesktopShortcuts(
getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void,
overlayInput: (window: BrowserWindow) => { readonly revision: number; readonly blocked: boolean },
): {
fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions
/**
* Send a native Edit action to the editor without matching user shortcuts.
* @param keyCode - edit key.
* @param modifiers - edit modifiers.
*/
sendEditingKey(keyCode: string, modifiers: Array<'control'>): void
attach(window: BrowserWindow): void
/**
* Intercept an approved browser guest's keys until its lease ends.
* @param window - owning product window.
* @param guest - approved browser contents.
* @param name - main-issued lease used as the webview element name.
* @returns idempotent listener disposer.
*/
attachGuest(window: BrowserWindow, guest: WebContents, name: DesktopBrowserLeaseId): () => void
dispose(): void
} {
let definitions: readonly ShortcutDefinition[] = []
let recording = false
let revision: ShortcutRevision | undefined
let closeBinding: NormalizedBinding | null = null
let editingInput: BrowserWindow['webContents'] | undefined
const scopedDesktop = platform === 'windows' || platform === 'macos'
const disposers = new Set<() => void>()
const guestInputs = new Map<WebContents, { window: BrowserWindow; reset(): void }>()
const closeAccelerator = (): string | undefined => presentBinding(closeBinding, platform).aria
?.replace('Meta+', 'Command+').replace(/Arrow(Up|Down|Left|Right)$/u, '$1')
const sendMenuClose = (): void => {
const window = getWindow()
if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled()
|| revision === undefined || recording || overlayInput(window).blocked) return
window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision })
}
let keys = new Set<string>()
const publish = (snapshot: ShortcutConfigSnapshot): void => {
const wasEnabled = revision !== undefined
const previousAccelerator = closeAccelerator()
revision = definitions.length === 0 || snapshot.status === 'loading' ? undefined : snapshot.revision
const rows = snapshot.status === 'loading' ? [] : effectiveShortcuts(definitions, snapshot.document, 'desktop', platform)
keys = new Set(rows.flatMap(row => row.binding !== null && row.issue === null && row.conflicts.length === 0
? [bindingKey(row.binding)] : []))
const close = rows.find(row => row.id === 'page.close')
closeBinding = close?.issue === null && close.conflicts.length === 0 ? close.binding : null
if (wasEnabled !== (revision !== undefined) || previousAccelerator !== closeAccelerator()) updateMenu()
const window = getWindow()
if (window !== undefined && !window.isDestroyed() && window.webContents.mainFrame.url.startsWith('dsh-app://app/')) {
window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot)
}
}
const persistence = desktopKeybindings(userData, platform, publish)
const assertSender = (event: IpcMainInvokeEvent): BrowserWindow => {
const window = getWindow()
if (window === undefined || window.isDestroyed() || event.sender !== window.webContents
|| event.senderFrame !== window.webContents.mainFrame) throw new Error('desktop shortcuts: rejected sender')
assertDesktopSender(event, ['app'])
return window
}
ipcMain.handle(DESKTOP_IPC.shortcutsGet, async (event, input: unknown) => {
assertSender(event)
definitions = parseShortcutDefinitions(input)
persistence.setDefinitions(definitions)
return persistence.readCurrent()
})
ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, expectedRevision: unknown) => {
assertSender(event)
if (typeof expectedRevision !== 'string') throw new Error('desktop shortcuts: invalid revision')
return persistence.edit(parseShortcutEdit(input), expectedRevision as ShortcutRevision)
})
ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => {
const window = assertSender(event)
if (typeof active !== 'boolean') throw new Error('desktop shortcuts: invalid recording state')
recording = active
window.webContents.setIgnoreMenuShortcuts(active)
})
ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => {
const window = assertSender(event)
if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()
|| overlayInput(window).blocked) return
window.close()
})
function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void {
let deadKey = false
const held = new Set<string>()
const consumed = new Map<string, 'press' | 'repeat'>()
let inputFrame: typeof contents.focusedFrame = null
let inputRevision: ShortcutRevision | undefined
let overlayRevision = overlayInput(window).revision
const resetInput = (): void => {
deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
}
if (guestName !== undefined) guestInputs.set(contents, { window, reset: resetInput })
const resetWindow = (): void => {
resetInput()
for (const guest of guestInputs.values()) if (guest.window === window) guest.reset()
}
const clear = (): void => {
resetInput()
if (guestName !== undefined) return
definitions = []; keys.clear(); recording = false
persistence.setDefinitions(null)
}
const navigation = (event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>): void => {
if (event.isMainFrame && !event.isSameDocument) clear()
}
const beforeInput = (event: Electron.Event, input: Input): void => {
const overlay = overlayInput(window)
if (overlay.revision !== overlayRevision) { resetInput(); overlayRevision = overlay.revision }
if (overlay.blocked) { event.preventDefault(); return }
if (event.defaultPrevented) { resetInput(); return }
if (editingInput === contents) {
held.clear()
consumed.clear()
contents.setIgnoreMenuShortcuts(true)
return
}
if (window !== getWindow() || !window.isFocused() || !window.isEnabled() || revision === undefined
|| (guestName !== undefined && !contents.isFocused())) {
contents.setIgnoreMenuShortcuts(false)
held.clear()
consumed.clear()
return
}
const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier])
const key = bindingKey({ code: input.code, modifiers })
const match = keys.has(key)
const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+')
&& input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '')
contents.setIgnoreMenuShortcuts(recording || match || menuMatch)
const frame = contents.focusedFrame
const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr')
if (input.type === 'keyDown') deadKey = input.key === 'Dead'
if (recording || composing || frame === null) { held.clear(); consumed.clear(); return }
let binding: NormalizedBinding = { code: input.code, modifiers }
let priority = false
if (scopedDesktop) {
if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision }
const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code)
if (input.type === 'keyUp') {
// A chord's first key reached the renderer, so its release must reach the same input handlers.
if (consumed.get(input.code) === 'press') event.preventDefault()
consumed.delete(input.code)
held.delete(input.code)
if (modifierKey) held.clear()
return
}
if (!input.isAutoRepeat) consumed.delete(input.code)
if (modifierKey) { held.clear(); return }
if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return }
if (input.isAutoRepeat && !held.has(input.code) && !match) return
held.add(input.code)
const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)]
codes.sort()
const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers }
priority = keys.has(key)
if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true }
}
const main = guestName === undefined && frame === contents.mainFrame
if (!priority && (main || !match)) return
event.preventDefault()
if (input.type !== 'keyDown') return
if (scopedDesktop) {
if (!input.isAutoRepeat) {
if (binding.secondCode !== undefined) {
consumed.set(binding.code, 'repeat')
consumed.set(binding.secondCode, 'repeat')
}
consumed.set(input.code, 'press')
}
// Electron can omit both keyups after interception; completed presses cannot seed another chord.
held.clear()
}
let embedding = frame
while (guestName === undefined && !main && embedding.parent !== null && embedding.parent !== contents.mainFrame) {
embedding = embedding.parent
}
window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: guestName === undefined ? main ? 'keyboard' : 'iframe' : 'webview',
revision, frameName: guestName ?? (main ? '' : embedding.name),
code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }),
repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta })
}
const dispose = (): void => {
contents.off('did-start-navigation', navigation)
contents.off('before-input-event', beforeInput)
contents.off('blur', resetInput)
contents.off('destroyed', dispose)
if (guestName === undefined) { window.off('blur', resetWindow); window.off('closed', closed) }
disposers.delete(dispose)
guestInputs.delete(contents)
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
}
const closed = (): void => { clear(); dispose() }
contents.on('did-start-navigation', navigation)
contents.on('before-input-event', beforeInput)
contents.on('blur', resetInput)
contents.once('destroyed', dispose)
if (guestName === undefined) { window.on('closed', closed); window.on('blur', resetWindow) }
disposers.add(dispose)
return dispose
}
return {
sendEditingKey(keyCode, modifiers) {
const window = getWindow()
if (window === undefined || window.isDestroyed() || overlayInput(window).blocked) return
const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0]
?? window.webContents
contents.focus()
const previous = editingInput
editingInput = contents
try {
// Electron emits before-input-event synchronously for these editor-owned keys.
contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
} finally {
editingInput = previous
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(recording)
}
},
fileMenu: (labels) => {
const accelerator = closeAccelerator()
return { label: labels.fileMenu, submenu: [{
id: 'dsh-page-close', label: labels.closePage, enabled: revision !== undefined,
...accelerator === undefined ? {} : { accelerator },
click: sendMenuClose,
}] }
},
attach(window) { attachInput(window, window.webContents) },
attachGuest: attachInput,
dispose() {
for (const dispose of disposers) dispose()
persistence.dispose()
for (const channel of [DESKTOP_IPC.shortcutsGet,
DESKTOP_IPC.shortcutsEdit, DESKTOP_IPC.shortcutsRecording, DESKTOP_IPC.shortcutsCloseWindow]) {
ipcMain.removeHandler(channel)
}
},
}
}
+4
View File
@@ -2,6 +2,8 @@
export const en = {
application: 'Application',
fileMenu: 'File',
closePage: 'Close Page or Window',
aboutMenu: 'About DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: 'Version V{version}',
@@ -133,6 +135,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string }
export const zh = {
application: '应用',
fileMenu: '文件',
closePage: '关闭页面或窗口',
aboutMenu: '关于 DeepSeek Harness',
aboutProduct: 'DeepSeek Harness',
aboutVersion: '版本 V{version}',
Loaded 100 of 1007 files, more files were not shown because too many files have changed in this diff. Show more