diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml index 8c89aed087..8a8f6629f8 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md -2026-09-16-plugin-configuration-on-the-plugins-page.md: 475580af48257f8c9f1d080617de1e672b74b028 -2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 22d0aa4ff1e4d027ebfc3e019047c7d5e94497d7 +2026-09-16-plugin-configuration-on-the-plugins-page.md: 700ada15385d0ff95e2f7f1cc58545a28677a507 +2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 23935312d2b09a524a1571f81c18ad8b5ffafc3f diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md index 475580af48..700ada1538 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md @@ -18,14 +18,14 @@ The page passes `view: 'page'` for each form. Official plugin cards and details **Only a save writes.** The form has no discard control or unsaved marker; leaving a page drops its staged edits on unmount. A save writes through the client settings scope with the revision fence. -**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired. +**The four host-plane pages register while the Host serves their namespaces.** Each page's package registers it through `ctx.slots.inject` when the shared settings mirror shows the namespace and disposes it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it; since [settings pages as companion packages](2026-09-17-settings-pages-as-companion-packages.md) that is one companion package per page, and `ui-settings-plugins` keeps the Settings section as the **Built-in plugins** shell around the inventory tab. The `settings.plugin.item` slot is retired. **The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency. ## Consequences - A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own. -- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section while its configuration pages live on the Plugins page. +- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section, and the configuration pages live on the Plugins page. - Settings lists the inventory only; the Settings browser goldens cover that read-only section. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md index 22d0aa4ff1..23935312d2 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.zh.md @@ -18,14 +18,14 @@ Status: implemented **只有保存才写入。** 表单没有放弃控件或未保存标记;离开页面时在卸载过程中丢弃暂存修改。保存经由客户端 settings scope,带 revision 栅栏。 -**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。 +**四个宿主平面页面在 Host 服务其命名空间期间注册。** 每个页面所在的包在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹;自[设置页作为伴生包](2026-09-17-settings-pages-as-companion-packages.zh.md)起,每个页面各是一个伴生包,`ui-settings-plugins` 把设置分区保留为清单标签页外面的**内置插件**外壳。`settings.plugin.item` slot 退役。 **官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。 ## 后果 - 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。 -- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,其配置页则位于插件页。 +- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,配置页则位于插件页。 - 设置只列出清单;Settings 浏览器 golden 覆盖该只读分区。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml new file mode 100644 index 0000000000..5bd9f6000c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md +2026-09-17-plugin-detail-page-extension-slots.md: d4bbca0c4466fbb78a8c56cfc4e327100385368f +2026-09-17-plugin-detail-page-extension-slots.zh.md: f07efc3d3cadded04a7551867680cc058d532943 diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md new file mode 100644 index 0000000000..d4bbca0c44 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md @@ -0,0 +1,35 @@ +# Agent Note: Plugin detail pages as an extension point + +Status: implemented + +English | [中文](2026-09-17-plugin-detail-page-extension-slots.zh.md) + +## Problem + +The Plugins page's detail pages — a bundle's page, a row's page, an official plugin's page — carried only what the page drew itself and the object's own configuration ([plugin configuration on the Plugins page](2026-09-16-plugin-configuration-on-the-plugins-page.md)). A plugin with something to say about a bundle it does not own — a diagnostics plugin's health check, a market plugin's update badge, a documentation plugin's README section — had no place on that bundle's page, and importing the page to extend it is forbidden by the client bundle purity rule. + +## Decision + +**The page declares three list slots keyed by region, not by object.** `plugins.detail.actions` renders at the head of a page before the page's own switch and uninstall; `plugins.detail.badge` beside the title after the version, beta, and problem tags; `plugins.detail.section` under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Each entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`. An entry decides from the subject whether to render and returns null otherwise; the page orders entries by `order`. + +**The subject is a projection, not the store.** `PluginPackageRef` and `PluginRowRef` carry the name, version, installed and enabled facts, and the row list — the facts a contribution decides on — and nothing of the page's own state, so the page's store stays private and the contract stays small. + +**A row's page still exists only for a configured row.** A list entry cannot say in advance which rows it has something for, and a row page with nothing on it shows nothing worth opening, so the configure control keeps its rule: a row is opened when a `plugins.row.config` entry names it, and contributions for the row render on that page. + +## Alternatives considered + +**One slot per object and region (`plugins.bundle.section`, `plugins.row.section`, ...).** Rejected: nine slots for three regions, and a contribution that spans objects would register three times. The subject discriminator carries the same information in three slots. + +**Passing the page's `PackageView` and `PackageRow` as the subject.** Rejected: those types carry the Host's read-only reasons, errors, entry ids, and fiber phases, which are the page's business; exposing them would make store internals a public contract. + +**Every row gets a page so row contributions always have a home.** Deferred: a row without configuration has nothing a page would show beyond its module and state; the rule can widen when a contribution needs it. + +## Consequences + +- A contribution is a slot registration with `import type` of `ui-plugin-manager` for the declarations; the page never names a contributing plugin. +- The e2e fixture bundle (`@fixture/live-client`) contributes to its own bundle and row pages through the three slots; its browser half registers them with the rest, and they leave with it. +- The slot catalog and the slots subsystem hierarchy list the three slots under `main`. + +## Testing + +`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` renders contributions on a bundle's page, a row's page, and an official plugin's page with the subject each is about, and pins the projection; `browser-plugin.client.spec.tsx` pins the declarations. `apps/web/tests/plugin-config.e2e.ts` switches the fixture bundle on and asserts its action, badge, and section on the bundle's page and the row's page, their absence on an official plugin's page, and holds the bundle page's golden. diff --git a/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md new file mode 100644 index 0000000000..f07efc3d3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.zh.md @@ -0,0 +1,35 @@ +# Agent Note:插件详情页作为扩展点 + +状态:已实现 + +[English](2026-09-17-plugin-detail-page-extension-slots.md) | 中文 + +## 问题 + +插件页的详情页——组合包页、行页、官方插件页——只承载页面自绘的内容和对象自己的配置([插件配置放在插件页](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md))。对不属于自己的组合包有话要说的插件——诊断插件的健康检查、市场插件的更新标签、文档插件的 README 区块——在那个组合包的页面上无处安放,而通过 import 页面来扩展它被客户端包纯净性规则禁止。 + +## 决策 + +**页面按区域而不是按对象声明三个 list slot。** `plugins.detail.actions` 渲染在页头,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下——组合包页在组件列表之后,行页与官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`。条目根据 subject 决定是否渲染,否则返回 null;页面按 `order` 排列条目。 + +**subject 是投影,不是 store。** `PluginPackageRef` 与 `PluginRowRef` 携带包名、版本、是否已安装、是否启用以及行列表——贡献者据以判断的事实——不带页面自己的任何状态,因此页面的 store 保持私有,契约保持精简。 + +**行页仍然只为已配置的行存在。** list 条目无法预先说明它对哪些行有话要说,而一个空空如也的行页没有值得打开的内容,所以配置控件保留原规则:有 `plugins.row.config` 条目点名这一行时才可打开,对该行的贡献渲染在那个页面上。 + +## 考虑过的替代方案 + +**按对象和区域各开一个 slot(`plugins.bundle.section`、`plugins.row.section` 等)。** 否决:三个区域要九个 slot,而跨对象的贡献要注册三次。subject 判别式在三个 slot 里承载同样的信息。 + +**把页面的 `PackageView` 与 `PackageRow` 直接作为 subject。** 否决:这些类型携带 Host 的只读原因、错误、条目 id 与 fiber 阶段,这些是页面自己的事;暴露它们会让 store 内部成为公共契约。 + +**每一行都有页面,让行级贡献总有落脚处。** 推迟:没有配置的行,页面除了模块名和状态之外无可展示;等有贡献需要时再放宽这条规则。 + +## 后果 + +- 一个贡献就是一次 slot 注册,只需 `import type` `ui-plugin-manager` 的声明;页面从不点名任何贡献插件。 +- e2e 夹具组合包(`@fixture/live-client`)通过三个 slot 向它自己的组合包页和行页贡献内容;它的浏览器半侧与其余注册一起完成,也一起离开。 +- slot 目录与 slots 子系统层级树在 `main` 下列出这三个 slot。 + +## 测试 + +`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` 在组合包页、行页和官方插件页上渲染贡献并核对各自的 subject,钉住投影;`browser-plugin.client.spec.tsx` 钉住声明。`apps/web/tests/plugin-config.e2e.ts` 打开夹具组合包,断言组合包页与行页上的操作、标签和区块、官方插件页上没有它们,并保留组合包页的 golden。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml new file mode 100644 index 0000000000..665be1daa4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md +2026-09-17-settings-pages-as-companion-packages.md: de2f2897938fcc158c49bed772f399b4d9328d19 +2026-09-17-settings-pages-as-companion-packages.zh.md: 27d00faa156f0f48c6dc3995930be9ca629a732d diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md new file mode 100644 index 0000000000..de2f289793 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md @@ -0,0 +1,40 @@ +# Agent Note: Settings pages as companion packages + +Status: implemented + +English | [中文](2026-09-17-settings-pages-as-companion-packages.zh.md) + +## Problem + +The four official settings pages — shell, agent loop, Subagent, web search — lived in one client package, `ui-settings-plugins`, beside the Built-in plugins section. Every new built-in page grew that package, and a community bundle that wanted a settings page had no worked example of a page living with the plugin that owns the namespace. Moving each page into the Host package that registers its namespace was considered and does not fit: the shell namespace is registered by the executor families under the sandbox row, the loop package would gain a browser build for one number field, and the Subagent namespaces are registered from a subpath row, which carries no browser half. + +## Decision + +**A community plugin's settings page lives in its own package's browser half.** It registers into `plugins.bundle.config` or `plugins.row.config`, owns its copy and styles, and reads and writes through `ctx.settingsScope`. Its i18n is the client locale service, as the shipped community bundles already do. + +**A built-in namespace's page lives in a companion client package.** `ui-settings-shell`, `ui-settings-agent-loop`, `ui-settings-subagent`, and `ui-settings-web-search` are client-only packages with an empty Host `apply`, one bare-name row each in the web composition's roster, registering into `plugins.item` while the Host serves their namespace. Host packages stay free of browser code; the namespace they edit is spelled as a literal, never imported from the owner. + +**The form machinery moves to `ui-primitives`.** `SettingsFormModel` with `settingsNumberField` and `settingsTextField`, `SettingsValueField` and `SettingsSecretField`, and the `SettingsForm` frame are the kit every page renders with; the frame takes its copy as `labels` and the model takes a structural scope, so the baseline package depends on neither a plugin nor its dictionary. + +**`settingsScope.whileServed(namespaces, register)` is the registration rule.** A page exists exactly while the Host serves one of its namespaces: the service watches the shared describe mirror, runs the registration when a namespace appears, and disposes it when none is served, so four packages share one rule instead of four copies. + +**`ui-settings-plugins` keeps the Built-in plugins section only:** the navigation entry and the tab row feature-owned tabs register into. + +## Alternatives considered + +**A schema-driven generic form.** Rejected: a generic renderer covers scalar fields and stops at the first dynamic control — the Subagent page's live model catalogue, its cross-field rule, its one-mutation save — and every exception would become a role the schema is not for. Every page is code; a community plugin that ships no page has none. + +**Dual-face Host packages for the built-in pages.** Rejected for the reasons above; the companion keeps the option open for a package that can carry a half. + +**Copy for the form frame in one shared dictionary.** Rejected: it would couple every page to another plugin's dictionary keys; each page's dictionary carries the few frame strings. + +## Consequences + +- The Plugins page's Official group lists the same four pages in the same order, from four packages instead of one; their labels and forms are unchanged, so the page goldens hold. +- The cookbook [adding a settings page](../../../../docs/cookbook/adding-a-settings-card.md) shows the community path and names the companion template. +- The client module system's rule stands: a browser half rides the Loader row whose specifier is the bare package name, so a bundle split into subpath rows keeps every page it registers on its root row. +- The shell page's `plugins.item` id is `shell`, where the old package registered `bash`: `subject.id` on an official page is now a discriminator `plugins.detail.*` contributions key on, so the id names the capability the page edits rather than one executor family. + +## Testing + +Each companion's `tests/apply.client.spec.ts` registers its page while the Host serves the namespace, withdraws it when the Host stops, and collapses on teardown; its card and controller specs carry the page's behavior from the former package. `packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`, `packages/client/ui-primitives/tests/settings-form.client.spec.tsx`, and `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` pin the kit; `packages/client/ui-settings/tests/while-served.client.spec.ts` pins the registration rule. `apps/web/tests/plugin-config.e2e.ts` exercises the shell and Subagent pages over the real wire. diff --git a/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md new file mode 100644 index 0000000000..27d00faa15 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.zh.md @@ -0,0 +1,40 @@ +# Agent Note:设置页作为伴生包 + +状态:已实现 + +[English](2026-09-17-settings-pages-as-companion-packages.md) | 中文 + +## 问题 + +四个官方设置页——终端、Agent 循环、Subagent、网页搜索——都放在一个客户端包 `ui-settings-plugins` 里,与「内置插件」分区挨着。每新增一个内置页面都让这个包再长一截,而想要设置页的社区组合包也找不到"页面与拥有命名空间的插件放在一起"的现成样板。曾考虑把每个页面搬进注册其命名空间的宿主包,但行不通:shell 命名空间由执行器家族在 sandbox 行之下注册,循环包为了一个数字字段要长出浏览器构建,Subagent 的命名空间从子路径行注册,而子路径行不携带浏览器半侧。 + +## 决策 + +**社区插件的设置页放在自己包的浏览器半侧。** 它注册进 `plugins.bundle.config` 或 `plugins.row.config`,自己拥有文案和样式,通过 `ctx.settingsScope` 读写。它的 i18n 走客户端 locale 服务,已发布的社区组合包本来就是这么做的。 + +**内置命名空间的页面放在伴生客户端包里。** `ui-settings-shell`、`ui-settings-agent-loop`、`ui-settings-subagent`、`ui-settings-web-search` 是纯客户端包,宿主 `apply` 为空,各在 Web 组合花名册里占一条裸名行,在 Host 服务其命名空间期间注册进 `plugins.item`。宿主包不含浏览器代码;它们编辑的命名空间以字面量拼写,从不从 owner 导入。 + +**表单机制搬进 `ui-primitives`。** `SettingsFormModel` 及 `settingsNumberField`、`settingsTextField`,`SettingsValueField` 与 `SettingsSecretField`,以及 `SettingsForm` 框架,是每个页面共同渲染用的套件;框架以 `labels` 接收文案,模型接受结构化的 scope,因此基线包既不依赖任何插件也不依赖其字典。 + +**`settingsScope.whileServed(namespaces, register)` 是注册规则。** 页面恰好在 Host 服务其某个命名空间期间存在:服务监视共享的 describe 镜像,命名空间出现时执行注册,一个都不再服务时注销,四个包共用一条规则而不是四份副本。 + +**`ui-settings-plugins` 只保留「内置插件」分区:** 导航项和供功能插件注册标签页的标签行。 + +## 考虑过的替代方案 + +**由 schema 驱动的通用表单。** 否决:通用渲染器只覆盖标量字段,碰到第一个动态控件就止步——Subagent 页的实时模型目录、跨字段规则、一次 mutate 提交——而每个例外都会变成一个 schema 本不该承担的 role。每个页面都是代码;没有发页面的社区插件就没有页面。 + +**内置页面做成双面宿主包。** 因上述原因否决;伴生包给能带半侧的包留了这个选项。 + +**表单框架的文案放在一本共享字典里。** 否决:那会让每个页面耦合到另一个插件的字典键;每个页面的字典自带那几条框架文案。 + +## 后果 + +- 插件页官方分组列出同样四个页面、同样顺序,来自四个包而不是一个;标签与表单未变,页面 golden 保持不变。 +- 手册[添加设置页](../../../../docs/cookbook/adding-a-settings-card.zh.md)展示社区路径并点名伴生包模板。 +- 客户端模块系统的规则不变:浏览器半侧挂在说明符为裸包名的 Loader 行上,因此拆成子路径行的组合包,其注册的所有页面都留在根行上。 +- 终端页在 `plugins.item` 里的 id 是 `shell`,旧包注册的是 `bash`:官方页面上的 `subject.id` 现在是 `plugins.detail.*` 贡献可以据以判断的标识,所以 id 以页面编辑的能力命名,而不是某一个执行器家族。 + +## 测试 + +每个伴生包的 `tests/apply.client.spec.ts` 在 Host 服务命名空间时注册页面、停止服务时注销、teardown 时收拢;其 card 与 controller 用例延续原包里的页面行为。`packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`、`packages/client/ui-primitives/tests/settings-form.client.spec.tsx` 与 `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` 钉住套件;`packages/client/ui-settings/tests/while-served.client.spec.ts` 钉住注册规则。`apps/web/tests/plugin-config.e2e.ts` 走真实链路验证终端页与 Subagent 页。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index ed1df3a4fe..ce21dc1c9a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 049785e9cd5dea2d92d9fc5a14dfeddac9b64469 -2026-08-08-web-background-job-display.zh.md: 1dd7ee9215fa034a0a7abbe75bd5f6eb6907e9ee +2026-08-08-web-background-job-display.md: 1ebd600e4d423f95ad663c333572a990d0c9efa7 +2026-08-08-web-background-job-display.zh.md: 56d062b5079e15c90cffc30b538efe162bc19d9d diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 049785e9cd..1ebd600e4d 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -99,7 +99,7 @@ A running one-shot background subagent therefore appears both there and in the s **No web path calls `ctx.jobs.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `job_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site. -**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running. +**No cancellation.** That phase owed a decision the seam then did not answer: `kill()` marked terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running. The [human job kill note](2026-08-26-human-job-kill.md) later resolved it: `kill` now takes an explicit `reported` claim, and the web stop control passes `reported: false` so the completion notice stays due. **No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader. @@ -133,7 +133,7 @@ Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts **Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint. -**`stopping` is rarely visible.** Only the model's `job_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change. +**`stopping` is visible on every kill path.** The model's `job_kill` and the web stop control both produce it; carrying it in the union from the start is what kept the human-kill phase off the wire format. **Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 1dd7ee9215..56d062b507 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -99,7 +99,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void **没有任何 Web 路径调用 `ctx.jobs.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `job_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。 -**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。 +**不做中断。** 那一期欠一个 seam 当时没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。[人工任务终止 Note](2026-08-26-human-job-kill.zh.md) 后来解决了它:`kill` 现在接受显式的 `reported` 认领,Web 停止控件传 `reported: false`,完成通知因此仍会投递。 **帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。 @@ -133,7 +133,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void **终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 -**`stopping` 很少可见。** 只有模型的 `job_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。 +**`stopping` 在每条 kill 路径上都可见。** 模型的 `job_kill` 与 Web 停止控件都会产生它;从一开始就把它放进联合类型,正是人工 kill 这一期不必动线路格式的原因。 **一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。 diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml new file mode 100644 index 0000000000..151857373f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-human-job-kill.md +2026-08-26-human-job-kill.md: efffdbd85bf2fa665565ff584a6938cfee827516 +2026-08-26-human-job-kill.zh.md: 206acca008518dd689351e40a99ff1571f3d1a8f diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md new file mode 100644 index 0000000000..efffdbd85b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.md @@ -0,0 +1,36 @@ +# Agent Note: Human job kill — an unclaimed terminal report instead of a second cancellation path + +Status: implemented + +Update: the `reported` option this note introduced is gone with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The registry keeps no report bit; `dsh-tool-jobs` claims only its own `job_kill` and waits in a ledger, so a human `job.kill` leaves the notice due by construction. The reason merge, the `job.kill` Remote, and the two-press control are unchanged. + +English | [中文](2026-08-26-human-job-kill.zh.md) + +## Problem + +The [web job display note](2026-08-08-web-background-job-display.md) shipped the task list read-only and recorded why: `JobRegistry.kill()` marks the job `reported`, and the [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.md) completion reporter suppresses the settlement notice for a reported job. That coupling is correct for the one caller that existed — the model's `job_kill`, whose own tool result already tells the model what it did — but a human pressing a stop button has no model-visible channel at all. A kill written against that contract would leave the model believing its task is still running, exactly the stale-world-model failure Claude Code ships today (its `/tasks` kill sets `notified: true` and the model learns nothing) and Kimi avoids (a model `TaskStop` suppresses its own notification; a human stop delivers one). + +## Decision + +A delivery claim means "the terminal state has a committed delivery path to the model", so the fix is to stop conflating cancellation with claiming that delivery. + +- **A human kill claims no delivery.** `JobRegistry.kill(id, { reason? })` records the reason only; the delivery ledger lives in `dsh-tool-jobs`, which claims a job for the model's own `job_kill` and for its waits, so a `job.kill` from the task list leaves the settlement to the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears a claim the model already holds, and a terminal record stays exactly as settlement left it. +- **A recorded kill reason merges into a `killed` settlement's detail** — producer facts first (`signal: SIGTERM; cancelled by the user`) — so the completion notice and the web row both say who stopped the work without a new snapshot field or notice template. A job that outruns its kill (settles `completed`/`failed`) keeps the producer detail alone. This is the reconciliation Codex spells out in its `` guidance: when the user stops something, the model is told rather than left to infer. +- **`job.kill` is a Job Controller Remote** (`@deepseek-ai/dsh-api-job-controller`, beside `job.follow`; it began life as `session.killJob` on the Session Controller and moved with the observation stream when that package split out) with the same live-only Agent lookup (`ctx.agents.get`) as `session.cancel`: a running job's owner is alive by the registry's ownership contract, and killing from a list must not revive a Session any more than listing does. Unknown and foreign jobs collapse to one `job/not-found` rejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; the controller requires `ctx.jobs` to load, so a composition without a registry has no kill Remote at all. The registry's owner fence is the only access rule: the Session Controller's subagent ownership fence is not applied, so a child session's own jobs are killable from its list (review rejected exporting that fence's helpers from the Session Controller package, and the rule adds nothing for a job the child owns), and the controller depends on neither the agent registry nor the Session Controller. No approval interaction: this single-user local BFF treats it as the same class of action as the turn-cancel button. +- **The task list's stop control is two-press** (arm, then confirm within 3s, matching the Kimi `s`+`y` and OpenCode double-Esc shape), rendered only on running job rows — rows with a `jobId`. Standalone activity rows (workflow runs) have no kill handle and no button. The pending press disables the control and stays pending on an admitted kill — the unary response and the jobs frames have no cross-carrier ordering, so only the authoritative frame (the row leaving the killable set) releases it; a rejected kill shows a brief hint. Because the kill addresses `ctx.jobs` by id, every job kind gains the control at once — bash, pwsh, pty, and one-shot background subagents. + +## Alternatives considered + +- **A `notify: boolean` option** — rejected because the registry cannot promise a notice (quiet delivery, unowned jobs, owner teardown all legitimately drop one); the ledger's claim names the fact the tool actually controls: whether one of its own tool results already delivered the terminal state. +- **A separate `interrupt()`/`stopByUser()` method** — one cancellation path with an explicit claim beats two methods whose only difference is a boolean, and Kimi's split (`stop` vs `stopByUser`) shipped with its TUI calling the wrong one. +- **Saying nothing to the model (Claude Code's shape)** — rejected; their own source comments question it, and the DSH blocker note recorded the stale-belief failure as the reason the control did not ship earlier. + +## Testing + +`tool-jobs` pins the ledger (a kill the model did not request leaves the notice due; its own `job_kill` and waits claim it) and the delivered notice text verbatim, reason included; `jobs-local` pins the detail merge on `killed` and the outran-kill case. `kill.host.spec.ts` pins the Remote command: admission, unowned-job kill from an agentless session, `job/not-found` for unknown/foreign. The client suites pin the RPC passthrough and the two-press control (arm, confirm, disarm timer, failure hint, single armed row, stale-phase cleanup); the keyless web e2e drives the button end to end. + +## Consequences + +- The completion notice for a human kill wakes an idle owner (default `wakeup` delivery) — a deliberate cost: an unclaimed completion the model never learns about is the failure this note exists to fix. +- `kill`'s positional `reason` parameter is gone (pre-release, no shim); the options object is the only form. +- A killed job's `detail` may now carry two clauses joined by `; `. Anything parsing `detail` as a single producer fact must treat it as opaque text, which `JobView.detail` always declared it to be. diff --git a/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md new file mode 100644 index 0000000000..206acca008 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md @@ -0,0 +1,36 @@ +# Agent Note:人工任务终止——用「未认领的终态报告」而非第二条取消路径 + +状态:已实现 + +Update:本文引入的 `reported` 选项已随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md) 消失。注册表不再保留播报位;`dsh-tool-jobs` 只在台账里认领自己的 `job_kill` 与等待,因此人类的 `job.kill` 天然让通知保持待送达。原因合并、`job.kill` Remote 与两击式控件保持不变。 + +[English](2026-08-26-human-job-kill.md) | 中文 + +## 问题 + +[Web 任务展示 Note](2026-08-08-web-background-job-display.zh.md) 把任务列表交付为只读并记下了原因:`JobRegistry.kill()` 会把任务标记为 `reported`,而 [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.zh.md) 的完成播报员对已 reported 的任务抑制结算通知。这个耦合对当时唯一的调用方是正确的——模型的 `job_kill` 自己的 tool result 已经告诉模型它做了什么——但按下停止按钮的人没有任何模型可见渠道。照那份契约写一个人工 kill,模型会一直以为任务还在跑,正是 Claude Code 今天在售的过期世界模型失败(其 `/tasks` kill 置 `notified: true`,模型什么也学不到),也正是 Kimi 避开的(模型 `TaskStop` 抑制自己的通知;人工 stop 投递一条)。 + +## 决策 + +送达认领的本义是「终态已有一条向模型送达的承诺路径」,所以修法是不再把「取消」与「认领这次送达」混为一谈。 + +- **人类的 kill 不认领送达。** `JobRegistry.kill(id, { reason? })` 只记录原因;送达台账在 `dsh-tool-jobs` 里,它只为模型自己的 `job_kill` 与等待认领任务,因此来自任务列表的 `job.kill` 让结算照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除模型已持有的认领,终态记录也保持结算时的原样。 +- **记录下来的 kill reason 合入 `killed` 结算的 detail**——生产者事实在前(`signal: SIGTERM; cancelled by the user`)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 `completed`/`failed`)只保留生产者 detail。这正是 Codex 在 `` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。 +- **`job.kill` 是 Job Controller 的 Remote**(`@deepseek-ai/dsh-api-job-controller`,与 `job.follow` 并列;它最初是 Session Controller 上的 `session.killJob`,该包拆出时随观测流一起迁走),与 `session.cancel` 一样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 `job/not-found` 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;控制器加载就要求 `ctx.jobs`,没有注册表的组合根本没有 kill Remote。注册表的所有者围栏是唯一的访问规则:不施加 Session Controller 的 subagent 所有权栅栏,子会话自己的 job 可以从它的列表里被杀停(评审否决了从 Session Controller 包导出该栅栏的实现函数,而这条规则对子会话自有的 job 也没有额外意义),控制器既不依赖 agent 注册表也不依赖 Session Controller。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。 +- **任务列表的停止控件为两击式**(先武装、3 秒内确认,对齐 Kimi 的 `s`+`y` 与 OpenCode 的双 Esc),只渲染在运行中的 job 行——即带 `jobId` 的行。独立 activity 行(workflow 运行)没有 kill 句柄也就没有按钮。请求进行中按钮禁用,且受理成功后保持 pending——unary 响应与 jobs 帧没有跨载体顺序保证,只有权威帧(行离开可杀集合)才释放控件;被拒绝的 kill 短暂提示。由于 kill 按 id 寻址 `ctx.jobs`,所有任务 kind 一次性获得该控件——bash、pwsh、pty 与一次性后台 subagent。 + +## 备选方案 + +- **`notify: boolean` 选项**——否决:注册表承诺不了通知(quiet 投递、无主任务、owner 拆除都会正当地丢弃一条);台账的认领命名的是工具真正控制的事实:它自己的某个 tool result 是否已经送达了终态。 +- **独立的 `interrupt()`/`stopByUser()` 方法**——一条取消路径带显式认领胜过两个只差一个布尔的方法;Kimi 的拆分(`stop` 与 `stopByUser`)上线时 TUI 就调错了那个。 +- **对模型保持沉默(Claude Code 的形状)**——否决;他们自己的源码注释都在质疑它,而 DSH 的阻塞 Note 正是把过期认知失败记为该控件此前不能上线的原因。 + +## 测试 + +`tool-jobs` 钉住台账(不是模型请求的 kill 让通知保持待送达;它自己的 `job_kill` 与等待认领它)与逐字的带原因投递通知文本;`jobs-local` 钉住 `killed` 的 detail 合并与跑赢 kill 的情形。`kill.host.spec.ts` 钉住 Remote 命令:受理、无 agent 会话 kill 无主任务、未知/他人的 `job/not-found`。客户端套件钉住 RPC 透传与两击控件(武装、确认、解除计时、失败提示、单一武装行、过期相位清理);无密钥 web e2e 端到端驱动按钮。 + +## 后果 + +- 人工 kill 的完成通知会唤醒空闲 owner(默认 `wakeup` 投递)——这是刻意的代价:模型永远学不到的未认领完成,正是本 Note 要修的失败。 +- `kill` 的位置参数 `reason` 已删除(pre-release,无垫片);options 对象是唯一形式。 +- 被 kill 任务的 `detail` 可能携带以 `; ` 连接的两个子句。任何把 `detail` 当单一生产者事实解析的代码都必须把它当不透明文本——`JobView.detail` 一直如此声明。 diff --git a/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.i18n.yaml new file mode 100644 index 0000000000..5359a7b2cd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md +2026-08-26-shell-execute-projection-and-jobs-at-start.md: 2a03cd9f99645c10377dc9d2a5fdc68cb5ab0a4a +2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md: bdc6cc432dbb8e3a0a8d0fc86cf055f3938ff201 diff --git a/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md new file mode 100644 index 0000000000..2a03cd9f99 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md @@ -0,0 +1,52 @@ +# Agent Note: One shell execute() — foreground as a projection, timeout as a bounded wait on the job + +Status: implemented + +Update: the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) owns the output ring, the cursors, and the pull sources every registered command feeds. + +English | [中文](2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md) + +## Problem + +A bash command that outran its foreground timeout was killed, discarding the work — the single most common way a long build or install failed under the agent. Fixing that inside the old seam was structurally awkward: `ctx.shell` had two execution methods, `run()` (deadline fused in, promise-only, no handle to keep) and `start()` (handle, no deadline), so "keep this already-running foreground command" had no expression — the deadline owner could only kill, and the caller had nothing to re-register with `ctx.jobs`. The two methods had also drifted: different stdout budgets, a documented "start ignores timeoutMs" wart, and sync-vs-async spawn-failure behavior that differed per path. + +Peer evidence pointed one way. Kimi keeps a timed-out command running by default (`bashAutoBackgroundOnTimeout`), rebinding nothing — the process task detaches, the caller signal unhooks, and the partial output returns with the handle. Claude Code's timeout handler calls `background()` on the same `ShellCommand` instead of killing, with an explicit comment that respawning would leak cleanups and duplicate events. Codex has no hand-over because it never had a "foreground spawn" at all: every `exec_command` is a bounded observation window over a session, and "still running" is a normal return carrying the handle. All three agree on the underlying shape: there is one way to execute, and foreground is a property of the wait, not the spawn. + +The first shipped version of this change expressed the hand-over on the seam: an `onExpiry: 'offer'` policy, `ShellExecution.promotion`, and a `ShellPromotionOffer` the tool had to answer synchronously at the deadline. Review rejected it: the protocol existed only because the tool registered the job at the timeout instead of at the start, so every executor carried a relay/timer/detach arm and every consumer a timing obligation that no execution needs. + +## Decision + +**The seam converges to `resolve()` + `execute()`.** `execute(spec)` returns `Promise` — the live `ShellProcess` itself plus the foreground projection `result()` (split collected streams, first-cause `timedOut`/`aborted`, rejecting only for infrastructure failures). `run()` and `start()` are deleted (pre-release, no shims); the historical differences they encoded became explicit inputs: `ShellExecSpec.onExpiry` is `'kill'` (the default) or `'none'`, and one stdout budget (`spec.stdoutMaxBytes`) applies everywhere. There is no hand-over protocol: a caller that wants to wait only for a while runs the command under `'none'` and bounds its own wait, and the handle stays valid after the caller stops waiting. + +**Preparation shares the execution deadline.** The handle is published after asynchronous confinement finishes. Preparation cancellation or failure rejects `execute`; expiry returns a settled timed-out handle with empty output. A late preparation result cannot spawn work. Provider facts are installed synchronously before subprocess settlement so an immediately failed launch still receives its sandbox classification. + +**Spawn failures are contained uniformly**: a synchronous spawn throw and an asynchronous rejection both settle the handle as `killed` with the note on the read path, while `result()` rejects with the original error (identity preserved — the sandbox executors map runner-attributed failures to `SANDBOX_UNAVAILABLE` in their `result()` decoration and stamp handle facts in `onProcessDone`, both keyed on the one handle instance). + +**With a job registry composed, `tool-bash`/`tool-pwsh` register every command with `ctx.jobs` at its start.** `run_in_background` returns the id at once. A foreground call starts the same job and waits on it with the registry's `wait(id, timeoutMs, owner, signal)`, the existing `timeoutMs` being the wait bound — no second knob. A command that settles within the wait returns the ordinary foreground result from the handle's `result()` and the tool removes the job record (`JobRegistry.remove`), so the model never sees an id and the list does not accumulate every `ls`. A command that outlives the wait keeps running as the job it already was, and the call returns `{ kind: 'promoted', jobId, timeoutMs, output }` — rendered as `[still running after ; moved to background job ]` plus hand-off guidance — seeded with one consuming registry read, so `job_output` continues exactly after it. Nothing changes hands at the timeout: not the process, not the abort signal, not the output. The tool never passes its own signal to the process; cancelling the call kills the job, and a kill from outside the call (the human's stop control, a parallel `job_kill`) reaches the process through the job's `cancel` hook, whose reason the tool renders as `[stopped: ]` in the foreground result (`stopped` on the value) so the model reads why instead of a command failure. + +**The background surface follows the registry, and the registry stays optional.** The tool's `inject` does not name `jobs`. It registers a foreground-only definition while no registry is composed, replaces it with the job-backed definition inside a `ctx.inject(['jobs'], …)` fork for as long as `ctx.jobs` is present, and restores the foreground-only one when the registry unloads while the plugin stays. A composition without the registry therefore keeps a plain `bash` whose schema advertises neither `run_in_background` nor the hand-over, and the model sees no `job_*` tools. Registration is best-effort: `promoteOnTimeout: false`, or a registry that refuses the job at its start (the owner's admission limit, no attached controller), runs the command under the executor's `'kill'` deadline instead, logged. + +**The registry reports what a waiting caller already collected.** The `settled` event carries `awaited`: whether the settlement released a live `wait`, which is true for the tool waiting on its own foreground command exactly as for a `job_output` wait. `dsh-tool-jobs` withholds the completion notice for awaited settlements and for kills the model requested through `job_kill`, and its private claim ledger is gone; a timed-out or aborted wait has already left the registry's waiter set, so a later settlement notifies as usual. A human kill from the web controller is neither, so the owning agent still receives the notice, reason included. + +## Alternatives considered + +- **A third method (`begin()`) beside `run`/`start`** — the first design. Rejected in review: the three "methods" were two orthogonal inputs (deadline policy; which projection the caller awaits) wearing method names, and the peer products all model one execution with projected views. Converging deleted the budget/wart drift instead of adding a surface. +- **Codex's session model** (every call an observation window, polling via `write_stdin`) — rejected as a model-facing vocabulary change duplicating what `ctx.jobs` already provides; its early-yield result contract survives in the promoted arm. +- **A separate hand-over threshold config** — rejected; "timeout means stop blocking the turn, not kill the work" needs no second timer, and an explicit model-passed `timeoutMs` bounds the wait the same way (both peers behave so; the schema text says it). +- **A promotion offer on the seam** (`onExpiry: 'offer'`, `ShellExecution.promotion`, `ShellPromotionOffer` with a synchronous `accept()`/`decline()` answer) — the version this PR first shipped. Rejected in review because the deferred registration was the only reason for the protocol: the executor's offer arm relayed the caller's signal, armed its own timer, and detached the signal on accept, all of which a consumer holding the handle can do itself, and none of which is needed once the job exists from the start. +- **A tool-owned deadline over `onExpiry: 'none'` without registering at start** — removes the seam protocol but keeps the command invisible and unkillable until the timeout; rejected because a listed, stoppable command from its first second is the point of the human kill. +- **A hard `jobs` dependency for the shell tools** — rejected: a composition that wants a shell without three model-visible `job_*` tools must stay possible, so the tool swaps definitions on the registry's presence instead. +- **Exempting waited-on commands from the admission cap, and an `attached` marker in `JobView`** — deferred: a refusal at the start falls back to the deadline kill, and grouping running foreground commands in the Web task list is a follow-up. + +## Testing + +Executor level (real processes): the `'kill'` and `'none'` arms classify first-cause; a caller abort before the deadline stays the first cause when the process outlives the deadline; a pre-aborted signal classifies `aborted`; preparation expiry yields a settled timed-out handle without spawning (sandbox suites); sync-throw containment with error identity through `result()`. Tool level: a real `printf …; sleep 30` with `timeoutMs: 250` is listed as `bash-1` while the call waits, returns the still-running text with the early output, and its next `job_output` read repeats nothing; a command that finishes within the wait returns the foreground result with the registry announcing `registered`, `settled` (`awaited: true`), `removed` and an empty list afterwards; a registry kill during the wait renders `[stopped: cancelled by the user]` before the signal marker; cancelling the call kills its job with `tool call aborted` as the reason; a start failure is the tool's error result with no job left; admission saturation falls back to the deadline kill with the warn; `promoteOnTimeout: false` keeps the kill, emits no job events, and drops the description sentence; the schema flips to job-backed when a registry loads later and back to foreground-only when it unloads, while disposing the tool plugin under a registry removes the tool without logging; pwsh mirrors every case over a scripted executor. Registry level: `awaited` for a live wait, `false` for none, timed-out, or aborted waits, and `true` while another wait on the job timed out; `remove` drops a settled record with a `removed` event and refuses a live job, an unknown id, and a foreign caller. Render texts are pinned verbatim. + +## Consequences + +- Every `ShellExecutor` consumer moved: the tools, the hook runner, tmux-context, and the webworker sandbox stack now speak `execute()`; executor test suites keep scenario intent through local `run`/`start` shims over the new seam. +- The `bash`/`pwsh` tool descriptions and `timeoutMs` schema text depend on the registry's presence (model-visible); the output union carries the `promoted` arm, and the foreground arm gained the optional `stopped` reason — the PTC system-prompt snapshots re-recorded with it. +- A registered command has no deadline at all: for a foreground call the timeout bounds only the wait, and stopping the command afterwards is `job_kill` or the web stop control. +- The Web task list shows every foreground command while it runs and streams it through `job.list`/`job.follow`; a settled one leaves with its result. +- `JobRegistry` gained `remove` and the `settled` event's `awaited` flag; `dsh-tool-jobs` keeps only the set of jobs the model killed. +- `start()`-era behavior where a synchronous spawn throw escaped to the caller is gone: callers read failures from `result()`/the read path. The sandbox "sync EACCES names the runner" classification now arrives as the `result()` rejection or the handle's `runnerFailed` fact. diff --git a/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md new file mode 100644 index 0000000000..bdc6cc432d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md @@ -0,0 +1,52 @@ +# Agent Note:唯一的 shell execute()——前台是投影,超时是对 job 的有界等待 + +状态:已实现 + +Update:[jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)拥有每条已登记命令现在填充的输出环、游标与拉取源。 + +[English](2026-08-26-shell-execute-projection-and-jobs-at-start.md) | 中文 + +## 问题 + +跑过前台超时的 bash 命令会被杀掉、工作作废——这是长构建或安装在 agent 手里失败的头号方式。在旧 seam 里修它结构上很别扭:`ctx.shell` 有两个执行方法,`run()`(deadline 内置、只给 promise、没有可保留的句柄)与 `start()`(有句柄、无 deadline),于是「保住这个已在运行的前台命令」无从表达——deadline 的所有者只会杀,调用方也拿不到可向 `ctx.jobs` 重新注册的东西。两个方法还各自漂移:stdout 预算不同、文档里挂着「start 忽略 timeoutMs」的疣、同步与异步 spawn 失败的行为按路径分叉。 + +同行证据指向同一个方向。Kimi 默认让超时的命令继续运行(`bashAutoBackgroundOnTimeout`),什么都不重绑——进程任务脱离、调用方信号解钩、部分输出随句柄返回。Claude Code 的超时处理对同一个 `ShellCommand` 调用 `background()` 而不是杀掉,并明确注释重新 spawn 会泄漏清理并重复事件。Codex 根本没有移交,因为它从未有过「前台 spawn」:每次 `exec_command` 都是对一个 session 的有界观察窗,「仍在运行」是携带句柄的正常返回。三家在底层形状上一致:执行只有一种方式,前台是等待的属性,不是 spawn 的属性。 + +本变更最初交付的版本把移交放在了 seam 上:`onExpiry: 'offer'` 策略、`ShellExecution.promotion`,以及工具必须在 deadline 处同步应答的 `ShellPromotionOffer`。评审否决了它:这套协议存在的唯一原因是工具把 job 登记推迟到了超时点而不是启动点,于是每个执行器都要背一条中继/计时/解绑分支,每个消费方都要背一条任何执行都不需要的时序义务。 + +## 决策 + +**seam 收敛为 `resolve()` + `execute()`。** `execute(spec)` 返回 `Promise`——活的 `ShellProcess` 本体加前台投影 `result()`(分流收集、首因 `timedOut`/`aborted` 归类、只为基础设施失败 reject)。`run()` 与 `start()` 删除(pre-release,无垫片);它们编码的历史差异变成显式输入:`ShellExecSpec.onExpiry` 取 `'kill'`(默认)或 `'none'`,stdout 预算统一为 `spec.stdoutMaxBytes`。没有任何移交协议:只想等一段时间的调用方以 `'none'` 运行命令并自己限定等待时长,调用方停止等待后句柄依然有效。 + +**准备与执行共用 deadline。** 异步沙箱准备完成后才发布句柄。准备期间的取消或失败会拒绝 `execute`;到期则返回已结算的超时句柄,输出为空。迟到的准备结果不会启动任务。提供方事实在 subprocess 结算前同步安装,立即失败的启动也能获得对应的沙箱归类。 + +**spawn 失败统一收容**:同步抛错与异步 rejection 都把句柄结算为 `killed`、说明进入读路径,而 `result()` 以原始错误 reject(保留同一性——沙箱执行器在其 `result()` 装饰里把可归因于 runner 的失败映射为 `SANDBOX_UNAVAILABLE`,并在 `onProcessDone` 里盖章句柄事实,两者都以同一个句柄实例为键)。 + +**组合中有 job 注册表时,`tool-bash`/`tool-pwsh` 在每条命令启动时就把它登记到 `ctx.jobs`。** `run_in_background` 立即返回 id。前台调用启动同样的 job,并用注册表的 `wait(id, timeoutMs, owner, signal)` 等待它,等待上限就是现有的 `timeoutMs`——没有第二个旋钮。在等待内结算的命令用句柄的 `result()` 返回普通前台结果,工具随即移除该 job 记录(`JobRegistry.remove`),模型从不看到 id,列表也不会堆满每一条 `ls`。超过等待仍在运行的命令继续作为它本来就是的那个 job 运行,调用返回 `{ kind: 'promoted', jobId, timeoutMs, output }`——渲染为 `[still running after ; moved to background job ]` 加交接指引——并以一次消费式注册表读取打底,因此 `job_output` 恰好从此处接续。超时那一刻没有任何东西易手:进程没有、中止信号没有、输出也没有。工具从不把自己的信号交给进程;取消调用会杀掉 job,而来自调用之外的杀停(人的停止控件、并行的 `job_kill`)经 job 的 `cancel` 钩子到达进程,工具把其原因渲染为前台结果里的 `[stopped: ]`(值上的 `stopped`),模型读到的是原因而不是命令失败。 + +**后台能力面跟随注册表,注册表保持可选。** 工具的 `inject` 不写 `jobs`。没有注册表时它注册纯前台定义;`ctx.jobs` 出现后,`ctx.inject(['jobs'], …)` fork 用带 job 的定义替换它,注册表存在多久就保持多久;注册表在插件仍在时卸载,则恢复纯前台定义。因此没有注册表的组合保留一个 schema 既不宣传 `run_in_background` 也不宣传移交的普通 `bash`,模型也看不到任何 `job_*` 工具。登记是尽力而为的:`promoteOnTimeout: false`,或注册表在启动时拒绝该 job(持有者的准入上限、没有已附加的控制器),都改为在执行器的 `'kill'` deadline 下运行命令,并记录日志。 + +**注册表报告等待方已经收走了什么。** `settled` 事件带 `awaited`:本次结算是否释放了一个存活的 `wait`,工具等待自己的前台命令时为真,`job_output` 的等待亦然。`dsh-tool-jobs` 对已 awaited 的结算和模型经 `job_kill` 请求的杀停不发完成通知,其私有的认领台账已删除;超时或中止的等待早已离开注册表的等待方集合,之后的结算照常通知。来自 web 控制器的人类杀停两者都不是,因此拥有者 agent 照常收到带原因的通知。 + +## 备选方案 + +- **在 `run`/`start` 旁加第三个方法(`begin()`)**——最初的设计。评审中否决:三个「方法」其实是披着方法名的两根正交输入(deadline 策略;调用方等待哪个投影),同行产品全都建模为一次执行加投影视图。收敛顺带删掉了预算/疣的漂移,而不是再添一层表面。 +- **Codex 的 session 模型**(每次调用都是观察窗、经 `write_stdin` 轮询)——否决:那是重复 `ctx.jobs` 已有能力的模型侧词汇变更;其早退结果契约保留在 promoted arm 里。 +- **独立的移交阈值配置**——否决;「超时意味着别再阻塞回合,而不是杀掉工作」不需要第二个计时器,模型显式传的 `timeoutMs` 同样限定等待(两家同行皆如此;schema 文案已说明)。 +- **seam 上的转移 offer**(`onExpiry: 'offer'`、`ShellExecution.promotion`、需同步 `accept()`/`decline()` 应答的 `ShellPromotionOffer`)——本 PR 最初交付的版本。评审否决,因为推迟登记是这套协议存在的唯一原因:执行器的 offer 分支中继调用方信号、自设计时器、accept 时解绑信号,这些持有句柄的消费方都能自己做,而 job 一开始就存在后一样都不需要。 +- **不在启动时登记、只由工具在 `onExpiry: 'none'` 之上自设 deadline**——去掉了 seam 协议,但命令在超时前依然不可见、不可杀;否决,因为从第一秒起就被列出、可停止的命令正是人类杀停的意义所在。 +- **shell 工具硬依赖 `jobs`**——否决:想要 shell 却不想让模型看到三个 `job_*` 工具的组合必须仍然可能,所以工具改为随注册表的存在切换定义。 +- **让被等待的命令不占准入名额,以及 `JobView` 上的 `attached` 标记**——延期:启动时被拒回落到 deadline 杀,Web 任务列表对运行中前台命令的分组留作后续。 + +## 测试 + +执行器层(真实进程):`'kill'` 与 `'none'` 两个分支按首因归类;deadline 前的调用方 abort 在进程活过 deadline 时仍是首因;预先 abort 的信号归类 `aborted`;准备到期返回已结算的超时句柄且不 spawn(沙箱套件);同步抛错收容且错误同一性穿过 `result()`。工具层:真实的 `printf …; sleep 30` 配 `timeoutMs: 250` 在调用等待期间就以 `bash-1` 列出,返回带早期输出的仍在运行文本,其下一次 `job_output` 读取不重复任何内容;在等待内完成的命令返回前台结果,注册表依次播报 `registered`、`settled`(`awaited: true`)、`removed`,之后列表为空;等待期间的注册表杀停在信号标记前渲染 `[stopped: cancelled by the user]`;取消调用以 `tool call aborted` 为原因杀掉其 job;启动失败是工具的错误结果且不留 job;准入饱和回落到 deadline 杀并告警;`promoteOnTimeout: false` 保持杀、不发任何 job 事件、撤掉描述句;注册表稍后加载时 schema 切到带 job 的版本、卸载时切回纯前台,而在注册表存在时释放工具插件会移除工具且不记录任何错误;pwsh 以脚本化执行器镜像每个用例。注册表层:存活等待为 `awaited`,无人等待、超时或中止的等待为 `false`,另一个等待超时时仍为 `true`;`remove` 以 `removed` 事件丢弃已结算记录,并拒绝存活 job、未知 id 与外部调用方。渲染文本逐字钉死。 + +## 后果 + +- 每个 `ShellExecutor` 消费方都已迁移:工具、hook runner、tmux-context 与 webworker 沙箱栈现在都说 `execute()`;执行器测试套件用本地 `run`/`start` 垫片保住场景语义。 +- `bash`/`pwsh` 的工具描述与 `timeoutMs` schema 文案取决于注册表是否存在(模型可见);输出 union 保留 `promoted` arm,前台 arm 新增可选的 `stopped` 原因——PTC 系统提示词快照随之重录。 +- 已登记的命令完全没有 deadline:前台调用的超时只限定等待,之后停下命令靠 `job_kill` 或 Web 停止控件。 +- Web 任务列表显示每条运行中的前台命令并经 `job.list`/`job.follow` 流式观看;完成的命令随结果离开。 +- `JobRegistry` 新增 `remove` 与 `settled` 事件的 `awaited` 标志;`dsh-tool-jobs` 只保留模型杀停过的 job 集合。 +- `start()` 时代同步 spawn 抛错逃逸给调用方的行为不复存在:调用方从 `result()`/读路径读失败。沙箱「同步 EACCES 指名 runner」的分类现在以 `result()` rejection 或句柄的 `runnerFailed` 事实呈现。 diff --git a/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.i18n.yaml b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.i18n.yaml new file mode 100644 index 0000000000..1bc2f5e95d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md +2026-09-01-workflow-run-in-background.md: 5a141b384186e7b4f9e8125c37eaf2c01a614a6d +2026-09-01-workflow-run-in-background.zh.md: 102a398d95eef6df07443a919c19dae57b5c1e74 diff --git a/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md new file mode 100644 index 0000000000..5a141b3841 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md @@ -0,0 +1,31 @@ +# Agent Note: Workflow runs gain `run_in_background` over the job registry + +Status: implemented + +Update: with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) the run registers a `JobSpec` (no `record` flag), narrates through `JobHandle.append` and `updateProgress`, and returns its rendered value as `JobOutcome.result`, which the model's first read after settlement carries once. + +English | [中文](2026-09-01-workflow-run-in-background.zh.md) + +## Problem + +A `workflow` call blocked the parent turn until the whole script settled: a long orchestration (an audit fanning out over hundreds of files) held the model hostage for its entire wall-clock, with no way to keep working, no live progress for a human, and cancellation as the only exit. Every other long-running execution surface — bash, pwsh, one-shot subagents — already had a `run_in_background` route into `ctx.jobs`. The [record merge](../architecture/2026-09-01-jobs-absorb-activity-record.md) also removed the foreground workflow's activity mirror on the explicit promise that live workflow narration would return as a background record job. + +## Decision + +The `workflow` tool gains `run_in_background: true` (exposed and accepted while the `enableRunInBackground` config holds, default on): the call registers the run as an owned `kind: 'workflow'` job with an observation record and returns `{ kind: 'background', jobId, runId }` immediately. + +- **The run belongs to the job, not the tool step.** The engine run starts inside the job starter with no `exec.signal`; `job_kill`, the job list's stop control, and owner teardown are the cancellation paths, each forwarding its reason into `run.cancel`. A synchronous engine rejection (meta/parse failure) propagates out of the starter, so nothing registers and the model sees the ordinary correctable error. +- **Settlement is the job's settlement.** `done` chains from `run.result`: dispose (a disposal failure warns and never rejects into the registry), stop the mirrors, then map the stop reason — `completed` carries the same rendered return value as the foreground path in `JobOutcome.output` (so the completion notice and `job_output` deliver it), `cancelled` settles `killed` and leaves the detail to the registry's kill-reason merge (the forwarded cancel reason is the same string), `error` settles `failed` with the script's failure message. +- **The record mirror replaces the deleted activity mirror.** `src/record.ts` subscribes `workflow/phase`, `workflow/log`, and member lifecycle events once per plugin and routes them into tracked runs' `JobHandle` faces as the same text lines the activity mirror wrote, on the `log` channel so the model's `job_output` never renders them; `updateProgress` tracks the current phase. There is no contained-error wrapper: `append`/`updateProgress` are non-throwing surfaces (post-settlement appends drop inside the registry), and a straggling event finds no tracked run. +- **The output schema becomes a `kind`-discriminated union** (`background` | `foreground`), matching bash's shape; the foreground envelope gains `kind: 'foreground'` for symmetry. The durable run-start/member/run-end session recording keeps its `exec.parent === undefined` gate on both paths, with the background run-end written from the job's `done` chain. +- **`workflow` joins `JobKindMap`** by declaration merging from the tool package, like `pwsh` and `pty`. + +## Alternatives considered + +- **A start/poll API on the tool itself** (a `workflow_status` companion): duplicates `job_output`/`job_kill` for one producer and leaves the run outside owner teardown and the session-header list; the job registry already is the start/poll surface. +- **Streaming intermediate values to the model** (partial results through `readOutput`): a workflow's value is the script's single return; per-agent intermediates are script-internal (`log()` narrates them for humans through the record). A consuming model cursor would invite polling loops against an inherently final-value producer. +- **Bridging `exec.signal` into the background run:** the returning tool step's abort (turn cancellation) would kill work the model just intentionally detached; bash's background route set the precedent that only the registry cancels. + +## Consequences + +The model can fire long orchestrations and keep working; humans watch phases, logs, and member lifecycles stream in the session-header job list and can stop the run there. A background run's value arrives only at settlement (`job_output` before that returns status alone). The snapshot tree pins the schema, prompt, and PTC stub changes; a recorded-session scenario replaying a full background run is deferred and tracked in the package README's limitations. diff --git a/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.zh.md b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.zh.md new file mode 100644 index 0000000000..102a398d95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.zh.md @@ -0,0 +1,31 @@ +# Agent Note:workflow 运行经任务注册表获得 `run_in_background` + +状态:已实现 + +Update:随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md),该运行注册的是 `JobSpec`(没有 `record` 标志),通过 `JobHandle.append` 与 `updateProgress` 叙述进度,并把渲染后的值作为 `JobOutcome.result` 返回,模型在结算后的第一次读取携带它一次。 + +[English](2026-09-01-workflow-run-in-background.md) | 中文 + +## 问题 + +`workflow` 调用会阻塞父级轮次直到整个脚本结算:一次长编排(扇出到数百个文件的审计)把模型按在原地耗完全部墙钟时间,模型无法继续工作,人类看不到实时进度,取消是唯一出口。其他每个长时执行面——bash、pwsh、一次性 subagent——都早已有通往 `ctx.jobs` 的 `run_in_background` 路径。[record 合并](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)删掉前台 workflow 的 activity 镜像时,也明确承诺实时 workflow 叙述会以后台 record 任务的形式回归。 + +## 决策 + +`workflow` 工具获得 `run_in_background: true`(在 `enableRunInBackground` 配置生效时公开并受理,默认开启):调用把运行注册为自有的 `kind: 'workflow'` 任务并声明观察 record,立刻返回 `{ kind: 'background', jobId, runId }`。 + +- **运行属于任务,不属于工具步骤。** 引擎运行在任务 starter 内启动且不带 `exec.signal`;取消路径是 `job_kill`、任务列表的停止控件与 owner 释放,各自把原因转发进 `run.cancel`。引擎的同步拒绝(meta/解析失败)从 starter 传播出去,因此什么都不注册,模型看到的是普通的可修正错误。 +- **结算即任务结算。** `done` 从 `run.result` 链起:先 dispose(释放失败只告警,绝不 reject 进注册表),再停掉镜像,最后映射结束原因——`completed` 在 `JobOutcome.output` 里携带与前台路径相同的渲染返回值(完成播报与 `job_output` 由此送达它),`cancelled` 结算为 `killed` 并把 detail 留给注册表的 kill 原因合并(转发的取消原因就是同一字符串),`error` 以脚本失败消息结算为 `failed`。 +- **record 镜像顶替被删的 activity 镜像。** `src/record.ts` 按插件订阅一次 `workflow/phase`、`workflow/log` 与成员生命周期事件,并把它们以 activity 镜像当年写下的同样文本行以 `log` 通道路由进被跟踪运行的 `JobHandle` 面(模型的 `job_output` 从不渲染它们);`updateProgress` 跟随当前 phase。这里没有收容错误的包装层:`append`/`updateProgress` 是不抛出的表面(结算后的 append 在注册表内丢弃),迟到的事件找不到被跟踪的运行。 +- **输出 schema 变为 `kind` 判别联合**(`background` | `foreground`),与 bash 的形态一致;前台包络为对称获得 `kind: 'foreground'`。持久的 run-start/成员/run-end 会话记录在两条路径上都保留 `exec.parent === undefined` 门槛,后台的 run-end 从任务的 `done` 链写出。 +- **`workflow` 进入 `JobKindMap`**,由工具包声明合并,与 `pwsh`、`pty` 相同。 + +## 曾考虑的替代方案 + +- **工具自带 start/poll 接口**(一个 `workflow_status` 伴生工具):为单一生产者复制 `job_output`/`job_kill`,还把运行留在 owner 释放与会话头部列表之外;任务注册表本身就是 start/poll 表面。 +- **向模型流式中间值**(经 `readOutput` 给部分结果):workflow 的值就是脚本的单一 return;每个 agent 的中间结果是脚本内部事务(`log()` 经 record 向人类叙述它们)。给天然只有终值的生产者一个消费型模型游标,只会招来轮询循环。 +- **把 `exec.signal` 桥接进后台运行:** 已返回的工具步骤的中止(轮次取消)会杀掉模型刚刚有意脱管的工作;bash 的后台路径已立下先例——只有注册表取消它。 + +## 后果 + +模型可以点燃长编排后继续工作;人类在会话头部任务列表里看着 phase、log 与成员生命周期流入,并能就地停止运行。后台运行的值只在结算时抵达(此前的 `job_output` 只返回状态)。快照树钉住 schema、提示词与 PTC 桩的变化;回放完整后台运行的 recorded-session 场景延期,记录在包 README 的限制一节。 diff --git a/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml new file mode 100644 index 0000000000..3bdaabeccf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md +2026-09-08-desktop-uninstall-preserve-dsh-home.md: 921d9681aa6a26c39e8861d2156dc868237f5b37 +2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: cb9187bb6d7c3f619dc083fcc3b36cca09494515 diff --git a/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md b/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md new file mode 100644 index 0000000000..921d9681aa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md @@ -0,0 +1,39 @@ +# Agent Note: Windows uninstall retains the Harness home + +Status: implemented + +English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md) + +## Problem + +Desktop and CLI share conversations, credentials, settings and plugins in the Harness home. Electron keeps browser storage and caches under `%APPDATA%`, and the updater keeps downloaded installers under `%LOCALAPPDATA%`; the upstream NSIS template leaves both behind unless a command-line flag is passed. Users who uninstall expect the application to leave no residue, while users who reinstall expect their conversations back. + +## Decision + +Windows uninstallation removes the Electron user-data directory, the `%APPDATA%` product directory and the updater cache together with the application, and never touches the Harness home. This is the 2026-09-08 proposal in amended form: Windows only, without the installed-artifact inventory programme, and without a user choice. No custom uninstaller page exists; silent uninstallation removes the same data. An uninstaller started with `--updated` or `/KEEP_APP_DATA` retains it, which covers electron-builder's in-place update and its replacement of an older installation from another directory, so updates keep UI preferences. A `DSH_HOME` published as a Windows environment variable protects every target overlapping it; the native helper cannot see a home configured only in a shell profile. + +Electron derives its user-data directory from the scoped package name, so the removal targets `%APPDATA%\@deepseek-ai\dsh-desktop` and afterwards removes ordinary empty parents below `%APPDATA%`, never a linked or populated scope directory. Removal runs through the native helper in `window-frame.dll`, which rejects protected Windows folders and paths overlapping the installation or the protected home, holds directory handles with listing access during traversal so the directory cannot be renamed or replaced underneath it, unlinks reparse points without entering their targets, clears the read-only attribute, and keeps deleting siblings after a locked file. It requires a fixed local drive and leaves a linked root or ancestor in place. A failure leaves residue and does not stop or report on uninstallation; the uninstaller has no custom copy. + +Installation also records `InstallLocation` on the Windows uninstall entry as standard inventory metadata. Launching the uninstaller directly from the Start menu context menu is unavailable to Win32 installers on Windows 11; only MSIX packages uninstall from there, so that request is outside this decision. + +## Alternatives considered + +**Opt-in removal on a custom uninstaller page, including the desktop profile and the whole home.** The desktop profile, sessions and credentials belong to the home shared with the CLI and stay in place for reinstallation; the remaining Electron data holds only UI preferences and caches, which does not justify a choice the user must understand, a recorded home path for the uninstaller, and a bilingual native dialog. + +**Delete the whole home when desktop is the only profile.** Profile presence does not establish whether shared data is needed by CLI or a future reinstall. + +**Leave Electron data in place, matching the upstream default.** Chromium caches grow to hundreds of megabytes and are the residue users report after uninstalling. + +**Use electron-builder's `deleteAppDataOnUninstall`.** The upstream `RMDir /r` follows no ownership rules, ignores long paths and enters linked directories; the native helper owns removal instead. + +**Establish the cleanup inventory by observing disposable accounts across install, update and uninstall.** The three roots follow from Electron's `userData` derivation and electron-builder's updater cache name, both fixed by configuration; observation would confirm what the configuration already states. + +## Deferred + +macOS cleanup (a signed helper behind an **Uninstall and keep Harness data** action, since drag-to-Trash cannot run application code) remains open under #3540 and is not decided here. + +## Consequences + +Reinstallation restores conversations, settings and plugins but not UI layout preferences. The desktop profile under the home remains with its packages and lock files. Other historical homes and user projects are not discovered or deleted. Roaming profiles on network or removable drives are left in place. + +Qualification uses isolated product identities with a per-run scoped package name. Source regressions cover template adaptation; the native remover regression covers refused roots, the protected home, rename blocking while pinned, read-only and locked files, and empty-parent removal; the uninstaller fixtures cover interactive, silent, `--updated`, `/KEEP_APP_DATA`, and a `DSH_HOME` inside the Electron directory, with a junction to an external project inside the removed data. The standard uninstaller pages render correctly at 200% scaling on the development host. diff --git a/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md b/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md new file mode 100644 index 0000000000..cb9187bb6d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md @@ -0,0 +1,39 @@ +# Agent Note: Windows 卸载保留 Harness 主目录 + +Status: implemented + +[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文 + +## 问题 + +Desktop 和 CLI 在 Harness 主目录中共享会话、凭据、设置和插件。Electron 把浏览器存储和缓存放在 `%APPDATA%` 下,更新器把下载的安装包放在 `%LOCALAPPDATA%` 下;上游 NSIS 模板在没有命令行参数时会把两者都留下。卸载的用户期望应用不留残余,重装的用户期望会话仍在。 + +## 决策 + +Windows 卸载随应用一起删除 Electron 用户数据目录、`%APPDATA%` 下的产品目录和更新器缓存,从不触碰 Harness 主目录。这是 2026-09-08 提案的修正形式:仅限 Windows,不含已安装产物清单方案,也不让用户选择。卸载程序没有自定义页面;静默卸载删除相同的数据。以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,这覆盖了 electron-builder 的原地更新和从其他目录替换旧安装两种情况,因此更新保留界面偏好。以 Windows 环境变量发布的 `DSH_HOME` 会保护所有与其重叠的目标;原生辅助程序看不到只在 shell 配置文件中设置的主目录。 + +Electron 根据带作用域的包名派生用户数据目录,因此删除目标是 `%APPDATA%\@deepseek-ai\dsh-desktop`,随后移除 `%APPDATA%` 之下普通且已空的父目录,从不移除链接或非空的作用域目录。删除通过 `window-frame.dll` 中的原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或受保护主目录重叠的路径,遍历期间以列举权限持有目录句柄,使目录无法在其下被改名或替换,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项。它要求固定的本地驱动器,链接的根目录或祖先目录会原样保留。失败只留下残余,不中止也不提示卸载;卸载程序没有任何自定义文案。 + +安装时还会在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据。Windows 11 上 Win32 安装包无法让开始菜单右键菜单中的“卸载”直接启动卸载程序,只有 MSIX 包能做到,因此该需求不在本决策范围内。 + +## 备选方案 + +**在自定义卸载页上按需删除,包括桌面配置档案和整个主目录。** 桌面配置档案、会话和凭据属于与 CLI 共享的主目录,为重装而保留;剩余的 Electron 数据只包含界面偏好和缓存,不值得让用户理解并做出选择,也不值得为卸载程序记录主目录路径并维护双语原生对话框。 + +**当桌面是唯一配置档案时删除整个主目录。** 配置档案的存在不能证明共享数据是否被 CLI 或将来的重装需要。 + +**沿用上游默认,保留 Electron 数据。** Chromium 缓存会增长到数百 MB,正是用户卸载后反馈的残余。 + +**使用 electron-builder 的 `deleteAppDataOnUninstall`。** 上游的 `RMDir /r` 不遵守归属规则、不支持长路径并会进入链接目录;改由原生辅助程序负责删除。 + +**通过在一次性账户上观察安装、更新和卸载来建立清理清单。** 三个根目录由 Electron 的 `userData` 派生规则和 electron-builder 的更新器缓存名决定,两者都由配置固定;观察只会确认配置已经说明的事实。 + +## 推迟事项 + +macOS 清理(由签名辅助程序支撑的“**卸载并保留 Harness 数据**”操作,因为拖到废纸篓无法运行应用代码)仍在 #3540 下开放,本决策不涉及。 + +## 结果 + +重装恢复会话、设置和插件,但不恢复界面布局偏好。主目录下的桌面配置档案及其包和锁文件保留。其他历史主目录和用户项目不会被发现或删除。位于网络或可移动驱动器上的漫游配置文件原样保留。 + +验收使用每次运行唯一的带作用域包名的隔离产品身份。源码回归覆盖模板适配;原生删除器回归覆盖被拒绝的根目录、受保护的主目录、持有期间阻止改名、只读与被占用文件,以及空父目录移除;卸载夹具覆盖交互、静默、`--updated`、`/KEEP_APP_DATA`,以及位于 Electron 目录内的 `DSH_HOME`,被删除的数据内含指向外部项目的 junction。标准卸载页面在开发机 200% 缩放下渲染正确。 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index f279a70f08..c52f80c6a1 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 5631906931122e45d4381a570c095203655b6435 -2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8 +2026-09-16-browser-excel-preview.md: 208bcdcd69d8ac30fcb5a7776271942424a92a05 +2026-09-16-browser-excel-preview.zh.md: c48d9156e44befaa265d5c1f4116d86efe099fda diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 5631906931..208bcdcd69 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -16,7 +16,7 @@ Preview is read-only and retains formula text alongside saved results. It does n A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. -The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry. +The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@fortune-sheet__react@1.0.4.patch) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under the shipped React 18.3.1 runtime. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry. The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with ` --dump-config-schema` projects native Schemastery Config declarations into JSON Schema without mounting plugins. Native validation mutates its input, evaluates `!!js` expressions, and runs transform callbacks; a static document cannot reproduce all of that. Three choices were contested in review and would be reopened without a record: what `complete` and the exit code mean when part of the projection is approximate, which rows the collector inspects, and how the projection treats native behavior it cannot model. + +## Decision + +- **Partial projection exits 1.** `x-cordis.complete` is false and the CLI exits 1 for any error diagnostic, any `partial`, `unsupported`, or `error` entry, or one plugin name resolving to several Config definitions, even when stdout holds a valid and useful schema. The exit code answers whether the document can replace native validation, not whether output was produced. Callers that only need the schema read stdout and ignore the exit code; the diagnostics under `x-cordis` name each limitation at every entry it affects. +- **Discovery is declaration-oriented and includes disabled rows.** The collector walks every row the Loader would receive, including rows disabled literally or by expression, and imports their modules. A disabled row's import or include failure therefore makes a bootable profile incomplete. The one exception follows the Loader: a disabled group or include without `group: true` never creates its children, so a missing carrier config is recorded as a childless tree and the entry schema does not validate its child declarations. +- **Widen instead of simulating native mutation.** Where native resolution can alter the input before later validation reads it (adapted values written back by a container, renamed dictionary keys, loose fallbacks, lazy metadata propagation into shared nodes), the projection keeps the available declaration detail beside an unrestricted alternative and records a limitation. It never emulates the mutation. Widening is scoped to the mechanism: only containers write adapted values back, so a bare primitive transform as an earlier union branch keeps `anyOf`. + +The [CLI reference](../../../../apps/cli/reference/README.md#config-schema-dump) documents the resulting behavior; [app-boot](../../../../packages/boot/app-boot/README.md) owns the collector API. + +## Alternatives considered + +**Exit 0 with a valid document and warnings only.** Rejected: a consumer feeding the schema to an editor or agent would treat an approximate document as authoritative. A nonzero exit forces the caller to decide explicitly. + +**Skip disabled rows during discovery.** Rejected: a disabled row is a declaration the user is likely to re-enable, and its schema, id target, and import failures are what an editing agent needs. Skipping would also change `configRef` targets whenever `disabled` toggles. + +**A relaxed child-list variant for ancestor-disabled children.** Rejected for now: it needs a parallel `entryList` and `entry` rule set with requiredness removed. Children under a disabled `group: true` row and patch insertions into a disabled group are validated as enabled, and the reference says so. + +**Emulating native mutation in the projection.** Rejected: transform callbacks are plugin code the dump must not execute, and reproducing writeback order would couple the projector to Schemastery internals. A widening with a limitation is cheaper to maintain and cannot reject inputs that native validation accepts. + +## Consequences + +A shipped profile whose rows use an unsupported carrier reports `complete: false` and exits 1 while still emitting usable schemas, so consumers must read `x-cordis.diagnostics` to distinguish an approximate document from an unusable one. Every widening is paired with a limitation diagnostic, and generated schemas validate the `--dump-config` output of every shipped profile. diff --git a/.agents/notes/implemented/feature/2026-09-20-config-schema-dump-validation-policy.zh.md b/.agents/notes/implemented/feature/2026-09-20-config-schema-dump-validation-policy.zh.md new file mode 100644 index 0000000000..d94f040057 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-config-schema-dump-validation-policy.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 配置 schema dump 的验证策略 + +Status: implemented + +[English](2026-09-20-config-schema-dump-validation-policy.md) | 中文 + +## Problem + +`dsh --profile --dump-config-schema` 在不挂载插件的情况下,把原生 Schemastery Config 声明投影为 JSON Schema。原生验证会修改输入、求值 `!!js` 表达式并执行 transform 回调;静态文档无法完整重现这些行为。评审中有三项选择存在争议,若不记录会被反复重提:投影部分近似时 `complete` 和退出码的含义、收集器检查哪些行,以及投影如何处理无法建模的原生行为。 + +## Decision + +- **部分投影以 1 退出。** 只要存在错误诊断、任何 `partial`、`unsupported` 或 `error` 条目,或同一插件名解析出多个 Config 定义,`x-cordis.complete` 即为 false 且 CLI 以 1 退出,即使 stdout 已包含有效且有用的 schema。退出码回答的是该文档能否替代原生验证,而不是是否产生了输出。只需要 schema 的调用方读取 stdout 并忽略退出码;`x-cordis` 下的诊断会在受影响的每个条目上说明各项限制。 +- **发现面向声明,并包含禁用行。** 收集器遍历 Loader 将接收的每一行,包括字面量禁用或表达式禁用的行,并导入它们的模块。因此禁用行的导入或 include 失败会使可启动的 profile 被标为不完整。唯一的例外遵循 Loader:未设置 `group: true` 的禁用 group 或 include 从不创建其子项,所以缺失的承载 config 记录为没有子项的树,entry schema 也不验证其子声明。 +- **放宽而不是模拟原生修改。** 当原生解析可能在后续验证读取前改变输入(容器写回适配值、字典键改名、loose 回退、lazy 元数据传播到共享节点)时,投影保留可获得的声明细节,同时并列一个不受限制的备选项,并记录一条限制。它绝不模拟该修改。放宽的范围限定于该机制:只有容器会写回适配值,因此作为前序 union 分支的裸原始类型 transform 保留 `anyOf`。 + +[CLI 参考](../../../../apps/cli/reference/README.zh.md#config-schema-dump)记录了由此产生的行为;[app-boot](../../../../packages/boot/app-boot/README.zh.md) 负责收集器 API。 + +## Alternatives considered + +**输出有效文档并仅给出警告,以 0 退出。** 被否决:把 schema 交给编辑器或 agent 的消费者会把近似文档当作权威。非零退出迫使调用方显式决定。 + +**发现时跳过禁用行。** 被否决:禁用行是用户很可能重新启用的声明,其 schema、id 目标和导入失败正是编辑 agent 所需。跳过还会导致 `configRef` 目标随 `disabled` 切换而变化。 + +**为祖先被禁用的子项提供放宽的子列表变体。** 暂被否决:它需要一套去掉必填约束的并行 `entryList` 和 `entry` 规则。禁用的 `group: true` 行之下的子项以及插入到禁用 group 的 patch 按启用状态验证,参考文档已如此说明。 + +**在投影中模拟原生修改。** 被否决:transform 回调是 dump 不得执行的插件代码,而重现写回顺序会把投影器与 Schemastery 内部实现耦合。带限制说明的放宽维护成本更低,也不会拒绝原生验证接受的输入。 + +## Consequences + +使用不受支持承载插件的已发布 profile 会报告 `complete: false` 并以 1 退出,同时仍输出可用的 schema,因此消费者必须读取 `x-cordis.diagnostics` 来区分近似文档与不可用文档。每次放宽都配有一条限制诊断,生成的 schema 能验证每个已发布 profile 的 `--dump-config` 输出。 diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml index d05cd07741..1293c824d8 100644 --- a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md -2026-09-16-desktop-release-version-derivation.md: 0f1d917cb0d382453fbb0dca561a1eae7d530d67 -2026-09-16-desktop-release-version-derivation.zh.md: e9576e30232b5eb247de047cb67f51db5fa43ae3 +2026-09-16-desktop-release-version-derivation.md: 54a7b9d52c63268f15209ad0a84bcd8da035dcee +2026-09-16-desktop-release-version-derivation.zh.md: dddf4b773217965380c4487803971311b57e0863 diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md index 0f1d917cb0..54a7b9d52c 100644 --- a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md +++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md @@ -10,11 +10,11 @@ Replacing a dsh prerelease identifier with the update channel name loses the bas ## Decision -The [Desktop release rules](../../../../apps/desktop/README.md#release-versions) preserve the complete dsh base for production and derive dated, indexed test versions from that base. Final release-family package versions remain equal. The base is retained separately before manifests are retagged, so another test release cannot append a second date suffix. +The [Desktop release rules](../../../../apps/desktop/README.md#release-versions) preserve the complete dsh base for production and derive dated, indexed test versions from that base. The derived version is [passed to packaging as an argument](2026-09-21-desktop-build-version-as-input.md) rather than written into manifests, and it is derived from the dsh base so another test release cannot append a second date suffix. The fixed Nightly feed is a distribution address, independent of version derivation. Existing clients continue to use that address with prerelease updates enabled and downgrades disabled. Test distribution does not publish the unsuffixed base. Operators check existing release records and objects before assigning an index. -The packaging decision retains runtime/version equality; the installed-update materials decision retains private identities and data isolation. Their Nightly naming examples do not define the test version policy. Historical release receipts and frozen archived records remain evidence of what was actually built. +The installed-update materials decision retains private identities and data isolation. Its Nightly naming examples do not define the test version policy. Historical release receipts and frozen archived records remain evidence of what was actually built. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md index e9576e3023..dddf4b7732 100644 --- a/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md +++ b/.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)在 production 中保留完整 dsh 基础版本,并从该基础版本派生带日期和序号的测试版本。最终发布家族包版本保持一致。修改清单版本前单独记录基础版本,避免下一次测试发布追加第二个日期后缀。 +[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)在 production 中保留完整 dsh 基础版本,并从该基础版本派生带日期和序号的测试版本。派生出的版本[作为参数传给打包](2026-09-21-desktop-build-version-as-input.zh.md),不写入清单;它从 dsh 基础版本派生,因此下一次测试发布不会追加第二个日期后缀。 固定 Nightly feed 是分发地址,与版本派生独立。现有客户端继续使用该地址,允许预发布更新并禁止降级。test 分发不发布无后缀基础版本。操作者分配序号前检查已有发布记录和对象。 -打包决策继续规定运行时与包版本一致性;安装版更新物料决策继续规定私有身份和数据隔离。其中的 Nightly 命名示例不定义测试版本规则。历史发布回执和已冻结归档保留为实际构建的证据。 +安装版更新物料决策继续规定私有身份和数据隔离。其中的 Nightly 命名示例不定义测试版本规则。历史发布回执和已冻结归档保留为实际构建的证据。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.i18n.yaml b/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.i18n.yaml new file mode 100644 index 0000000000..a5fb8f7ea7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.md +2026-09-21-desktop-build-version-as-input.md: 8653c1ed580ac360e0c2f5145781303f15fc29b2 +2026-09-21-desktop-build-version-as-input.zh.md: 0a995ab5740a73de00b2a417d07640b3c07e4690 diff --git a/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.md b/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.md new file mode 100644 index 0000000000..8653c1ed58 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.md @@ -0,0 +1,37 @@ +# Agent Note: Pass the Desktop build version as an argument instead of rewriting manifests + +Status: implemented + +English | [中文](2026-09-21-desktop-build-version-as-input.zh.md) + +## Problem + +[Deriving Desktop test versions](2026-09-16-desktop-release-version-derivation.md) fixed what a test build is called but not where that name lives. Publishing one meant running `release:dsh` to rewrite the version in every release-family manifest and the lockfile — 295 files — because packaging, the update feed, and upload validation each read the version from the checkout. Those edits are never committed, so a test build left the working tree dirty until someone reverted it, and a reverted tree no longer describes the artifacts that were published. The index in `.YYYYMMDD.index` was also chosen by hand against the records and the bucket, which is a lookup a script can do. + +Two more facts about a build were unrecoverable afterwards. A build handed to a colleague or pushed to a test feed is reachable from no tag, and the build tree it came from is not a checkout, so nothing connected an installer to its sources. A production release was likewise recorded only in the bucket. + +## Decision + +The version a build publishes is an argument. `--build-version` names it, `--build-version auto` proposes the next index for the day, and the value reaches electron-builder through `extraMetadata`, the update feed, and the upload validation as one input. Manifests keep the product version, so no packaging run modifies tracked files. + +The version rules in the [Desktop release rules](../../../../apps/desktop/README.md#release-versions) are unchanged: production publishes the dsh base, a prerelease base takes `.YYYYMMDD.index`, and a stable base takes `-test.YYYYMMDD.index`. Validation uses `semver` itself, because `electron-updater` compares feed versions with `semver.gt` against the installed `app.getVersion()`. + +This supersedes two requirements of that note. Release-family manifests are no longer rewritten, and the shell version no longer equals the bundled runtime version: the runtime is the dsh package at the product version, and `verifyDesktopRuntime` receives the version whoever prepared that tree wrote, which installed-update qualification rewrites for its own materials. The application already reported the two separately to the mandatory update policy. + +Every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest. A production upload tags the packaged commit as `desktop-v` after the artifacts are public, and reports the command to run by hand rather than failing an upload that already completed; a build from a modified checkout is not tagged. Test and local builds are deliberately left untagged, because a tag per test build would bury the releases. + +Upload reads the published version from the completion record packaging wrote, not from a variable, so [release fields still come only from the target file](../../../../apps/desktop/README.md#release-versions). + +## Alternatives considered + +**Keep rewriting manifests and revert afterwards.** The dirty tree is the defect, and a revert races anything else reading the workspace. It also leaves the published artifacts describing a state the repository no longer has. + +**Rewrite only the packed runtime's manifest in a temporary copy.** The bundled dsh would then claim a version that does not exist on npm, and the copy would have to be kept consistent with the lockfile for no gain. + +**Number every build automatically without an argument.** Operators confirm the version with the user before packaging. Automatic numbering is offered as `auto`, which prints what it chose, rather than as the default. + +**Tag every build.** Test feeds receive builds continuously; tagging each one would make `desktop-v*` useless for finding releases. Artifacts carry their commit instead. + +## Consequences + +A packaging run leaves the repository unchanged, so the tree that built an artifact is the tree the tag or the recorded commit names. `auto` contacts the destination bucket, including under `--check`, and falls back to the local output directory when no bucket is configured or the listing cannot complete within its deadline; a partial listing is discarded rather than risking a reused index that would overwrite a published installer. Uploading a production release now writes to the repository, which earlier release automation deliberately avoided; the write is one tag, performed by the operator's own command after the upload succeeds. diff --git a/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.zh.md b/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.zh.md new file mode 100644 index 0000000000..0a995ab574 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Desktop 发布版本改为命令参数,不再改写清单 + +Status: implemented + +[English](2026-09-21-desktop-build-version-as-input.md) | 中文 + +## 问题 + +[从完整 dsh 版本派生 Desktop 测试版本](2026-09-16-desktop-release-version-derivation.zh.md)确定了测试构建叫什么,但没有确定这个名字存在哪里。发布一次测试构建要先运行 `release:dsh`,把版本写进所有发布家族清单和锁文件——295 个文件——因为打包、更新 feed 和上传校验都各自从工作区读取版本。这些改动从不提交,所以每次测试构建都会留下脏工作区直到有人还原,而还原后的工作区又不再描述已经发布出去的产物。`.YYYYMMDD.index` 里的序号同样靠人对着发布记录和 bucket 挑选,而这是脚本可以完成的查询。 + +另有两件事在构建之后无法还原。交给同事或推到测试 feed 的构建没有任何标签可查,它来自的构建树也不是工作区,因此没有任何东西把安装包和源码连起来。production 发布同样只在 bucket 里留下痕迹。 + +## 决策 + +构建发布的版本是一个参数。`--build-version` 指定它,`--build-version auto` 给出当天的下一个序号,该值经 `extraMetadata` 进入 electron-builder,并作为同一个输入贯穿更新 feed 与上传校验。清单保留产品版本,因此任何打包运行都不修改被跟踪的文件。 + +[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)中的版本规则不变:production 发布 dsh 基础版本,预发布基础版本追加 `.YYYYMMDD.index`,稳定基础版本追加 `-test.YYYYMMDD.index`。校验直接使用 `semver`,因为 `electron-updater` 就是用 `semver.gt` 把 feed 版本与已安装的 `app.getVersion()` 比较。 + +本决策取代上述 Note 的两条要求。发布家族清单不再被改写;外壳版本也不再等于内置运行时版本:内置运行时就是产品版本的 dsh 包,`verifyDesktopRuntime` 接收准备该运行时树的一方写入的版本,而安装升级验证会为自己的材料改写这个版本。应用本来就把两者分别上报给强制更新策略。 + +所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`。production 上传在产物公开后把打包所用 commit 打成 `desktop-v<版本>` 标签,失败时只打印需要手工执行的命令,而不让已完成的上传变成失败;来自有改动工作区的构建不打标签。test 与本地构建有意不留标签,因为为每个测试构建打标签会淹没真正的发布。 + +上传从打包写下的完成记录中读取发布版本,而不是从变量读取,因此[发布字段仍然只来自目标文件](../../../../apps/desktop/README.zh.md#release-versions)。 + +## 考虑过的替代方案 + +**继续改写清单,事后还原。** 脏工作区本身就是缺陷,而还原会与任何同时读取工作区的操作竞争,也会让已发布产物所描述的状态在仓库中不复存在。 + +**只在临时副本里改写打包运行时的清单。** 内置 dsh 会因此宣称一个 npm 上不存在的版本,副本还得与锁文件保持一致,没有任何收益。 + +**不要参数,全部自动编号。** 操作者需要在打包前与用户确认版本。自动编号以 `auto` 提供并打印所选版本,而不是作为默认行为。 + +**为每个构建打标签。** 测试 feed 持续接收构建,逐个打标签会让 `desktop-v*` 无法再用于查找发布。改由产物自身携带 commit。 + +## 影响 + +打包运行不再改动仓库,因此构建产物的代码树就是标签或记录的 commit 所指的那棵树。`auto` 会访问目标 bucket,`--check` 下同样如此;未配置 bucket 或列举未能在期限内完成时回退到本地输出目录,不完整的列举结果会被丢弃,以免复用序号覆盖已发布的安装包。production 上传现在会写入仓库,而早先的发布自动化刻意避免这一点;这次写入只是一个标签,由操作者自己的命令在上传成功后执行。 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml deleted file mode 100644 index cd64ee4423..0000000000 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md -2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7 -2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571 diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md deleted file mode 100644 index e1c2435814..0000000000 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md +++ /dev/null @@ -1,95 +0,0 @@ -# Agent Note: Uninstall Desktop while preserving the Harness home - -Status: proposed - -English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md) - -## Problem - -Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage. - -The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory. - -## Proposal - -Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup. - -Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup. - -User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history. - -### 1. Establish ownership and actual paths - -Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths. - -| Candidate | Windows | macOS | Required treatment | -|---|---|---|---| -| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. | -| Installer/update cache | `%LOCALAPPDATA%/` | `~/Library/Caches/` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. | -| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. | -| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. | -| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. | -| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. | - -Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts. - -### 2. Implement shared preservation and cleanup rules - -Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location. - -Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview. - -Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup. - -Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable. - -### 3. Windows implementation - -Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup. - -Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`. - -Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result. - -Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged. - -### 4. macOS implementation - -Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied. - -Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification. - -Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned. - -### 5. Verification and documentation - -Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests. - -Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed. - -Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests. - -## Alternatives considered - -**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome. - -**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here. - -**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation. - -**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations. - -## Acceptance criteria - -- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains. -- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact. -- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data. -- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash. -- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result. -- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion. - -## Risks - -Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention. - -OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note. diff --git a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md b/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md deleted file mode 100644 index 27fd7c8cbe..0000000000 --- a/.agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md +++ /dev/null @@ -1,95 +0,0 @@ -# Agent Note: 卸载 Desktop 并保留 Harness 主目录 - -Status: proposed - -[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文 - -## 问题 - -用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。 - -[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。 - -## 提案 - -提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。 - -Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。 - -用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。 - -### 1. 确定归属和实际路径 - -使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。 - -| 候选项 | Windows | macOS | 处理要求 | -|---|---|---|---| -| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 | -| 安装器及更新缓存 | `%LOCALAPPDATA%/` | `~/Library/Caches/` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 | -| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 | -| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 | -| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 | -| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 | - -建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。 - -### 2. 实现共用的保留与清理规则 - -按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。 - -拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。 - -阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。 - -不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。 - -### 3. Windows 实现 - -保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。 - -正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。 - -按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。 - -由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。 - -### 4. macOS 实现 - -在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。 - -使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。 - -把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。 - -### 5. 验证和文档 - -增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。 - -在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。 - -为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。 - -## 考虑过的替代方案 - -**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。 - -**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。 - -**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。 - -**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。 - -## 验收标准 - -- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。 -- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。 -- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。 -- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。 -- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。 -- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。 - -## 风险 - -删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。 - -操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6cd790f7c5..e0e5b174eb 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,6 +38,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT | | [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 | | [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | MIT | | [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | @@ -71,6 +72,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT | | [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause | | [`acorn`](https://github.com/acornjs/acorn) | MIT | +| [`ajv`](https://github.com/ajv-validator/ajv) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 026b49d50b..7e395f72d5 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 46c6a66336839db443a825c66ebfd71dcce398af -README.zh.md: 14838114b04977e7668e33479d1ba84a8ddc8434 +README.md: 4eb113a6155433cb69f949d9e461ccfbd0142e7c +README.zh.md: 6a0e8a38a70d2ad551b4e91619545b3def09f2ec diff --git a/apps/cli/README.md b/apps/cli/README.md index 46c6a66336..4eb113a615 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -43,7 +43,7 @@ The tree composes over an empty root: Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. -Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. +Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. `--dump-config-schema` imports the composed tree's declared plugin schemas and prints JSON Schema for entries and patches instead of configuration values; read the [schema-dump safety and scope](reference/README.md#config-schema-dump) before inspecting untrusted plugins. The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. The [startup and reload failure table](../../packages/boot/app-boot/README.md#startup-and-reload-failures) compares optional and required plugin failures with configuration HMR. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 14838114b0..6a0e8a38a7 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -43,7 +43,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 `dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。 -使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 +使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。`--dump-config-schema` 会导入组合树中插件声明的 schema,并打印描述 entry 与 patch 的 JSON Schema,而不是配置值;检查不受信任的插件前,请阅读 [schema dump 的安全性与范围](reference/README.zh.md#config-schema-dump)。 层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。[启动与重载失败表](../../packages/boot/app-boot/README.zh.md#startup-and-reload-failures)对比 optional、required 插件启动失败与配置 HMR 的行为。 diff --git a/apps/cli/package.json b/apps/cli/package.json index e355fd822a..0c9cea1db0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -144,6 +144,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@types/js-yaml": "^4.0.9", "@types/ws": "8.18.1", + "ajv": "^8.20.0", "execa": "^10.0.0", "ws": "8.21.0" }, diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index cb0eddf183..3d7319ec04 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 33465e8c96df9cbfbecc038616caf297146ec971 -README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d +README.md: 094d8ba5bfaa851429adbfa872105550e8b63fe2 +README.zh.md: 2bd6abde1cb854ec9598f8f7cc10cc0a7538d2f9 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 33465e8c96..094d8ba5bf 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -14,7 +14,7 @@ The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize `dsh --profile --from-default-profile