Merge remote-tracking branch 'origin/master' into worktree/fix-1463-rich-content-bridge

This commit is contained in:
Tianyi Cui
2026-08-17 13:56:17 +08:00
106 changed files with 2593 additions and 559 deletions

View File

@@ -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-07-14-provider-routed-llm-adapters.md
2026-07-14-provider-routed-llm-adapters.md: e1eaf52f21481a7c65e85effb7607b16f9b0ffdd
2026-07-14-provider-routed-llm-adapters.zh.md: 4e620408dabffc8368293635613afb5778b6e822
2026-07-14-provider-routed-llm-adapters.md: 78c8d6788006c503b532ff2bbddd30342415f0a4
2026-07-14-provider-routed-llm-adapters.zh.md: 5e73cab5f1f2b1296c9a486d1c833e95bb5674a0

View File

@@ -40,9 +40,9 @@ pi-ai's common stream options do not expose stop sequences. `dsh-llm-pi-ai` reje
Assistant messages carry the request's `provider` and `model`, plus an optional JSON-serializable adapter replay state. A successful `assistant/message` session event records those fields and `deriveMessages()` returns them with the assistant message. User, system, context, and tool-result messages carry no assistant route fields. The provider/model fields are authoritative loop data; an adapter owns only its opaque replay-state payload.
A terminal successful `finish` chunk may carry replay state, and `BlockAssembler` retains it alongside usage and finish reason. The loop attaches that state to the assembled assistant message's model source without exposing a response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history.
A terminal successful `finish` chunk may carry replay state as a `ReplayEnvelope`: opaque response-level metadata plus optional per-block entries aligned with the emitted block sequence. `BlockAssembler` makes one keep/drop decision for content and metadata — when max-token assembly drops a tool call, the envelope loses the entry at the same position — so the state the loop attaches to the assembled assistant message's model source always describes the stored blocks, per the [max-token replay-state alignment decision](../bug-fix/2026-08-15-max-token-replay-state-alignment.md). The loop exposes no response-rewrite hook. Error and aborted responses do not produce a normal assistant message and therefore do not enter future model history.
The pi-ai replay state is a versioned, minimal projection of its successful `AssistantMessage`: source API/provider/model, response id/model, stop reason, and index-aligned text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmRuntime` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. An adapter receiving replay state with an unknown version or mismatched block shape fails explicitly; a different adapter receives only provider-neutral content plus provider/model fields.
The pi-ai replay state fills that envelope with a versioned, minimal projection of its successful `AssistantMessage`: a response half (source API/provider/model, response id/model, stop reason) and per-block text, thinking, and tool-call signatures. It does not duplicate text or tool arguments already carried by Harness content blocks, and it omits diagnostics, timestamps, usage, and errors. On a later request, `LlmRuntime` gives replay state to the target adapter only when the historical provider and target provider are currently owned by the same adapter instance. That adapter combines the logged Harness content with replay state when it can restore the historical response, and owns any required cross-model or cross-provider conversion. Durable content stays authoritative: an adapter receiving replay state it cannot use — an unknown kind or version, malformed metadata, or a block shape that no longer matches the content — degrades that message to provider-neutral conversion with a diagnostic; a different adapter receives only provider-neutral content plus provider/model fields.
This state is model-visible replay input and therefore follows the existing [reconstructable-request rule](2026-07-05-reconstructable-requests.md): it is present in both the terminal `finish` chunk and the assembled `assistant/message` model source that drives derivation. Resume and fork preserve it verbatim. Compaction that shadows the assistant message also removes its replay state from the active surface; the summary is ordinary provider-neutral content.

View File

@@ -40,9 +40,9 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定
助手消息携带请求的 `provider``model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。
成功的终止 `finish` 分片可以携带回放状态,`BlockAssembler` 会将其与 token 用量和结束原因一起保留。agent loop 会把该状态附加到已组装助手消息模型来源中,但不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
成功的终止 `finish` 分片可以`ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。
pi-ai 回放状态其成功 `AssistantMessage` 的带版本最小投影,包含源 API/提供方/模型、响应 ID/模型、停止原因,以及按索引对齐的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。适配器收到未知版本或块形状不匹配的回放状态时会显式失败;其他适配器只能收到提供方无关的内容以及提供方/模型字段。
pi-ai 回放状态其成功 `AssistantMessage` 的带版本最小投影填充该结构:一个响应半区(源 API/提供方/模型、响应 ID/模型、停止原因,以及逐块的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。持久化内容保持权威:适配器收到无法使用的回放状态——未知 kind 或版本、格式错误的元数据、或与内容不再匹配的块结构——会把该消息降级为提供方无关转换并带出诊断;其他适配器只能收到提供方无关的内容以及提供方/模型字段。
该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩compaction遮蔽助手消息时也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。

View File

@@ -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-07-30-config-plane-boundaries.md
2026-07-30-config-plane-boundaries.md: 2dfb527749e8dea54e037abc602af1611c3f9794
2026-07-30-config-plane-boundaries.zh.md: 8b1ec3206237b7c2deafb743b62e697b3c452ffc
2026-07-30-config-plane-boundaries.md: aab0d7a80c2732b33637ec721a9ab7c1b50fce3f
2026-07-30-config-plane-boundaries.zh.md: fb069e6dd9f276c7cc0069695b388ef37a081ab4

View File

@@ -6,6 +6,8 @@ English | [中文](2026-07-30-config-plane-boundaries.zh.md)
> Scope: boundary hardening of the [web configuration plane](2026-07-30-web-config-plane.md) — which namespaces reach the wire, which callers reach them, and how an editor holding a partial, possibly stale view writes without destroying what it cannot see.
> The caller boundary, the redaction, and the revision fencing remain current. Restricting which namespaces reach the wire to the configurable-provider directory is superseded by the [plugin-owned settings surface](2026-08-12-plugin-owned-settings-surface.md), which serves every registered namespace.
## Problem
The plane worked and was reachable by more callers, and with more authority, than its design claimed.

View File

@@ -6,6 +6,8 @@ Status: implemented
> 范围:对 [Web 配置面](2026-07-30-web-config-plane.md)的边界加固——哪些 namespace 能抵达协议、哪些调用方能抵达它们,以及一个只持有局部且可能陈旧的视图的编辑器该如何写入,才不会毁掉它看不见的东西。
> 调用方边界、脱敏与 revision 设栅依然有效。把「哪些 namespace 能抵达协议」限制为可配置提供方目录这一条,已被[由插件自己拥有的设置表层](2026-08-12-plugin-owned-settings-surface.md)取代——后者服务每一个已注册的 namespace。
## 问题
这个面能用,但能触达它的调用方、以及它们所拥有的权限,都比设计声称的更多。

View File

@@ -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-08-12-plugin-owned-settings-surface.md
2026-08-12-plugin-owned-settings-surface.md: 3137cfe81ef3cb78a940f085c559ab4a7b62cce3
2026-08-12-plugin-owned-settings-surface.zh.md: 8dd5e5ccebf1cfb80b55a615f6049dd391943bd7

View File

@@ -0,0 +1,61 @@
# Agent Note: Plugin-owned settings surface
Status: implemented
English | [中文](2026-08-12-plugin-owned-settings-surface.zh.md)
## Problem
A plugin that registered a settings namespace could not reach the browser configuration page, and both gates that stopped it lived in this repository.
`packages/host/apiproxy` held two hardcoded namespace lists. `settings.describe` filtered its answer through them and every write checked them first, so a namespace outside them answered `settings-not-exposed` even when its owner had registered it. Adding a plugin to the configuration page therefore meant editing a package the plugin author does not own.
The plugin configuration section rendered an unordered list of whatever cards were registered into `settings.plugin.item`. A card carried an opaque `id`, never the namespace it edited, so the section could not tell which served namespaces already had a home. That left every question about "who renders this namespace" unanswerable from the ledger the section could see.
Together the two meant a user-authored plugin was configurable only by hand-editing `settings.yaml`. The [web plugin configuration note](../feature/2026-08-10-web-plugin-configuration.md) recorded the allowlist as deliberate, and the [config-plane boundaries note](2026-07-30-config-plane-boundaries.md) tied web-configurability to membership in the configurable-provider directory. Both conclusions blocked exactly the plugin authors the general seam was built for.
## Decision
**Registering is exposing.** The api-proxy serves every namespace `ctx.settings.describe()` returns and gates no write. `WEB_SETTINGS_NAMESPACES`, `PRODUCT_SETTINGS_NAMESPACES`, the union with `ctx.llm.listConfigurableProviders()`, and the `settings-not-exposed` error code are gone. A name no registration answers — unknown, or malformed and therefore unable to address one — folds into the seam's own `settings-rejected`, so the proxy contributes no boundary and no vocabulary of its own.
**The settings seam is untouched.** Which client may read a namespace, and which page renders it, are facts about consumers; a Service Definition that carried either would let one Consumer dictate its contract. `SettingsRegisterOptions` gains nothing.
**`settings.plugin.item` is keyed on the settings namespace.** The slot moved from `list` to `keyed`, the key being the namespace the card edits, following the `tool.call.toolview` precedent where each tool plugin registers its renderer under the tool name. A card declares `key`, not `id`/`order`. The slot is declared by the Plugins section's `configurable` tab, which owns the card list.
**The tab drives dispatch from the served namespaces.** It reads `settings.describe` once, subscribes to the settings-document invalidation and to connection resets, and dispatches one key per served namespace. What renders is the intersection of two ledgers — namespaces a live Host plugin registered, and cards registered under those keys — computed in the tab's controller from the slot ledger (`ctx.slots.entries`, `ctx.slots.subscribe`) and the wire answer.
Keying makes absence the signal, and that is what removes the bookkeeping the previous shape needed. A namespace another surface owns (`ui-theme`, `permission`, `llm-*`, `agent-presets`) has no card under its key, so it renders nothing without declaring anything anywhere. A card whose namespace this deployment does not serve is never dispatched, which also fixes the old empty-state defect: the tab counted registered cards, including ones rendering nothing, so a deployment exposing none showed an empty list instead of its empty line.
**Nothing renders a form it was not given.** The tab supplies no fallback card. A plugin's browser half owns its card completely — chrome, controls, and copy — which is what the slot's `fallback` option would have replaced with a schema-reverse-rendered form.
## What the allowlist protected
The gate did keep one thing off the wire, and this note states it plainly because the decision has to survive the accurate version: a registered namespace the list did not name never had its resolved, `base`, or `user` values reach the browser at all. The plugin inventory page is not a substitute — `PluginInventoryEntry` carries `entryId`, `moduleName`, `enabled`, and `fiberPhase`, and its "configuration" row renders an enabled/disabled tag, never a stored value.
What the gate was not is the boundary its position suggested. Every `settings.*` method sits in `PRIVILEGED_METHODS` (`packages/client/connection`), so a non-loopback or cross-origin request is refused with 403 before reaching this code; `role('secret')` fields are structurally stripped from every layer of every response; and the document the plane edits is the user's own `settings.yaml`, which the same settings page offers to open. The writes it did not block were also the consequential ones: `permission` (which can widen the approval preset) and `agent-presets` (which decides what a session mounts) were both already served.
So the exposure this change actually adds, in this repository, is one namespace: `agent-default-model`, whose two fields name a provider and a model and which no browser half renders. A future namespace whose values genuinely must not cross the wire is answered per field by `role('secret')` — finer than a namespace switch, and already enforced.
## Alternatives considered
**A declaration on `settings.register()`** (`client: { surface: 'plugin-config' | 'custom', title, description }`), which the removed `WEB_SETTINGS_NAMESPACES` comment named as the intended direction. It keeps registration from crossing the transport by default and lets a plugin author self-serve in one line. Rejected because `surface` is browser-page vocabulary and `title`/`description` are presentation: a Service Definition carrying them is a seam shaped by one Consumer. Its fail-closed property is also worth less than it reads — see what the allowlist protected, above.
**A separate exposure catalog**, a registry of its own that plugins join beside their settings registration, generalizing `ctx.llm.registerConfigurableProviders()`. Rejected because it makes one fact require two registrations that can drift: registering a namespace and forgetting the catalog entry produces a section nothing can edit, with no gate able to see the mistake.
**A deny-list `Config` field on the api-proxy**, so a deployment could withhold a namespace. Rejected for having no consumer: every currently registered namespace is one a user may edit, and a genuinely sensitive field is answered per-field by `role('secret')`, which is the finer instrument. A namespace-wide switch invented ahead of its first use is the speculative option the package rules forbid.
**A schema-driven generic card as the slot's `fallback`**, so a plugin with no browser half still got a form from `schema.toJSON()` (schemastery already carries `description`, `role`, `min`/`max`/`step` and serializes them). Rejected because client plugins load at runtime from mounted Loader entries, so a plugin author can ship a real card, and a reverse-rendered form was already judged worse than a hand-written one for the Models page. The `fallback` option remains available without a contract change if that judgment changes.
**A client-side claim registry**, where each surface owning a namespace declares it so a generic card knows what is already covered. Rejected with the generic card: keyed dispatch already makes an unclaimed key render nothing, so the registry would restate what the slot ledger says.
**Keeping the list slot and adding a namespace field to its options.** Rejected because the section would still enumerate entries rather than namespaces, keeping the empty-state defect and leaving a card for an uncomposed plugin to suppress itself.
## Consequences
A plugin distributed outside this repository is configurable from the settings page with no change here: it registers its namespace on the Host and its card under that key in the browser, and the section pairs the two. Cards now appear in card registration order rather than by hand-assigned `order`. That is stable for the cards this package registers, which install from one generator, and **not** stable across plugins: apply order between packages is unconstrained (`packages/client/AGENTS.md`), so several external cards can still reorder between boots. Ordering them needs an explicit key the section can sort on, which the keyed registration does not carry today.
Deferred, and larger than this change: the redactor returns a `role('secret')` reachable only through a union, intersection, or transform verbatim (its own `TODO(settings-wire-redaction)`), and `schema.toJSON()` carries a secret's default. That gap predates this change, but serving every registered namespace widens its blast radius from schemas audited in this repository to any third-party schema, so the wire should refuse a namespace it cannot prove it can redact. Also deferred: an assembled-composition test of the headline capability — an overlay-mounted fixture plugin whose Host half registers a namespace and whose `dsh.client` half registers a card, asserted end-to-end. The current coverage proves each half separately; the shipped cards' unchanged output cannot prove the new path.
The wire read the section adds is one `settings.describe` beside the per-scope reads the cards already make. Its invalidation is imprecise in one direction: the wire announces document commits and connection resets, not registrations, so a namespace registered after the section's read joins on the next commit or reconnect.
Two frictions remain for an author outside this repository, both recorded in the section's README. The browser half must be a `dsh.client` package built in the client module system's lazy-CJS factory format, and the `clientBundle` preset that emits it lives in `packages/client/tsdown.client.ts` rather than a published package. The bundle-purity gate forbids importing this package's card chrome or staged-form model as values, so such a card reimplements staging and revision fencing. Sharing them would mean either publishing the preset or declaring a child slot inside the card so the section supplies the chrome; neither is built.

View File

@@ -0,0 +1,61 @@
# Agent Note: 由插件自己拥有的设置表层
Status: implemented
[English](2026-08-12-plugin-owned-settings-surface.md) | 中文
## Problem
注册了 settings 命名空间的插件到不了浏览器配置页,而拦住它的两道门都在本仓库里。
`packages/host/apiproxy` 持有两份硬编码的命名空间清单。`settings.describe` 用它们过滤答复,每次写入也先对照它们,因此清单之外的命名空间即便其拥有方已注册,也只会得到 `settings-not-exposed`。于是把一个插件加进配置页,意味着要改一个插件作者并不拥有的包。
插件配置分区渲染的是注册进 `settings.plugin.item` 的卡片列表,无序。卡片携带的是不透明的 `id`,从不是它所编辑的命名空间,因此分区无从判断哪些被服务的命名空间已经有了归属。凡是"这个命名空间由谁渲染"的问题,都无法从分区看得见的账本里得到答案。
两者相加,用户自己写的插件就只能靠手改 `settings.yaml` 来配置。[web 插件配置 note](../feature/2026-08-10-web-plugin-configuration.md) 把白名单记为刻意为之,[配置面边界 note](2026-07-30-config-plane-boundaries.md) 则把「可在 Web 上配置」绑定到可配置提供方目录的成员资格。这两条结论恰恰挡住了那个通用 seam 本来要服务的插件作者。
## Decision
**注册即暴露。** api-proxy 服务 `ctx.settings.describe()` 返回的每一个命名空间,写入不设门禁。`WEB_SETTINGS_NAMESPACES``PRODUCT_SETTINGS_NAMESPACES`、与 `ctx.llm.listConfigurableProviders()` 的并集,以及 `settings-not-exposed` 错误码,全部删除。没有任何注册应答的名字——未知的,或格式非法因而根本无法寻址到注册的——都折叠为 seam 自己的 `settings-rejected`,于是代理既不贡献边界,也不贡献自己的词汇。
**settings seam 不动。** 哪个客户端可以读某个命名空间、哪个页面渲染它,都是关于 Consumer 的事实Service Definition 只要携带其中之一,就等于让一个 Consumer 决定它的契约。`SettingsRegisterOptions` 一个字段都没加。
**`settings.plugin.item` 以 settings 命名空间为键。** 该 slot 从 `list` 改为 `keyed`,键就是卡片所编辑的命名空间,沿用 `tool.call.toolview` 的先例——每个工具插件把自己的渲染器注册在工具名这个键上。卡片声明 `key`,不再声明 `id`/`order`。该 slot 由「插件」分区的 `configurable` 标签页声明,卡片列表归它所有。
**标签页以被服务的命名空间驱动派发。** 它读取一次 `settings.describe`,订阅 settings 文档失效通知与连接重置,并为每个被服务的命名空间派发一个键。渲染出来的是两份账本的交集——存活 Host 插件注册的命名空间,以及注册在这些键上的卡片——由标签页的 controller 从 slot 账本(`ctx.slots.entries``ctx.slots.subscribe`)与协议答复算出。
以命名空间为键,让「缺席」本身成为信号,而这正是它消掉旧形态所需簿记的原因。归别的界面所有的命名空间(`ui-theme``permission``llm-*``agent-presets`)在其键上没有卡片,于是什么都不渲染,且无需在任何地方声明任何东西。命名空间未被本部署服务的卡片根本不会被派发,这同时修掉了旧的空态缺陷:标签页数的是已注册卡片,其中包含那些什么都不渲染的,因此一个都不暴露的部署看到的是空列表,而不是它那行空态文案。
**不渲染任何未被交给它的表单。** 标签页不提供兜底卡片。插件的浏览器半侧完整拥有自己的卡片——外观、控件与文案——而这正是 slot 的 `fallback` 选项会用一份 schema 反向渲染的表单取代掉的东西。
## 白名单实际护住了什么
这道门确实挡住了一样东西,本 note 如实写出,因为这个决策必须在准确版本下也站得住:不在名单上的已注册命名空间,其 resolved、`base``user` 值根本不会抵达浏览器。插件清单页不能替代它——`PluginInventoryEntry` 携带的是 `entryId``moduleName``enabled``fiberPhase`它那一行「configuration」渲染的是启用停用标签从不是任何已存值。
这道门不是的,是它所处位置暗示的那种边界。每个 `settings.*` 方法都在 `PRIVILEGED_METHODS` 里(`packages/client/connection`),非回环或跨源请求在到达这段代码之前就以 403 被拒;`role('secret')` 字段在每种响应的每一层都被结构性剥离;而这个面所编辑的文档,本就是用户自己的 `settings.yaml`,同一个设置页还提供了打开它的入口。它没有挡住的写入,恰恰是有分量的那些:`permission`(能放宽审批预设)与 `agent-presets`(决定一个会话挂载什么)本来就已被服务。
因此本次改动在本仓库实际新增的暴露面是一个命名空间:`agent-default-model`——它的两个字段指明一个提供方与一个模型,且没有任何浏览器半侧渲染它。将来若某个命名空间的值确实不该跨越协议,由 `role('secret')` 逐字段作答:比整命名空间开关更精细,而且已经在执行。
## Alternatives considered
**在 `settings.register()` 上加声明**`client: { surface: 'plugin-config' | 'custom', title, description }`),这也是被删掉的 `WEB_SETTINGS_NAMESPACES` 注释所点名的既定方向。它让注册默认不跨越传输边界,并让插件作者一行代码自助。否决的原因是 `surface` 是浏览器页面的词汇,而 `title`/`description` 属于呈现Service Definition 一旦携带它们,就成了被单个 Consumer 塑形的 seam。它那条 fail-closed 性质的价值也不如读起来那么高——见上文「白名单实际护住了什么」。
**另设一份暴露目录**,插件在注册 settings 之外再加入这份自有注册表,即把 `ctx.llm.registerConfigurableProviders()` 一般化。否决的原因是它把一件事实拆成两处可能脱节的注册:注册了命名空间却忘了目录条目,产出的是一个谁都编辑不了的分节,而没有任何门禁看得见这个错误。
**给 api-proxy 加一个 deny-list `Config` 字段**,让部署方能扣下某个命名空间。因为没有消费者而否决:当前每一个已注册的命名空间都是用户可以编辑的,而真正敏感的字段由 `role('secret')` 逐字段作答,那是更精细的工具。在第一个用例出现之前就发明出来的整命名空间开关,正是包规则所禁止的投机选项。
**把 schema 驱动的通用卡片作为该 slot 的 `fallback`**,让没有浏览器半侧的插件也能从 `schema.toJSON()` 得到一份表单schemastery 本就携带 `description``role``min`/`max`/`step` 并将其序列化)。否决的原因是客户端插件按已挂载的 Loader entries 在运行时加载,插件作者完全可以交付一张真正的卡片;而反向渲染的表单在模型页那次已被判定不如手写。若这个判断日后改变,`fallback` 选项无需改动契约即可启用。
**客户端认领注册表**让每个拥有某命名空间的界面声明它好让通用卡片知道哪些已经有人管。与通用卡片一并否决keyed 派发本就让无人认领的键什么都不渲染,这份注册表只会把 slot 账本已经说过的话再说一遍。
**保留 list slot只给它的 options 加一个命名空间字段。** 否决的原因是分区枚举的仍是 entry 而非命名空间,空态缺陷照旧,未组装插件的卡片也仍需自我抑制。
## Consequences
在本仓库之外分发的插件无需改动这里即可从设置页配置:它在 Host 上注册自己的命名空间、在浏览器里把卡片注册在该键上,由分区把两者配对。卡片现在按卡片注册顺序出现,而不再依赖手工指定的 `order`。对本包注册的这几张卡它是稳定的——它们从同一个 generator 安装;对**跨插件**的卡片它并不稳定:包与包之间的 apply 顺序是无约束的(`packages/client/AGENTS.md`),因此多个外部卡片仍可能在不同次启动之间重排。要为它们定序,需要一个 section 可排序的显式键,而 keyed 注册今天并不携带。
以下延后,且都大于本次改动:脱敏器对只能经由 union、intersection 或 transform 抵达的 `role('secret')` 原样返回(其自身的 `TODO(settings-wire-redaction)`),而 `schema.toJSON()` 会携带 secret 的默认值。该缺口早于本次改动,但服务每一个已注册命名空间,把它的影响面从本仓库内经审计的 schema 扩大到任意第三方 schema因此协议应当拒绝服务它无法证明可安全脱敏的命名空间。同样延后的还有对本次头号能力的组装态测试——用 overlay 挂载一个 fixture 插件Host 半注册命名空间、`dsh.client` 半注册卡片)并在端到端断言。当前覆盖分别证明了两个半侧;已发卡片输出未变这一点,证明不了新路径。
分区新增的协议读取是一次 `settings.describe`,与卡片各自已有的 per-scope 读取并列。它的失效通知在一个方向上不精确:协议通告的是文档提交与连接重置,而非注册行为,因此在分区读取之后才被注册的命名空间,要等下一次提交或重连才会加入。
对仓库之外的作者仍留有两处摩擦,均记在该分区的 README 里。浏览器半侧必须是按客户端模块系统的 lazy-CJS factory 格式构建的 `dsh.client` 包,而产出它的 `clientBundle` 预设位于 `packages/client/tsdown.client.ts`并非已发布的包。bundle 纯净度门禁禁止以值的形式导入本包的卡片外观与暂存表单模型,因此这样的卡片要重新实现暂存与 revision 设栅。要共享它们,要么发布该预设,要么在卡片内部声明一层子 slot 让分区提供外观;两者都尚未构建。

View File

@@ -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/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md
2026-07-31-composer-text-layers-share-one-scrollport.md: 6097779529f86e6d994296ae396f108c63f01abc
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 753d67d538d0c17512444639d60b7b5c8ff80e9a
2026-07-31-composer-text-layers-share-one-scrollport.md: d01231f706a7d3850ce1b3770ef351cb7e211384
2026-07-31-composer-text-layers-share-one-scrollport.zh.md: 3ce8cc05dc57b05bb8c0bd903a0b64b12d4a6963

View File

@@ -24,6 +24,8 @@ One scrolling box, holding both layers.
The browser then applies one offset to both layers, in the same frame, on the same compositor. The caret is bound to its glyphs by construction rather than by upkeep: there is no code to run, no event to wait for, and no state that can be one frame stale. The wheel-chaining handler stays, retargeted from the textarea to the scrollport, and remains the only listener on the box.
Safari's native text control has one engine exception: deleting across a soft-wrap threshold can retain the former line layout after the mirror shrinks. The [Safari soft-wrap recovery](2026-08-13-safari-textarea-soft-wrap-reflow.md) restores the zero-overflow invariant before paint without changing the one-scrollport design.
Two things the previous mechanism needed are gone with it:
**The backdrop's trailing-line sentinel.** It existed to keep the two boxes' scroll extents equal — a textarea reserves a line box for the caret after a final newline while `white-space: pre-wrap` collapses a text node's trailing newline, so a draft ending in a newline made the backdrop one line shorter and clamped the mirrored offset a line above the caret. With one scrollport the backdrop's own extent decides nothing: the mirror div sizes the stack for both layers, both start at the same top, and a layer whose content ends earlier simply paints nothing on the last line. The shape is worth keeping in mind rather than the mechanism: it is the one that measured 628 against 652 when the two boxes had to agree on a height.

View File

@@ -24,6 +24,8 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl
于是浏览器在同一帧、同一个合成器上,把同一个偏移施加给两层。光标与字形的绑定来自结构本身,而不是来自持续维护:没有代码要跑,没有事件要等,也没有任何状态可能落后一帧。滚轮接力处理器保留,只是从 textarea 改挂到滚动容器上,并且仍是这个盒子上唯一的监听。
Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的删除可能在镜像层收缩后仍保留原先的行布局。[Safari 软换行恢复](2026-08-13-safari-textarea-soft-wrap-reflow.md)会在绘制前恢复零溢出不变量,而不改变单滚动容器设计。
上一版机制所需要的两样东西随它一起消失:
**backdrop 的尾行哨兵。** 它的存在只是为了让两个盒子的滚动范围相等——textarea 会在末尾换行之后为光标保留一个行盒,而 `white-space: pre-wrap` 会折叠文本节点的尾随换行,因此以换行结尾的草稿会让 backdrop 少一行把镜像偏移钳制在光标上方一行。改为单一滚动容器后backdrop 自身的范围不再决定任何事:镜像层为两层统一定高,两层顶端对齐,内容更早结束的那一层只是在最后一行什么都不画。值得记住的是这类草稿形状而不是那套机制:正是它在「两个盒子必须就高度达成一致」的时代量出了 628 对 652。

View File

@@ -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/bug-fix/2026-08-04-large-history-pagination-call-stack.md
2026-08-04-large-history-pagination-call-stack.md: 28c22121123a227c507c506683ae727d238d98bd
2026-08-04-large-history-pagination-call-stack.zh.md: 57dde9bdc0a4aa52e1af024eb606bf9258430fa7

View File

@@ -0,0 +1,27 @@
# Agent Note: Large history provenance is scanned without argument expansion
Status: implemented
English | [中文](2026-08-04-large-history-pagination-call-stack.zh.md)
## Problem
A finalized assistant message can reference hundreds of thousands of streamed chunks through `sourceEventSeqs`. History pagination found the message group's first event with `Math.min(event.seq, ...sourceEventSeqs)`, so a valid session could exceed the JavaScript engine's function-argument limit and make `session.history` fail with HTTP 500.
## Decision
Pagination scans `sourceEventSeqs` and updates the earliest sequence number one element at a time. The algorithm remains linear in the provenance size and preserves the existing page boundary: a page starts before all recorded sources of its oldest included message.
A regression test rejects multi-argument minimum calls and verifies that every provenance event remains on the page with its finalized message. This exercises the failure mechanism without making the default test suite allocate a production-sized chunk stream.
## Alternatives considered
- **Raise the JavaScript stack or argument limit** — rejected: the limit is engine- and deployment-dependent, and array expansion still makes valid history depend on an unrelated runtime ceiling.
- **Truncate `sourceEventSeqs` during pagination** — rejected: this could cut a page inside a message and violate replay grouping.
- **Cap streamed chunk count at the provider boundary** — rejected: providers may legitimately emit long streams, and pagination must handle every valid session representation.
## Consequences
- Large provenance arrays no longer make history pagination throw solely because of their length.
- Pagination semantics and wire responses are unchanged.
- This does not bound the byte size of a history page or the browser cost of replaying it; those performance concerns remain separate from the server-side call-stack failure.

View File

@@ -0,0 +1,27 @@
# Agent Note: 大规模历史记录的溯源信息通过扫描处理,不做参数展开
Status: implemented
[English](2026-08-04-large-history-pagination-call-stack.md) | 中文
## 问题
一条已定稿的 assistant 消息可以通过 `sourceEventSeqs` 引用数十万个流式分片。历史记录分页使用 `Math.min(event.seq, ...sourceEventSeqs)` 查找消息组的首个事件,因此,有效会话可能超出 JavaScript 引擎的函数参数数量上限,导致 `session.history` 以 HTTP 500 失败。
## 决策
分页逻辑逐项扫描 `sourceEventSeqs`,每次使用一个元素更新最早的序号。该算法的复杂度相对溯源信息规模仍为线性,并保留现有的页面边界:页面起点位于其所含最早消息的所有已记录来源之前。
回归测试会拒绝以多个参数调用取最小值的做法,并验证每个溯源事件都会与其已定稿消息保留在同一页中。这既覆盖了故障机制,也避免默认测试套件分配生产规模的分片流。
## 考虑过的替代方案
- **提高 JavaScript 栈或参数上限**:不予采纳,因为该上限取决于引擎和部署环境,而且数组展开仍会让有效历史记录受制于无关的运行时上限。
- **在分页时截断 `sourceEventSeqs`**:不予采纳,因为这可能会从消息中间切分页面,破坏回放分组。
- **在提供方边界限制流式分片数量**:不予采纳,因为提供方可能会合理地产生长流,而分页必须处理每一种有效的会话表示。
## 后果
- 大型溯源数组不再仅因长度而使历史记录分页抛出异常。
- 分页语义与协议响应保持不变。
- 本决策不限制历史记录页面的字节大小,也不限制浏览器回放该页面的开销;这两项性能问题仍与服务端调用栈故障分开处理。

View File

@@ -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/bug-fix/2026-08-12-fix-pwsh-terminal-overlay-dup.md
2026-08-12-fix-pwsh-terminal-overlay-dup.md: 7a214ea093faf49141c66bfc2cbb065fbeb29825
2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md: 6d38ca58ad97e5ad04992e54a7938c9fea300863

View File

@@ -0,0 +1,55 @@
# Agent Note: fix the pwsh terminal overlay duplicate-loader collision
Status: implemented
English | [中文](2026-08-12-fix-pwsh-terminal-overlay-dup.zh.md)
## Problem
`apps/web/tests/pwsh-terminal.e2e.ts` fails on every platform with `TypeError: duplicate loader entry id: tool-pwsh`, thrown from `vendor/loader/src/config/group.ts:64` while applying the web composition. The failing seed lane boots the full shipped bundle plus a test overlay, so the E2E never reaches its rendering assertion and every `check:ci:snapshot`/`test:web` run reports a red web test even though the feature under test is unrelated to the change under review.
The web E2E scaffold applies an `extraOverlayPath` after the shipped Web surface and base patches. `pwsh-terminal.overlay.yml` used an `insert` block to add a `tool-pwsh` row:
```yaml
- insert:
- id: pwsh-local
name: '@deepseek-ai/dsh-pwsh-local'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
```
`insert` is correct only while `tool-pwsh` is absent from the composition. The id exists because `86b6979bdc` (refactor(bundle): fold the Windows shell platform layer into the base rows) moved both shell stacks into the base bundle with inverted platform gates — `packages/bundle/base/cordis.patch.yml` declares `tool-pwsh` with `disabled: !!js process.platform !== 'win32'`, so the row is present in the composition on every platform. Later, `42fc7c5ffb` (refactor(preset): gate tool-pwsh by platform alongside tool-bash) added a web-app patch row that disables `tool-pwsh` for surfaces that use presets; a patch row cannot introduce an id, so it is not the source of the collision. The overlay's `insert` delivers a second row with the same id in the same loader group, and the loader rejects the pair at boot.
## Decision
Replace the overlay's `insert` of `tool-pwsh` with a top-level id-targeted override:
```yaml
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: false
```
The effective `tool-pwsh` state is a three-layer stack: the base row gates `disabled` on `process.platform !== 'win32'`, the web-app overlay sets `disabled: true` unconditionally for preset surfaces, and this lane's override clears it back to `disabled: false` regardless of platform. An `id`-targeted top-level override replaces the composed row; only an `insert` would collide.
The lane also now disables `pwsh-sandbox` by id, symmetric with the existing `bash-sandbox` disable: the base gates `pwsh-sandbox` with `disabled: !!js process.platform !== 'win32'`, so on Windows it would otherwise mount beside the inserted `pwsh-local` and both would register the same executor service. Disabling it keeps `pwsh-local` the lone executor on every platform.
The overlay header comment was updated to describe the full selection and the `tool-pwsh` inline comment now names the base row as the source of the id.
## Alternatives considered
**Keep the `insert` and change the web composition instead.** Rejected, because the shipped web composition should keep the host `tool-pwsh` row disabled for every surface that uses presets; the overlay is the lane that deliberately needs it, so the by-id enable belongs there. The base row itself cannot be removed either: it is the platform-gated shell-stack declaration shared by every bundle.
**Enable `tool-pwsh` in the `insert` block.** Not possible: an `insert` of an id that already exists is the very duplicate being fixed. The row must be targeted by id, which is the top-level override form, not `insert`.
**Patch `tool-pwsh` by id without setting `disabled: false`.** Insufficient: the web-app overlay sets `disabled: true` unconditionally, and the base row's platform gate only applies where the web-app override is absent, so an override that only restates `name` leaves the row disabled and the lane renders no terminal card. The `disabled: false` is required.
**Only disable `bash-sandbox` and rely on the platform gate to keep `pwsh-sandbox` off.** Rejected: that holds on POSIX but breaks on Windows, where the base row leaves `pwsh-sandbox` enabled and it would collide with the inserted `pwsh-local` on the shared executor service. The lane's `pwsh-sandbox` disable keeps one executor on every platform.
## Verification
Reverting the fix (restoring the `insert` of `tool-pwsh`) reproduces the exact `duplicate loader entry id: tool-pwsh` boot failure, confirming the override is load-bearing. With the fix in place `pwsh-terminal.e2e.ts` passes 2/2 on the same head — this exercises the POSIX seam, where the seeded pwsh call renders through the enabled `tool-pwsh` and the inserted `pwsh-local`. The seed lane requires a usable `pwsh`, so it skips on hosts without one; a `pwsh` binary is present on this machine and the test ran. The Windows path (base `pwsh-sandbox` mounted beside the inserted `pwsh-local`) is not exercised by any CI lane, whose `test:web` runs only on Linux; the overlay disables `pwsh-sandbox` to keep that path composable if it ever runs on a Windows dev machine.
## Consequences
The web E2E seed lane that exercises PowerShell boot now composes instead of colliding, so `check:ci:snapshot` and `test:web` stop failing on the duplicate independently of the change under test. The pattern is general: a `--patch`/`extraOverlayPath` overlay must probe whether a row already exists in the bundle it augments before choosing `insert` over an id-targeted override; `insert` of an id that the base or shipped Web surface already declares is a boot-time duplicate.

View File

@@ -0,0 +1,55 @@
# Agent Note修复 pwsh 终端 overlay 的重复 loader 冲突
Status: implemented
[English](2026-08-12-fix-pwsh-terminal-overlay-dup.md) | 中文
## Problem
`apps/web/tests/pwsh-terminal.e2e.ts` 在所有平台上都以 `TypeError: duplicate loader entry id: tool-pwsh` 失败,由 `vendor/loader/src/config/group.ts:64` 在应用 web 组合时抛出。该失败的 seed 通道会启动完整发布的 bundle 加一个测试 overlay因此 E2E 永远到不了渲染断言,导致 `check:ci:snapshot``test:web` 每次运行都报一个红的 web 测试,即便被测功能与评审中的改动无关。
web E2E scaffold 在已发布的 Web 表面与 base patches 之后应用 `extraOverlayPath``pwsh-terminal.overlay.yml``insert` 块新增 `tool-pwsh` 行:
```yaml
- insert:
- id: pwsh-local
name: '@deepseek-ai/dsh-pwsh-local'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
```
`insert` 仅在组合中不存在 `tool-pwsh` 时才正确。该 id 存在是因为 `86b6979bdc`refactor(bundle): fold the Windows shell platform layer into the base rows把两套 shell 栈以互逆的平台门移进了 base bundle —— `packages/bundle/base/cordis.patch.yml` 声明 `tool-pwsh``disabled: !!js process.platform !== 'win32'`,于是该行在每个平台都存在于组合中。随后 `42fc7c5ffb`refactor(preset): gate tool-pwsh by platform alongside tool-bash往 web-app patch 里加了一行对使用 preset 的表面禁用 `tool-pwsh` 的行patch 不能引入 id因此它不是冲突来源。overlay 的 `insert` 于是在同一个 loader 组里再送一个同 id 的行loader 在启动时拒绝这对重复。
## Decision
把 overlay 对 `tool-pwsh``insert` 替换成顶层按 id override
```yaml
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: false
```
有效的 `tool-pwsh` 状态是三层栈base 行把 `disabled` 门在 `process.platform !== 'win32'`web-app overlay 对 preset 表面无条件设 `disabled: true`,本通道的 override 无论平台都把 `disabled: false` 还回去。`id` 定位的顶层 override 替换组合后的行;只有 `insert` 才会相撞。
该通道现在也按 id 禁用 `pwsh-sandbox`,与既有的 `bash-sandbox` 禁用对称base 以 `disabled: !!js process.platform !== 'win32'` 门住 `pwsh-sandbox`,因此在 Windows 上它本会与插入的 `pwsh-local` 并存,两者会注册同一个 executor 服务。禁用它让 `pwsh-local` 在每个平台上都是唯一 executor。
overlay 头部注释已更新为完整描述选择,`tool-pwsh` 行内注释现在把 base 行标为该 id 的来源。
## Alternatives considered
**保留 `insert`、改 web 组合。** 拒绝。已发布的 web 组合应在所有使用 preset 的表面上保持 host `tool-pwsh` 行禁用overlay 才是那条刻意需要该行的通道,因此按 id 启用应放在那里。base 行本身也不能移除:它是所有 bundle 共享的平台门 shell 栈声明。
**在 `insert` 块里启用 `tool-pwsh`。** 不可行。对已存在的 id 做 `insert` 正是这里要修的重复。该行必须按 id 定位,即顶层 override 形式,而非 `insert`
**只按 id 改 `tool-pwsh` 而不设 `disabled: false`。** 不充分。web-app 无条件设 `disabled: true`base 行的平台门只在 web-app override 缺失处生效,因此只重申 `name` 的 override 会让行保持禁用,通道渲染不出终端卡。`disabled: false` 是必需的。
**只禁用 `bash-sandbox`、依赖平台门让 `pwsh-sandbox` 保持关闭。** 拒绝。在 POSIX 上成立,但在 Windows 上会失败base 行让 `pwsh-sandbox` 启用,它会与插入的 `pwsh-local` 在共享 executor 服务上相撞。本通道禁用 `pwsh-sandbox` 让每个平台只有唯一 executor。
## Verification
把修复还原(恢复对 `tool-pwsh``insert`)即复现同样的 `duplicate loader entry id: tool-pwsh` 启动失败,证实 override 是有效的。修复后同一 head 上 `pwsh-terminal.e2e.ts` 2/2 通过 —— 这作用于 POSIX seam播种的 pwsh 调用经启用的 `tool-pwsh` 与插入的 `pwsh-local` 渲染出来。该 seed 通道需要可用的 `pwsh`,无此二进制的主机会跳过;本机有 `pwsh`测试实际跑过。Windows 路径base `pwsh-sandbox` 与插入的 `pwsh-local` 并存)没有任何 CI lane 覆盖,其 `test:web` 只在 Linux 上跑overlay 禁用 `pwsh-sandbox` 让该路径在真到 Windows 开发机运行时可组合。
## Consequences
用于执行 PowerShell 启动的 web E2E seed 通道现在能组合而非相撞,因此 `check:ci:snapshot``test:web` 不再与被测改动无关地在该 duplicate 上失败。该模式具有通用性:`--patch`/`extraOverlayPath` overlay 在决定用 `insert` 还是按 id override 之前,必须探测目标 bundle 是否已存在该行;对已由 base 或已发布 Web 表面声明的 id 做 `insert`,是启动期重复。

View File

@@ -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/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md
2026-08-13-safari-textarea-soft-wrap-reflow.md: fb264a8e6fbe24369584f2427bbb0c462b450ecf
2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: 7f55a5260e825059e1f9a08db03f13e19484d14e

View File

@@ -0,0 +1,47 @@
# Agent Note: Safari textarea soft-wrap shrink recovery
Status: implemented
English | [中文](2026-08-13-safari-textarea-soft-wrap-reflow.zh.md)
## Problem
The composer keeps the caret and selection in a transparent native textarea while the backdrop paints visible glyphs and the hidden mirror determines the full draft height. The [single-scrollport decision](2026-07-31-composer-text-layers-share-one-scrollport.md) therefore depends on the textarea owning no scrollable overflow: after every draft commit, its `scrollHeight` and `clientHeight` are equal and its `scrollTop` is zero.
Safari 26.5.2 can retain the textarea's former native line layout when Backspace moves a draft across a soft-wrap threshold at the same time that React updates the mirror. In the reproduced two-line-to-one-line transition, the mirror, backdrop, grow stack, and textarea box all become 28px high, but the textarea still reports `scrollHeight=52` and `scrollTop=20`. The caret remains in the stale native line while the backdrop correctly paints one line.
The `color` declaration is not a layout input. Changing its inline style changes the computed color but leaves the stale `52/28/20` state intact. Editing the stylesheet rule happens to trigger broader rule invalidation and clears the state to `28/28/0`, which explains why Web Inspector makes the declaration appear causal.
## Decision
`InputBar` detects Safari once from the Apple vendor and the `Version/... Safari/...` user-agent form, while rejecting known alternate iOS browser tokens such as `CriOS`, `FxiOS`, `EdgiOS`, and `OPiOS`. A browser shell indistinguishable through these identity fields still has to violate the textarea overflow invariant before the recovery mutates layout.
The native textarea change handler records whether an edit shortens the controlled draft. After that draft commits, a layout effect returns without reading geometry unless both the cached Safari identity and the native-shrink signal are present. It then checks the single-scrollport invariant: equal `scrollHeight` and `clientHeight` are settled and trigger no forced layout. A mismatch first changes the textarea's real height by one pixel, forces layout, restores the owned height, and forces layout again. This rebuilds Safari's native text-control layout without changing the value, selection, IME state, or undo transaction.
The temporary native overflow can leave the draft scrollport's auto height at the former line count even after the textarea is correct. The recovery therefore repeats the one-pixel invalidation on `[data-input-scroll]` after repairing the textarea. Both elements return to their owned styles before paint; the settled one-line state is `scrollHeight=clientHeight=28`, `scrollTop=0`, and a 28px scrollport.
## Verification
Component tests synthesize Safari's stale metrics, assert the textarea-then-scrollport invalidation order, preserve selection, and prove that a growing native draft reads no geometry. Browser-identity tests cover desktop and mobile Safari, desktop Chromium, Chrome, Edge, and Opera on iOS, and an Apple web view.
The assembled package is also exercised in Safari 26.5.2 through the native 51-character-to-50-character Backspace path. Playwright WebKit 26.5 settles correctly without the workaround in both the assembled app and a reduced page, so the repository's Chromium browser lane cannot reproduce this Safari application defect; the focused component test pins the engine state until an automatable Safari lane exists.
## Alternatives considered
**Change `color` or use `-webkit-text-fill-color`.** Rejected because inline color changes and transparent text fill leave the stale native geometry unchanged. Stylesheet-rule editing works only because its invalidation scope is broader than the declaration's paint semantics.
**Set `scrollTop=0`.** Rejected because it moves the stale native content without rebuilding its two-line `scrollHeight`; the caret can become clipped instead of aligned.
**Rewrite the textarea value.** Clearing and restoring the value rebuilds Safari's text control, but it mutates the editing state that owns IME composition and selection. The height invalidation leaves the value untouched.
**Use `field-sizing: content`.** Rejected because Safari reproduces the stale two-line intrinsic height after the same deletion, and the composer still needs the mirror as the caret ruler and backdrop metric peer.
**Invalidate only the textarea or only the scrollport.** Rejected because the textarea-only recovery clears `52/28/20` but can leave the scrollport at 52px, while the scrollport-only recovery leaves the textarea's native overflow untouched. The ordered pair is the smallest complete recovery.
**Check geometry after every Safari draft commit.** Rejected because reading `scrollHeight` or `clientHeight` after React changes the mirror can synchronously lay out even a healthy growing draft. A native shortening signal limits the invariant read to edits that can produce the observed shrink defect.
**Run the recovery in every browser.** Rejected because Chromium, Playwright WebKit, and Firefox maintain the invariant without forced layouts. The Safari identity and observed mismatch jointly bound the synchronous work.
## Consequences
Non-Safari browsers, programmatic draft updates, and native edits that do not shorten the draft perform no geometry read. A native Safari shortening reads the overflow invariant and pays the four forced layouts only when the textarea violates it. The exceptional path accepts rare local work before paint to preserve caret alignment, native editing semantics, and the single scrolling box. An equivalent stale state caused only by resize or sidebar width changes has not been observed and is outside this recovery trigger. The browser test gap remains explicit: real Safari evidence owns the engine defect, while deterministic component coverage owns the recovery and its browser gate.

View File

@@ -0,0 +1,47 @@
# Agent Note: Safari textarea 软换行收缩恢复
Status: implemented
[English](2026-08-13-safari-textarea-soft-wrap-reflow.md) | 中文
## 问题
composer 把光标与选区留在透明的原生 textarea 中,由 backdrop 绘制可见字形,并由隐藏的镜像层决定完整草稿高度。因此,[单滚动容器决策](2026-07-31-composer-text-layers-share-one-scrollport.md)依赖 textarea 不持有可滚动溢出:每次草稿提交后,它的 `scrollHeight``clientHeight` 相等,`scrollTop` 为零。
当 Backspace 让草稿跨过软换行阈值,同时 React 更新镜像层时Safari 26.5.2 可能保留 textarea 原先的原生行布局。在复现出的两行变一行转换中镜像层、backdrop、自增高栈和 textarea 盒都变为 28px 高,但 textarea 仍报告 `scrollHeight=52``scrollTop=20`。光标留在陈旧的原生行中,而 backdrop 已正确绘制为一行。
`color` 声明不是布局输入。修改 inline style 会改变计算后的颜色,却让陈旧的 `52/28/20` 状态保持不变。编辑样式表规则会碰巧触发范围更广的规则失效,并把状态清为 `28/28/0`,这正是 Web Inspector 让该声明显得像成因的原因。
## 决策
`InputBar` 通过 Apple vendor 与 `Version/... Safari/...` 形式的 user agent 一次性识别 Safari同时排除 `CriOS``FxiOS``EdgiOS``OPiOS` 等已知的 iOS 其他浏览器 token。仅凭这些 identity 字段无法区分的浏览器壳仍必须先违反 textarea 溢出不变量,恢复逻辑才会修改布局。
原生 textarea change handler 会记录本次编辑是否缩短受控草稿。草稿提交后,除非同时存在已缓存的 Safari identity 与原生缩短信号,否则 layout effect 会在读取几何前直接返回。随后它才检查单滚动容器不变量:`scrollHeight``clientHeight` 相等即为稳定态,不会触发强制布局。出现差异时,逻辑先把 textarea 的实际高度改变一个像素,强制布局,再恢复其自有高度并再次强制布局。这样无需改变值、选区、输入法组合状态或撤销事务,即可重建 Safari 的原生文本控件布局。
即使 textarea 已正确恢复,临时的原生溢出仍可能让草稿滚动容器的 auto 高度停在原行数。因此,恢复逻辑会在修复 textarea 后,对 `[data-input-scroll]` 重复一次单像素失效。两个元素都会在绘制前恢复各自拥有的样式;稳定的一行状态为 `scrollHeight=clientHeight=28``scrollTop=0`,滚动容器高度为 28px。
## 验证
组件测试会合成 Safari 的陈旧度量,断言先 textarea 后滚动容器的失效顺序,保留选区,并证明原生草稿增长不会读取几何。浏览器 identity 测试覆盖桌面与移动 Safari、桌面 Chromium、iOS ChromeEdgeOpera 和 Apple web view。
组装后的包还会在 Safari 26.5.2 中通过原生的 51 字符到 50 字符 Backspace 路径验证。Playwright WebKit 26.5 在组装应用与最小化页面中都无需本绕法即可正确稳定,因此仓库的 Chromium 浏览器泳道无法复现这个 Safari 应用缺陷;在可自动化的 Safari 泳道出现之前,由聚焦组件测试固定该引擎状态。
## 备选方案
**修改 `color` 或使用 `-webkit-text-fill-color`。** 被否决,因为 inline color 修改与透明 text fill 都不会改变陈旧的原生几何。编辑样式表规则之所以有效,只是因为其失效范围比该声明的绘制语义更广。
**设置 `scrollTop=0`。** 被否决,因为这只会移动陈旧的原生内容,不会重建其两行 `scrollHeight`;光标可能从错位变为被裁剪。
**重写 textarea 的值。** 清空再恢复值能够重建 Safari 文本控件,但会改动拥有输入法组合与选区的编辑状态。高度失效不会触碰值。
**使用 `field-sizing: content`。** 被否决,因为相同删除后 Safari 的两行固有高度仍会陈旧,并且 composer 仍需要镜像层充当光标标尺与 backdrop 的度量对端。
**只让 textarea 或滚动容器失效。** 被否决,因为只恢复 textarea 虽能清除 `52/28/20`,却可能把滚动容器留在 52px只恢复滚动容器则不会改变 textarea 的原生溢出。这个有序二元操作是最小的完整恢复。
**每次 Safari 草稿提交后都检查几何。** 被否决,因为 React 改变镜像层后读取 `scrollHeight``clientHeight`,即使草稿健康增长也可能同步执行布局。原生缩短信号把不变量读取限制在可能产生已观测收缩缺陷的编辑中。
**在所有浏览器中运行恢复逻辑。** 被否决,因为 Chromium、Playwright WebKit 与 Firefox 无需强制布局即可维持该不变量。Safari identity 与已观测到的差异共同限定同步工作范围。
## 影响
非 Safari 浏览器、程序化草稿更新以及不会缩短草稿的原生编辑都不会读取几何。Safari 的原生缩短会读取溢出不变量,并且仅在 textarea 违反不变量时承担四次强制布局。例外路径以绘制前的罕见局部工作换取光标对齐、原生编辑语义与单一滚动盒。尚未观测到仅由 resize 或侧栏宽度变化引发的同类陈旧状态,本恢复触发器也不覆盖它。浏览器测试缺口保持显式:真实 Safari 证据负责引擎缺陷,确定性的组件覆盖负责恢复逻辑与浏览器门控。

View File

@@ -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/bug-fix/2026-08-15-max-token-replay-state-alignment.md
2026-08-15-max-token-replay-state-alignment.md: 256a64403a08377cf35ba645175698678eaa7f8b
2026-08-15-max-token-replay-state-alignment.zh.md: a24f3e194dca100d2ea0faf659b1868c605c26c1

View File

@@ -0,0 +1,33 @@
# Agent Note: Replay state aligns with assembled content by construction
Status: implemented
English | [中文](2026-08-15-max-token-replay-state-alignment.zh.md)
## Problem
pi-ai recorded one opaque replay blob per response, projected from the provider's native message, while `BlockAssembler.blocks()` separately dropped tool calls from a `max-tokens` response because a truncated call is unsafe to execute. The durable assistant message therefore stored transformed content next to metadata describing the untransformed native block list. The next request failed during history reconstruction with `INVALID_REPLAY_STATE: block count does not match assistant content`, and because the mismatch was already on disk, every later request on that session failed the same way — the session was permanently stuck. The root cause is structural: two representations of one response were snapshotted at different pipeline points, with their index alignment enforced only by a read-time hard error.
## Decision
Two changes, one per side of the durable boundary.
**Write side — one keep/drop decision.** The finish chunk's `replayState` becomes a typed `ReplayEnvelope`: an opaque `response` half plus optional opaque per-block entries aligned with the emitted block sequence. `BlockAssembler` computes its keep/drop decision once and applies it to blocks and envelope entries together, so any transformation assembly performs — today's max-token tool-call drop or a future one — prunes the matching metadata by construction. Retained blocks keep their entries, so a truncated response keeps signatures for the reasoning and text it kept. An envelope whose entries do not match the emitted block count is discarded whole (a misemitting adapter must not publish misattributed metadata). pi-ai splits its former flat state into a version-2 response half and per-block signature entries.
**Read side — durable content is authoritative.** `toPiAssistant` treats replay state as fidelity metadata, not as a load-bearing input: any state the reading build cannot use — another adapter's kind, another version (including the flat version-1 form already on disk), malformed metadata, or a block shape that no longer matches the content — degrades that one message to the existing foreign provider-neutral conversion and reports the `INVALID_REPLAY_STATE` diagnostic through the plugin's `onReplayDegrade` hook (a logger warning). The request proceeds. This is what lets sessions poisoned before this change continue instead of erroring forever, and it bounds every future divergence source to a fidelity loss on one message.
## Verification
Assembler unit tests prove pruning, misalignment discard, and pass-through for untransformed and per-block-free envelopes. pi-ai unit tests prove the version-2 envelope round-trip and that every formerly-throwing invalid-state case now degrades to foreign conversion with the diagnostic. An agent-loop regression drives a truncated text-plus-tool-call response through persistence and shows the follow-up request carrying the pruned envelope. Keyless real-composition tests boot `dsh-llm-pi-ai` through the Loader and prove a native continuation without `tool_calls` after truncation, and a successful continuation over a legacy flat-state message whose block count no longer matches. The authored keyless snapshot scenario `max-tokens-continue` pins the assembled application's durable log — truncated turn, pruned envelope on the stored message, continued turn — through the real ACP subprocess path.
## Alternatives considered
**Suppress the whole replay state when assembly drops a tool call.** Works for today's one transformation, but re-derives the drop condition beside `blocks()` (the two drift silently), discards valid signatures for the retained blocks, and leaves read-time divergence — legacy sessions on disk foremost — a hard error.
**Keep the state and relax pi-ai's block-count validation to attach what fits.** Rejected: index-aligned signatures attached to a different block list would present false native history to the provider. Degrading attaches nothing.
**Teach each adapter to rewrite its state after assembly.** Rejected as an adapter obligation with an opaque blob; the envelope moves exactly the needed structure — and nothing else — into shared vocabulary, and the assembler's single decision does the rewrite mechanically.
## Consequences
Continuing after a max-token response that included a tool call works, retains the kept blocks' native signatures, and replays as a native pi-ai message. Sessions recorded before this change replay their affected assistant messages as provider-neutral content (with a diagnostic) instead of failing the turn; on-disk `replayState` values changed shape under the pre-release no-compatibility stance, with the old flat form handled by the same degrade path. This supersedes the read-time hard-error rule in the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md) for unusable state; validation itself is unchanged and still precedes any native reconstruction.

View File

@@ -0,0 +1,33 @@
# Agent Note: 回放状态与组装内容按构造对齐
Status: implemented
[English](2026-08-15-max-token-replay-state-alignment.md) | 中文
## 问题
pi-ai 为每个响应记录一个从提供方原生消息投影而来的不透明回放数据,而 `BlockAssembler.blocks()` 会另行从 `max-tokens` 响应中丢弃工具调用,因为被截断的调用不能安全执行。持久化的 assistant 消息因此把变换后的内容与描述未变换原生块清单的元数据存在一起。下一个请求在历史重建阶段以 `INVALID_REPLAY_STATE: block count does not match assistant content` 失败;由于不一致已经落盘,该会话之后的每个请求都以同样方式失败——会话被永久卡死。根因是结构性的:同一响应的两种表示在流水线的不同位置各自拍摄快照,其索引对齐只靠读取时的硬错误来维持。
## 决定
两处改动,各覆盖持久化边界的一侧。
**写侧——一次保留/丢弃决定。** finish 分片的 `replayState` 变为有类型的 `ReplayEnvelope`:一个不透明的 `response` 半区,加上与发射块序列对齐的可选不透明逐块条目。`BlockAssembler` 只计算一次保留/丢弃决定,并把它同时应用于块和逐块条目,因此组装执行的任何变换——今天的 max-token 工具调用丢弃或未来的其他变换——都按构造裁剪掉对应元数据。保留的块保留其条目所以被截断的响应仍为其保留的推理reasoning与文本保有签名。条目数与发射块数不一致的数据整体丢弃发射不当的适配器不得发布归属错误的元数据。pi-ai 把原先的平铺状态拆为版本 2 的 response 半区和逐块签名条目。
**读侧——持久化内容是权威记录。** `toPiAssistant` 把回放状态当作保真度元数据,而非承重输入:读取方无法使用的任何状态——其他适配器的 kind、其他版本包括已落盘的平铺版本 1 形式)、格式错误的元数据、或与内容不再匹配的块结构——都把这一条消息降级为既有的外来提供方无关转换,并通过插件的 `onReplayDegrade` 钩子logger 警告)上报 `INVALID_REPLAY_STATE` 诊断。请求继续执行。正是这一点让本次改动之前已被毒化的会话得以继续而不是永远报错,也把未来一切分叉源约束为单条消息的保真度损失。
## 验证
组装器单元测试证明裁剪、错位丢弃、以及未变换与无逐块条目数据的透传。pi-ai 单元测试证明版本 2 数据的往返以及先前每个抛错的无效状态用例现在都降级为外来转换并带出诊断。agent loop 回归用例驱动一个被截断的文本加工具调用响应穿过持久化,并证明后续请求携带裁剪后的数据。无密钥真实组合测试通过 loader 启动 `dsh-llm-pi-ai`,证明截断后不带 `tool_calls` 的原生续聊,以及在块数不再匹配的旧平铺状态消息之上成功续聊。手工编写的无密钥快照场景 `max-tokens-continue` 通过真实 ACP 子进程路径钉住组装应用的持久化日志——截断轮次、存储消息上裁剪后的数据、以及继续的轮次。
## 已考虑的替代方案
**组装丢弃工具调用时抑制整个回放状态。** 对今天唯一的变换有效,但在 `blocks()` 旁边重新推导丢弃条件(两处会无声漂移),丢掉保留块的有效签名,并让读取时的分叉——首当其冲是已落盘的旧会话——仍然是硬错误。
**保留状态并放宽 pi-ai 的块数校验、能贴多少贴多少。** 否决:索引对齐的签名贴到不同的块清单上,会向提供方呈现虚假的原生历史。降级则什么都不贴。
**让每个适配器在组装后改写自己的状态。** 否决:这把义务压给持有不透明数据的适配器;信封只把恰好需要的结构——不多一分——纳入共享词汇,组装器的单一决定即可机械完成改写。
## 影响
包含工具调用的 max-token 响应之后的续聊可以工作,保留块保有原生签名,并作为原生 pi-ai 消息回放。本次改动之前记录的会话,其受影响的 assistant 消息作为提供方无关内容回放(带诊断)而不是让轮次失败;`replayState` 落盘形状在预发布无兼容承诺立场下发生变化,旧平铺形式由同一降级路径处理。对不可用状态而言,这取代了[提供方路由适配器决定](../architecture/2026-07-14-provider-routed-llm-adapters.md)中读取时硬错误的规则;校验本身不变,仍先于任何原生重建执行。

View File

@@ -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-10-web-plugin-configuration.md
2026-08-10-web-plugin-configuration.md: bdb5a3cbddabb516a0921abc00f1be97be4d17d7
2026-08-10-web-plugin-configuration.zh.md: ed8ced71ed50063f9b1702ff2a6601271712f910
2026-08-10-web-plugin-configuration.md: 218d5ed4536c489f89cb53c62c457a988e451b7e
2026-08-10-web-plugin-configuration.zh.md: 999e785699741084e1e230c4a240a0605491cb04

View File

@@ -4,6 +4,8 @@ Status: implemented
English | [中文](2026-08-10-web-plugin-configuration.zh.md)
> The three sections, the layering, and the staged-save form remain current. The Host allowlist and the unkeyed card list are superseded by the [plugin-owned settings surface](../architecture/2026-08-12-plugin-owned-settings-surface.md): every registered namespace is served, and cards are keyed on the namespace they edit.
## Problem
Everything a plugin can be configured with lived in `cordis.yml`. A user who wanted a longer shell timeout, a different search endpoint, or fewer parallel tool calls had to find the composition file, know its shape, and restart — while the Models page had shown for months that a settings namespace can be edited from the browser and take effect immediately.

View File

@@ -4,6 +4,8 @@ Status: implemented
[English](2026-08-10-web-plugin-configuration.md) | 中文
> 三个分节、分层解析与暂存保存表单依然有效。Host 白名单与无键卡片列表已被[由插件自己拥有的设置表层](../architecture/2026-08-12-plugin-owned-settings-surface.md)取代:每一个已注册的命名空间都被服务,卡片以它所编辑的命名空间为键。
## 问题
插件的一切可配置项都只存在于 `cordis.yml`。想要更长的 shell 超时、不同的搜索端点或更少的并行工具调用,用户必须找到组装文件、了解它的形状,然后重启——而 Models 页几个月来一直在证明settings 命名空间可以在浏览器里编辑并立即生效。

View File

@@ -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-11-collapsible-ask-user-question-card.md
2026-08-11-collapsible-ask-user-question-card.md: 5c7e62749e63a6042285b79751c400ba09038b49
2026-08-11-collapsible-ask-user-question-card.zh.md: 5f4b5851e4b595e634841bf87827db70fe928afb

View File

@@ -0,0 +1,32 @@
# Agent Note: Collapsible Ask-User Question Card
Status: implemented
English | [中文](2026-08-11-collapsible-ask-user-question-card.zh.md)
## Problem
`dsh`'s ask-user takeover renders the pending question set as a bottom card capped at `min(60vh, 520px)`, so a long batch or a user who wants to re-read the conversation above before deciding has no way to reduce the card — the conversation above becomes hard to read because only a few lines peek out at the top.
## Decision
Add a minimize/maximize toggle to the question card header, next to the existing dismiss action. Collapsing hides the option body and the footer actions, leaving a header strip (eyebrow, title, both icon buttons) so the user still sees that a question is pending; expanding restores the full card.
- State lives in `QuestionFlow` local state (`minimized`), so drafts and the current question index survive collapse/expand — nothing is re-derived or reset, and the answers already picked remain submit-ready.
- The toggle is a plain `IconChevronDownOutline14` / `IconChevronUpOutline14` pair on the existing 24px icon-button grid; `aria-expanded` reflects the card state and the label flips between `nav.minimize` / `nav.maximize` (the collapsed button reads "expand" for screen readers).
- While minimized the option body and footer are unmounted (`{!minimized && ...}`), so no hidden interactive surface remains in the a11y tree.
- The collapse button is disabled while a submit/cancel is in flight (`busy !== null`), matching the dismiss button's existing guard.
- CSS: `.cardMinimized` drops the `max-height` cap and hides `.body` / `.footer`; `.header` gains bottom padding so the strip is not cramped.
- Scope: only the generic question flow (`QuestionFlow`) gets the toggle. The plan-review card (`PlanReviewPanel`) is a different shape (one decision over one plan) and keeps its current layout.
## Consequences
- Users can shrink the question card to read the conversation, then expand to answer — drafts and position are preserved because the state lives in the flow component, not in the DOM.
- The minimize action is visually adjacent to dismiss; both share the icon button style, so the header stays balanced.
- Product copy additions are confined to the `question` locale namespace (`nav.minimize` / `nav.maximize`), paired zh/en per the dictionary contract.
## Alternatives considered
- **Auto-collapse on scroll**: collapsing the card when the user scrolls the conversation would reclaim space without a button, but it fights the user mid-interaction and hides the pending-question signal unexpectedly; an explicit toggle keeps the decision with the user.
- **Resizable card**: a drag handle would let users size the card freely, but it is more machinery than the ask needs and does not address "I want the card out of the way entirely".
- **Persisting the collapsed state per session**: nice-to-have, but the ask is per-interaction; persisting adds storage and sync complexity without a clear win for this surface.

View File

@@ -0,0 +1,32 @@
# Agent Note: 可收起的提问卡片
Status: implemented
[English](2026-08-11-collapsible-ask-user-question-card.md) | 中文
## Problem
`dsh` 的 ask-user 接管界面把待回答的问题组渲染为底部卡片,高度上限为 `min(60vh, 520px)`;当问题批次较长、或用户想先阅读上方的会话记录再决定时,卡片会占满大部分视口且无法缩小——上方会话几乎被遮住,只能看到顶部几行。
## Decision
在提问卡片头部(现有的"放弃整组问题"按钮旁)增加收起/展开切换按钮。收起时隐藏选项主体和底部操作区只保留一条头部eyebrow、标题、两个图标按钮用户仍能看到"有未答问题"的信号;展开后恢复完整卡片。
- 状态存放在 `QuestionFlow` 的本地 state`minimized`),因此收起/展开不会丢失草稿和当前题目索引——已选答案仍可直接提交。
- 切换按钮使用 `IconChevronDownOutline14` / `IconChevronUpOutline14`,复用现有 24px 图标按钮网格;`aria-expanded` 反映卡片状态,文案在 `nav.minimize` / `nav.maximize` 之间切换(收起后按钮对读屏器显示为"展开")。
- 收起时选项主体和底部通过 `{!minimized && ...}` 卸载a11y 树中不残留隐藏的可交互面。
- 提交/取消进行中(`busy !== null`)时收起按钮禁用,与现有放弃按钮的守卫一致。
- CSS`.cardMinimized` 去掉 `max-height` 上限并隐藏 `.body` / `.footer``.header` 增加底部 padding避免折叠后过于局促。
- 范围:只有通用提问流(`QuestionFlow`)获得该切换。计划评审卡片(`PlanReviewPanel`)是另一种形态(对一个计划做一次决策),保持现有布局。
## Consequences
- 用户可以缩小提问卡片以阅读会话,再展开作答——草稿和位置因状态存放在流程组件中而得以保留。
- 收起动作紧邻放弃按钮,二者共用图标按钮样式,头部保持平衡。
- 新增产品文案仅落在 `question` locale 命名空间(`nav.minimize` / `nav.maximize`),按字典契约中英成对。
## Alternatives considered
- **滚动时自动收起**:用户滚动会话时自动折叠卡片可以省空间,但会在交互中途与用户对抗,并意外隐藏"待答问题"信号;显式切换把决定权交给用户。
- **可拖拽调整大小**:拖拽手柄让用户自由调整卡片大小,但比需求所需的机制更复杂,也没有解决"让卡片完全让开"的诉求。
- **按会话持久化折叠状态**:锦上添花,但提问是单次交互;持久化引入存储与同步复杂度,对这个界面没有明确收益。

View File

@@ -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-08-08-native-windows-pull-request-ci.md
2026-08-08-native-windows-pull-request-ci.md: 39dadfa9ba883bd9178091cf67a32dc44cf405f5
2026-08-08-native-windows-pull-request-ci.zh.md: 14f21c3e97761a351621062a61636e5e9f33151f
2026-08-08-native-windows-pull-request-ci.md: 31a1a1893b0c6248a30ac6e12b409282f608a689
2026-08-08-native-windows-pull-request-ci.zh.md: ba2520c2514580e5af367df86dd3195485a0a34c

View File

@@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions
The required `windows` job in [ci.yml](../../../../.github/workflows/ci.yml) remains `windows node 24 / wine blocking` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. The stable `windows` job id remains a dependency of `all checks passed`. The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology.
Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 60-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
Every pull request also starts an ordinary independent `windows-native` job named `windows node 24 / native complete` on the organization-owned `dsh-windows-2025-16core` runner. It enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs `pnpm run check:ci:windows-complete` under native PowerShell. A 120-minute timeout bounds a stuck gate without treating the measured performance target as a correctness deadline.
The native job is deliberately absent from `all-checks-passed.needs` and does not use `continue-on-error`: the aggregate neither waits for it nor changes conclusion because of it, while the job retains its own unmasked result. Workspace build, production-site, and 100%-per-file coverage failures make the native job fail. The broader static, documentation, package, and built-artifact portability inventory remains observational. Linux remains the owner of duplicate lint and snapshot enforcement, while native Windows independently enforces supported-source coverage.

View File

@@ -14,7 +14,7 @@ Status: implemented
[ci.yml](../../../../.github/workflows/ci.yml) 中必需的 `windows` 作业仍是在 `ubuntu-latest` 上运行的 `windows node 24 / wine blocking`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org归档通过该校验前绝不会投入使用。稳定的 `windows` 作业 ID 仍是 `all checks passed` 的依赖项。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,60 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动一个常规且独立的 `windows-native` 作业,名称为 `windows node 24 / native complete`。该作业为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行 `pnpm run check:ci:windows-complete`。门禁卡住时,120 分钟超时会为其设定上限,同时不把实测性能目标当作正确性截止时间。
原生作业被刻意排除在 `all-checks-passed.needs` 之外,且不使用 `continue-on-error`:聚合流程既不等待它,也不会因它改变结论;该作业则保留自身未被掩盖的结果。工作区构建、生产网站和逐文件 100% 覆盖率检查失败会使原生作业失败。更广泛的静态检查、文档、包和构建产物可移植性清单仍作为观测项报告。重复的 lint 与快照强制检查仍由 Linux 负责,原生 Windows 则独立强制执行受支持源码覆盖率。