Merge codex/subprocess-win32-process-primitives into codex/subprocess-native-containment

This commit is contained in:
pku-xht
2026-08-20 18:45:37 +08:00
222 changed files with 7434 additions and 806 deletions
@@ -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-24-web-config-tree-boot-and-transport-layering.md
2026-07-24-web-config-tree-boot-and-transport-layering.md: f397ca6a25cffa0ba709d0a8e84717a1c228b951
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: e1cde78bad16d618913e52894eff1271dac74be1
2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f
2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7a175b44fa8806b4b73eca538f32f16aa778fdee
@@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state.
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route.
**Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`.
@@ -18,7 +18,7 @@ Status: implemented
**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer``register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy``toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、index tap `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer``register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy``toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行`onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。
**包出口纪律。** modules 包只暴露 `.`node 半)与 `./client`(完整浏览器半:`ClientModuleSystem``parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__``./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`
@@ -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-credential-boundaries-and-atomic-registration.md
2026-07-30-credential-boundaries-and-atomic-registration.md: 69ddfdcf3cd6c15218c672d43cc5aadfd48a0223
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: d234a073467c743aa90e8e60f1f96f4d288fd651
2026-07-30-credential-boundaries-and-atomic-registration.md: 95c16ef2bf3d82d7b8b53e3975ece3f0c63402f3
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 56ce0d8cbfc0f9023aca406ad43797e7347fdc77
@@ -8,7 +8,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m
## Problem
The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/reference-updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
@@ -22,7 +22,7 @@ Two request-path defects sat beside them. DeepSeek resolved connection and crede
**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/reference-updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
## Alternatives considered
@@ -10,7 +10,7 @@ Status: implemented
凭据路径越过它自己划下的边界发生了泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'``writable: false``set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。
存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true``set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true``set` 总是抛错,`credentials/reference-updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
@@ -26,7 +26,7 @@ Status: implemented
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/reference-updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
## 曾考虑的替代方案
@@ -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-08-10-remote-event-delivery.md
2026-08-10-remote-event-delivery.md: 9c2b5087772a5a343514d1766a14e90edb261813
2026-08-10-remote-event-delivery.zh.md: 213715f5d9efcc11290059e5c5b0c06bbd7e255d
2026-08-10-remote-event-delivery.md: ee3d9884b53f5fa5d0b0072660888c5f4d283b1b
2026-08-10-remote-event-delivery.zh.md: a51a0ade174e70177f438a4bbc6bcd452807f436
@@ -8,7 +8,7 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md)
[Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy.
The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol.
@@ -75,7 +75,7 @@ Delivery shares no implementation with the cordis event system: one-way only, no
export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
'credentials/reference-updated',
'llm/adapters-updated',
'settings/document-updated',
] as const
@@ -8,7 +8,7 @@ Status: implemented
[Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。
Host 拥有 `agent-preset/selected``commands/change``credentials/updated``llm/adapters-updated``settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。
Host 拥有 `agent-preset/selected``commands/change``credentials/reference-updated``llm/adapters-updated``settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。
那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。
@@ -75,7 +75,7 @@ $dispatch(event: string, args: readonly unknown[]): void
export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
'credentials/reference-updated',
'llm/adapters-updated',
'settings/document-updated',
] as const
@@ -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-13-credential-records-and-authorization-flows.md
2026-08-13-credential-records-and-authorization-flows.md: a52d85854c8c660b4965d07a13a2dbfefbac5723
2026-08-13-credential-records-and-authorization-flows.zh.md: 1b6960cd9cff5e7320816a1528e3c621fea41691
@@ -0,0 +1,68 @@
# Agent Note: Credential records and authorization flows
Status: implemented
English | [中文](2026-08-13-credential-records-and-authorization-flows.zh.md)
## Problem
The harness credential plane could only express one kind of secret: a value behind an environment-variable name. `CredentialRef` is a POSIX identifier, resolution layers the process environment over a managed file and `.env` fallbacks, and every consumer reads it per operation. That covers an API key exactly and covers nothing else.
Some credentials are not values a deployment can be told to store. They are obtained — by a conversation with a human who opens a page, approves an account, and pastes a code back — and what comes out is a token document with a refresh half that rotates behind the user's back. pi-ai models this directly (`Credential = ApiKeyCredential | OAuthCredential`, an app-owned `CredentialStore`, `Models.login()`), and the harness had nowhere to put any of it. `PiAiAdapter` built its collection with `createModels()` and no options, so the store was pi-ai's in-memory default: empty at every boot, discarded on every configuration change. `openai-codex`, whose only method is OAuth, therefore failed every request with `Provider is not configured` — [withheld from the directory](../bug-fix/2026-08-13-oauth-only-providers-withheld.md) as a release fix, which removed the broken offer without adding the capability.
Two further gaps followed from the same missing plane. A provider's own ambient discovery ran against the raw process environment, so a key held by the credential seam was invisible to it and a local credential file was never even looked for. And a login had no surface to run from, because nothing in the harness could ask a human a question on a plugin's behalf.
## Decision
Three seams, each owning one question, and every pi-ai concept behind an adapter inside `llm-pi-ai`.
**`dsh-credentials` grows a second key space.** A `CredentialRef` answers *what is behind this environment-variable name*; a `CredentialKey` answers *what credential does this plugin hold for this id*. The record union is `{ kind: 'api-key', key?, env? } | { kind: 'grant', payload }` — the api-key half structural because the seam can describe it, the grant half opaque because a library that owns a token format keeps owning it. The only constraint on a payload is that it survives a JSON round trip, enforced on the way in and on the way out.
The key is `<scope>/<id>` where the scope is the **owning plugin's registered name**, not the provider's. A user knows `openai-codex`; which adapter family answers for the bytes inside that record is exactly what a bare provider name loses. Two plugins serving the same provider name would read each other's payload, and a record left by an uninstalled plugin could not be told from a live one. The `/` also keeps the two grammars disjoint, so the key spaces cannot collide. This assumes one adapter registers a given provider route, which the LLM registry already enforces.
Records do not layer. There is no environment an authorization grant could be read from, so presence of the record is the whole fact, and the empty-value rule that governs references does not apply: an `api-key` record carrying neither a key nor env states that its owner confirmed ambient authentication, which is configured.
**`dsh-authorization` owns the conversation, never the protocol.** A plugin that knows how to obtain its own credential registers a flow under the `CredentialKey` that flow writes. The seam runs one attempt per key, routes a neutral vocabulary of notices and prompts, and settles. A second authorization protocol arrives as another flow rather than as another seam, and a surface that renders one flow renders all of them.
Two choices carry the weight:
- **The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms a commit it observed during the attempt — presence alone would let a re-authorization pass a stale record off as fresh — and refuses a flow that resolved without one. This is what lets `Models.login()` — which persists through the store adapter as part of logging in — stay the single writer, instead of the credential being copied back out and written a second time.
- **The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the page that asked, a headless caller supplies an interaction that declines, and there is no ambient provider to be absent or ambiguous between two open tabs.
**`llm-pi-ai` holds all three translations.** `credentialStoreFrom` maps pi-ai's `CredentialStore` onto records; `authContextFrom` answers pi-ai's ambient questions from the credential seam, then the launch environment, with file existence checked against the host process's filesystem; `registerPiAiFlows` restates pi-ai's `AuthEvent`/`AuthPrompt` in the neutral vocabulary and runs `Models.login()`. Every collection is built with the first two, which is what keeps a signed-in provider signed in across the collection rebuild a configuration change causes. With a posture that works, the directory stops withholding OAuth-only routes and `openai-codex` is offered again.
The credential plane stays optional, as it already was for reference resolution. Reads answer "nothing stored" without a credentials service, because such a composition genuinely holds no credential; writes refuse by name, because a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam through `ctx.inject`, so a headless or ACP composition mounts with no sign-in and nothing else changed.
### Two mechanisms the seams needed underneath
`withFileLock` takes a per-call wait limit. pi-ai runs an OAuth refresh *inside* `credentials.modify()`, so the record write path holds the lock across a network round trip; the 2s default was chosen for a render-and-rename and would fail every other writer of the document. The retry cadence stays fixed — that is a protocol constant — while the wait is sized by the longest holder a contender can meet: refs and records share one file and one lock, so every writer of the document (`DOCUMENT_LOCK_WAIT_MS`, reference writes and record deletes included) waits an OAuth refresh out, not only the mutation that runs one.
The seam's edges get the same discipline as its write path. A prompt decline is an outcome, not a breakage — an interaction rejects with `AuthorizationDeclinedError` and the attempt settles `cancelled` — while a notice a surface cannot render is logged and lost rather than failing the flow, and `authorization/settled` fans out with contained listener failures on the credentials seam's terms. On the store side, an api-key record is admitted before it is rendered (what `parseRecord` refuses at the next boot is refused at the write), and `llm-pi-ai` asks `isCredentialKeySegment` before addressing a record, so an arbitrary hand-declared route key reads as "nothing stored" instead of throwing mid-resolution.
Withdrawal settles an attempt whether or not its flow reacts to the signal. A flow is supposed to stop when its signal fires, but one that does not would hold its key for the life of the process, and a wedged key is indistinguishable from a busy one from outside. The orphaned run is left to finish on its own.
## Alternatives considered
- **Putting the pi-ai `CredentialStore` shape into the seam itself.** It is the shape that works and it is already designed. It also names `api_key`/`oauth` as the world's two credential kinds and keys by provider id, which is the ownership loss above; a second adapter family would have to pretend to be pi-ai to participate. The record union is deliberately one step more abstract in exactly two places — the key, and the opacity of a grant.
- **A dedicated login-interaction seam beside `user-questions`.** Authorization prompts look like questions, and reusing `ctx.userQuestions` was tempting. But that seam is built for a model's tool call to pause on an agent's behalf: it validates the calling agent, refuses a delegated caller, and has one ambient UI provider. An authorization prompt has no agent, must reach the configuration page that started it, and can be withdrawn per prompt by a browser callback winning a race. The vocabularies overlap; the lifecycles do not.
- **Reading `~/.codex/auth.json` into a store.** It makes Codex work without any of this, and pi-ai would own the refresh. It also binds the harness to another tool's private file format for one provider, and leaves every other login unbuilt.
- **Joining a second `begin()` to the attempt already running.** Friendlier than refusing, until two humans are answering the same flow's questions. Refusal with `inFlight` on the entry lets a surface disable the button rather than discover the state by error.
- **Keeping the OAuth-only withholding as a safety net.** It would now hide a provider that works. The predicate is deleted rather than left inert; `docs/subsystems/credentials.md` and the package READMEs carry what replaced it.
## Consequences
`.credentials.yaml` gains a version and two sections. A boot upgrades the recognized pre-release flat layout in place — an all-string flat mapping nests verbatim under `refs:` under the writer lock — because a key stored through the Models page by an earlier internal build must survive the layout change without a hand edit and without its model requests failing. Any flat shape the recognizer cannot prove it understands keeps the by-name refusal with the hand migration stated in the message; the parser itself still reads exactly one layout, and the migration step retires with the pre-release stance at the first tagged release. Every fixture in the repo that wrote the flat document was rewritten; the llm suites' fixtures were missed by the record change itself and fixed here.
`openai-codex` returns to the provider picker and to the Models page directory. Signing in is offered for every installed provider that ships a login, which today is all 38 — 31 collect a key through pi-ai's own prompt, six offer that beside a subscription login, and Codex offers only the subscription login.
What this does not yet include is the surface: the wire contract that carries notices and prompts to the browser, and the Models-page control that starts a login. Until that lands, the flows are reachable only in-process, and a deployment still configures a key by typing it into the settings form.
Two limits are recorded in the package READMEs rather than fixed. An attempt is not durable, so reloading the page mid-login abandons it. And signing out is `deleteRecord`, which forgets the record locally without telling the issuer; a provider needing a server-side revoke has nowhere to declare it.
## Testing
The seam's suite pins the lifecycle it owns: single-flight refusal and release, withdrawal before the flow starts and during it, a flow that ignores its signal, the commit confirmation, and the settlement event including the `failed` case a caller sees as a thrown error. The invariant companion pins that a settled key is a free key, because a wedged one is otherwise invisible.
`llm-pi-ai` covers the three translations against a real `$DSH_HOME` document — an api-key credential field by field, an OAuth credential verbatim including its refresh half, a foreign plugin's record skipped by scope, and the write refusal without a credentials service — plus every `AuthEvent` and `AuthPrompt` member restated, with `Models.login()` mocked at the collection boundary since a real one opens a browser. Two real-composition tests boot the plugin with and without the authorization seam.
The `models-settings` and `onboarding-usable-provider` web e2e goldens regain exactly the `openai-codex` option line they lost when it was withheld — the whole assembled-application difference this change makes today, because the Models page has no login control yet to record.
@@ -0,0 +1,68 @@
# Agent Note: 凭据记录与授权 flow
Status: implemented
[English](2026-08-13-credential-records-and-authorization-flows.md) | 中文
## Problem
harness 的凭据平面只能表达一种机密:藏在某个环境变量名之后的值。`CredentialRef` 是一个 POSIX 标识符,解析时按进程环境、受管文件、`.env` 回退分层,每个消费方按操作读取。这恰好覆盖 API key,此外什么都不覆盖。
有些凭据不是"可以让部署方去存"的值。它们是被**取得**的——与人对话:对方打开页面、批准账号、把码粘回来——产出的是一份带 refresh 半边、会在用户背后轮换的 token 文档。pi-ai 直接建模了这一点(`Credential = ApiKeyCredential | OAuthCredential`、由应用拥有的 `CredentialStore``Models.login()`),而 harness 无处安放其中任何一项。`PiAiAdapter` 用不带参数的 `createModels()` 构造集合,于是那个 store 就是 pi-ai 的内存默认实现:每次启动为空,每次配置变更被丢弃。只以 OAuth 认证的 `openai-codex` 因此每个请求都以 `Provider is not configured` 失败——[被目录withheld](../bug-fix/2026-08-13-oauth-only-providers-withheld.md) 作为发布前修复,它移除了错误的供给,但没有补上能力。
同一处缺失还带来另外两个缺口。提供方自带的凭据发现是对着裸进程环境跑的,因此凭据 seam 保管的密钥对它不可见,本地凭据文件更是从未被查找过。而登录没有任何界面可以发起,因为 harness 里没有任何东西能代替插件向人发问。
## Decision
三个 seam,各自拥有一个问题;所有 pi-ai 概念都藏在 `llm-pi-ai` 内部的适配器背后。
**`dsh-credentials` 长出第二个键空间。** `CredentialRef` 回答*这个环境变量名背后是什么*`CredentialKey` 回答*这个插件为这个 id 持有什么凭据*。记录联合体是 `{ kind: 'api-key', key?, env? } | { kind: 'grant', payload }`——api-key 那半是结构化的,因为 seam 能描述它;grant 那半是不透明的,因为拥有 token 格式的库应当继续拥有它。对 payload 的唯一约束是它能原样通过一次 JSON 往返,读写两个方向都会校验。
键的形式是 `<scope>/<id>`,其中 scope 是**拥有该记录的插件的注册名**,不是提供方名。用户知道的是 `openai-codex`;究竟哪个 adapter 家族为记录里的字节负责,恰恰是裸提供方名会丢掉的信息。服务同一个提供方名的两个插件会互相读到对方的 payload,已卸载插件留下的记录也无法与仍在使用的区分开。`/` 同时让两种文法互斥,两个键空间因此不可能相撞。这以"同一个 provider 路由只由一个 adapter 注册"为前提,而 LLM 注册表本就强制了这一点。
记录不分层。授权 grant 没有任何"环境"可供读取,因此记录是否存在就是全部事实,管辖引用的空值规则在此不适用:一条既无 key 也无 env 的 `api-key` 记录,陈述的是其拥有者确认了环境认证可用,这属于已配置。
**`dsh-authorization` 拥有对话,从不拥有协议。** 知道如何取得自己那份凭据的插件,以该 flow 写入的 `CredentialKey` 注册。seam 对每个键同时只跑一次尝试,路由一套中立的 notice/prompt 词汇,然后结算。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam;能渲染一个 flow 的界面就能渲染全部 flow。
两个选择承担了主要分量:
- **写入由 flow 拥有。** `run()` 返回即表示记录已通过 `ctx.credentials` 提交;seam 核实的是本次尝试期间观察到的提交——只看记录存在与否,会让重新授权把陈旧记录冒充成新鲜的——并拒绝返回时没提交记录的 flow。正是这一点让 `Models.login()`——它把持久化当作登录的一部分,经由 store 适配器完成——保持为唯一写入方,而不是把凭据复制出来再写第二遍。
- **交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个页面,无头调用方传入一个直接拒绝的交互实现,也不存在"环境提供方缺席"或"该归两个已打开标签页中哪一个"的问题。
**三处翻译全都留在 `llm-pi-ai`。** `credentialStoreFrom` 把 pi-ai 的 `CredentialStore` 映射到记录;`authContextFrom` 先查凭据 seam 再查启动环境来回答 pi-ai 的环境提问,文件存在性则按宿主进程的文件系统判断;`registerPiAiFlows` 把 pi-ai 的 `AuthEvent`/`AuthPrompt` 重述为中立词汇并运行 `Models.login()`。每个集合都用前两者构造,正是这一点让已登录的提供方在配置变更导致集合重建之后仍然处于登录状态。有了行得通的姿态之后,目录不再扣留仅 OAuth 的路由,`openai-codex` 重新被提供。
凭据平面仍是可选的,正如它在引用解析上一贯如此。没有凭据服务时读取回答"未存储",因为这样的组合确实不持有任何凭据;写入则指名拒绝,因为一次 grant 凭空蒸发的登录会先报告成功、再让每个请求失败。flow 注册通过 `ctx.inject` 限定在授权 seam 之下,因此 headless 或 ACP 组合挂载后没有登录能力,其余一切不变。
### seam 底下需要的两处机制
`withFileLock` 接受按调用声明的等待上限。pi-ai 在 `credentials.modify()` **内部**执行 OAuth 刷新,因此记录写入路径要跨越一次网络往返持锁;2 秒的默认值是按"渲染并 rename"的量级选的,会让该文档的每一个其他写入方失败。重试节奏保持固定——那是协议常量——而等待时长按争用方可能遇到的最长持锁方来定:refs 与 records 共享同一份文件、同一把锁,因此该文档的每一个写入方(`DOCUMENT_LOCK_WAIT_MS`,含引用写入与记录删除)都要等得起一次 OAuth 刷新,而不只是执行刷新的那个 mutation。
seam 的边缘与写入路径同一纪律。prompt 被拒是结果而非故障——交互实现以 `AuthorizationDeclinedError` 拒绝,尝试以 `cancelled` 结算;渲染不了 notice 的界面只丢那条 notice、绝不拖垮 flow`authorization/settled` 按 credentials seam 的条款以遏制方式分发监听器故障。存储侧,api-key 记录在渲染前先行准入(`parseRecord` 下次启动会拒绝的,写入时就拒绝),`llm-pi-ai` 在寻址记录前先问 `isCredentialKeySegment`,任意手写路由键读作「没有存储任何东西」,而不是在解析途中抛错。
撤销会结算一次尝试,无论其 flow 是否响应信号。flow 本应在信号触发时停止,但不停止的那个会把键占到进程结束,而被卡住的键从外部看与忙碌中的键无法区分。被遗弃的执行体听任其自行结束。
## Alternatives considered
- **把 pi-ai 的 `CredentialStore` 形状直接放进 seam。** 那是行得通且已经设计好的形状。它同时把 `api_key`/`oauth` 定为世上仅有的两种凭据类别,并以 provider id 为键,也就是上文那种所有权丢失;第二个 adapter 家族将不得不假装自己是 pi-ai 才能参与。记录联合体刻意只在两处更抽象一步——键,以及 grant 的不透明性。
- **在 `user-questions` 旁再建一个专用登录交互 seam。** 授权提示看起来就像问题,复用 `ctx.userQuestions` 很有诱惑力。但那个 seam 是为"模型的工具调用代表 agent 暂停"而建的:它校验调用方 agent、拒绝被委派的调用方、只有一个环境 UI 提供方。授权提示没有 agent,必须抵达发起它的配置页面,还可能被浏览器回调赢得竞速后按单个提示撤下。词汇重叠,生命周期不重叠。
- **把 `~/.codex/auth.json` 读进一个 store。** 这能让 Codex 在不做上述任何事的情况下工作,刷新也由 pi-ai 负责。它同时为了一个提供方把 harness 绑死在另一个工具的私有文件格式上,且其余所有登录仍然没有着落。
- **让第二次 `begin()` 并入已在运行的尝试。** 比拒绝更友好,直到两个人在回答同一个 flow 的问题为止。以 entry 上的 `inFlight` 配合拒绝,界面得以禁用按钮,而不是靠报错才发现状态。
- **把"仅 OAuth 则扣留"当安全网保留。** 它现在会藏起一个能用的提供方。该判定被删除而不是留成惰性代码;`docs/subsystems/credentials.md` 与包 README 承载了取代它的内容。
## Consequences
`.credentials.yaml` 增加了版本与两个分区。启动时会把能精确识别的发布前扁平布局原地升级——全字符串的扁平 mapping 在写锁下逐字下沉到 `refs:` 之下——因为早期内测构建经模型页面存下的密钥必须在布局变更后继续可用,不能要求手工编辑,也不能让模型请求失败。识别器无法证明自己理解的扁平形态仍被指名拒绝,迁移办法写在报错信息里;解析器本身始终只读一种布局,迁移步骤将随发布前立场在首个正式版本时移除。仓库中所有写扁平文档的 fixture 都已改写;llm 各套件的 fixture 被记录改动本身漏掉了,在此补上。
`openai-codex` 回到提供方选择器与 Models 页目录。凡是自带登录的已安装提供方都会得到登录入口,而今天这是全部 38 个——31 个经 pi-ai 自己的提示收取密钥,6 个在此之外还提供订阅登录,Codex 只提供订阅登录。
尚未包含的是界面:把 notice 与 prompt 送到浏览器的 wire 契约,以及 Models 页上发起登录的控件。在那之前,flow 只能在进程内触达,部署方仍然通过在设置表单里输入密钥来配置。
有两项限制记在包 README 里而非就地修复。一次尝试不可持久,登录途中刷新页面会丢弃它。登出即 `deleteRecord`,它只在本地遗忘而不通知签发方;需要服务端吊销的提供方无处声明这一点。
## Testing
seam 自己的套件钉住它拥有的生命周期:单飞的拒绝与释放、flow 启动前与进行中的撤销、一个忽略自身信号的 flow、提交核实,以及包含"调用方看到的是抛出错误"那种 `failed` 情形的结算事件。invariant companion 钉住"已结算的键就是空闲的键",因为被卡住的键否则不可见。
`llm-pi-ai` 针对一份真实的 `$DSH_HOME` 文档覆盖三处翻译——逐字段的 api-key 凭据、连 refresh 半边一起原样保存的 OAuth 凭据、按 scope 跳过的他插件记录,以及没有凭据服务时的写入拒绝——外加每一个 `AuthEvent``AuthPrompt` 成员的重述;`Models.login()` 在集合边界处被 mock,因为真实登录会打开浏览器。两个真实组合测试分别在挂载与不挂载授权 seam 的情况下启动插件。
`models-settings``onboarding-usable-provider` 两条 web e2e golden 恰好收回了被扣留时失去的那一行 `openai-codex` 选项——这是本次改动今天在装配后的应用上造成的全部差异,因为 Models 页还没有可录制的登录控件。
@@ -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-19-web-index-injection-table.md
2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2
2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f
@@ -0,0 +1,30 @@
# Agent Note: structured index injection table (webserver/index-inject)
Status: implemented
English | [中文](2026-08-19-web-index-injection-table.zh.md)
## Problem
The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `<script src>` tags, the `__DSH_BOOT__` graph global) and ui-theme's first-paint theme script. The old mechanism was `webServer.tapIndex(html => html)` string transforms: each registrant regex-located `<head>`/`<body>` and spliced HTML on its own. The static worker deployment (the page is a build artifact; the host tree runs in a Web Worker) has no serve-HTML step at all, so the worker side hand-copied the same data into its `/__boot__` payload (`graph` + `theme` via `ctx.get`), and the page side re-implemented what the taps did (a facade installer, a theme applier, a preload loop) — one boot semantics, three implementations.
## Decision
Make the injection surface an event over pure data: the webserver declares the `webserver/index-inject` event and the `IndexInjection` row union (`global`/`script`/`script-src`/`style`/`html`, `head|body` placement). A plugin that wants to inject subscribes and pushes rows; every collection (`collectIndexInjections()`) is a fresh emit, so subscribers read live state at emit time (module graph, theme preference — no re-registration staleness), and a subscription dies with its fiber.
One table, two renderers: the served form's `webServer.renderIndex(html)` renders rows into index.html deterministically (head rows after the opening head tag, body rows after the opening body tag; `<` JSON-escaped in global values, attribute-escaped `src`); the worker form's `/__boot__` payload is `{ injections }`, executed row by row by a small page-side interpreter (set global / create script element / load external through the tunnel's `loadBundle` / mount style and markup). Rows are pure JSON data — that is the both-ends-equivalent discipline.
`tapIndex`/`applyIndexTaps` survive as the raw-HTML escape hatch, applied after row rendering; every internal consumer moved to the event.
## Consequences
- client-modules and ui-theme no longer regex-edit HTML; the worker's `readBootPayload` service-poking (`clientModules`, `settings`, theme constants through `loader.load`) is deleted; the page-side `installModuleLoaderFacade`, `applyBootTheme`, and `PARSER_PRELOAD_IDS` re-implementations retire.
- Ordering: across subscribers, subscription order (same as the old tap order); within one subscriber, push order — modules itself guarantees queue → preloads → global.
- The served rendering of the manifest global changed from `window.__DSH_BOOT__ =` to `globalThis["__DSH_BOOT__"] =`; no committed snapshot expectation carries that text, so none needed re-recording.
- New model-visible or page-visible boot inputs extend the row union; no new tap consumers.
## Alternatives considered
- **Keep tap functions, add a worker-side renderer that re-runs them over a fake document** — rejected: taps are opaque `html => html` closures, so the worker cannot serialize or replay them without shipping a DOM emulation into the boot path.
- **A registration-style table (`registerInjection(row): dispose`)** — rejected for the two problems the event dissolves: rows staled against live state (theme preference, module graph) unless every producer re-registered on change, and every producer owned one more disposer. The per-emit pull reads fresh state with fiber-scoped cleanup for free.
- **Deleting `tapIndex` outright** — rejected: an escape hatch for raw HTML transforms costs nothing while the table is young, and external compositions may have transforms no row kind expresses yet.
@@ -0,0 +1,30 @@
# Agent Note: 结构化 index 注入表(webserver/index-inject 事件)
Status: implemented
[English](2026-08-19-web-index-injection-table.md) | 中文
## Problem
Web 壳的启动 HTML 需要三类注入:client-modules 的引导协议(`__ModuleLoader__` 注册队列内联脚本、parser 阻塞的 preload `<script src>``__DSH_BOOT__` 全局图)与 ui-theme 的首帧主题脚本。旧机制是 `webServer.tapIndex(html => html)` 字符串变换:每个注册方各自用正则找 `<head>`/`<body>` 改 HTML。静态 worker 部署(页面是构建产物、host 树在 Web Worker 里)没有「服 HTML」这一步,于是 worker 侧只能在 `/__boot__` 载荷里手工重抄同一批数据(graph + theme,经 `ctx.get` 硬掏),页面侧再用手写代码(facade 安装、theme 应用、preload 循环)把 tap 干的事重演一遍——同一份启动语义存在三份实现。
## Decision
注入面事件化、数据化:webserver 声明 `webserver/index-inject` 事件与纯数据行类型 `IndexInjection``global`/`script`/`script-src`/`style`/`html``head|body` 定位)。想注入的插件订阅事件、往表里 push 行;每次收集(`collectIndexInjections()`)都是一次全新 emit,订阅方现读现填(模块图、主题偏好天然新鲜,无重注册问题),订阅随 fiber 销毁自动摘除。
一张表两个渲染器:served 形态 `webServer.renderIndex(html)` 确定性把行渲染进 index.htmlhead 行插 head 首、body 行插 body 首,全局值 JSON `<` 转义、src 属性转义);worker 形态 `/__boot__` 载荷就是 `{ injections }`,页面侧小解释器逐行执行(设全局 / 建脚本元素 / 经 tunnel loadBundle 载外链 / 挂样式与 DOM)。行是纯 JSON 数据,这是双端等价的纪律。
`tapIndex`/`applyIndexTaps` 保留为原始 HTML 变换的逃生口,在行渲染之后执行;内部消费者全部迁走。
## Consequences
- client-modules 与 ui-theme 不再各自正则改 HTMLworker 侧 `readBootPayload``ctx.get` 手掏(clientModules、settings、theme 常量 loader.load)删除;页面侧 `installModuleLoaderFacade``applyBootTheme``PARSER_PRELOAD_IDS` 三份重抄退役。
- 顺序语义:跨订阅方按订阅注册顺序(与旧 tap 顺序一致),单订阅方内按 push 顺序;modules 自己保证 队列→preload→全局 三行有序。
- `__DSH_BOOT__` 的 served 渲染文本从 `window.__DSH_BOOT__ =` 变为 `globalThis["__DSH_BOOT__"] =`;已核实无已提交快照期望含此文本,无需重录。
- 新的模型可见/页面可见注入一律走行类型扩展,不再新增 tap 消费者。
## Alternatives considered
- **保留 tap 函数、worker 侧对假 document 重放**——否决:tap 是不透明的 `html => html` 闭包,worker 无法序列化或重放,除非把 DOM 仿真塞进启动链。
- **注册表式(`registerInjection(row): dispose`)**——否决于事件天然化解的两个问题:行数据会相对活状态(主题偏好、模块图)过期,除非每个生产者变更时重注册;且每个生产者多背一个 disposer。按次 emit 的拉取免费获得新鲜读取与 fiber 级清理。
- **直接删除 `tapIndex`**——否决:表还年轻,原始 HTML 变换的逃生口零成本,外部组合可能还有行类型暂不能表达的变换。
@@ -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-08-10-pre-plugin-theme-bootstrap.md
2026-08-10-pre-plugin-theme-bootstrap.md: 7f5c57316d7dd022b08918459282a6f6128eb93b
2026-08-10-pre-plugin-theme-bootstrap.zh.md: 7152bf8dc2bbfae2a671c4da899cbebda0bd4b39
2026-08-10-pre-plugin-theme-bootstrap.md: cf3b2d5b6c8e8b102df6a37bab0fc63d81d8736b
2026-08-10-pre-plugin-theme-bootstrap.zh.md: 2836f7b6d32528796bf64bf708229d5677866364
@@ -12,7 +12,7 @@ The web shell renders `Loading plugins…` before the browser-side plugin tree a
## Decision
ui-theme's host half transforms each index HTML document through `ctx.webServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `<body>` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.
ui-theme's host half answers every `webserver/index-inject` collection with one body-placed script row (`bootThemeInjection`), which `renderIndex` renders as a synchronous inline script immediately after the opening `<body>` tag. The subscription is unconditional — a composition without a web server never emits the event, so ui-theme still activates and contributes nothing. When the HTML parser executes the script, the body exists, but the shell's module script and framework-free boot page have not yet run.
The host half registers the [`ui-theme.preference` settings section](2026-08-06-host-backed-web-preferences.md) when a settings provider exists. For each index response, it embeds that schema-validated built-in preference in the inline script; without a settings provider or active registration, it embeds the `system` default. The browser resolves `system` through `prefers-color-scheme`, falling back to light when `matchMedia` is unavailable. It writes only the two pieces of DOM state that ThemePresenter later owns: `document.documentElement.style.colorScheme` and `body[data-ds-dark-theme]`.
@@ -12,7 +12,7 @@ Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。ui-theme
## 决策
ui-theme 的主机侧通过 `ctx.webServer.tapIndex()` 转换每份 index HTML,在 `<body>` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme,但不会安装转换。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。
ui-theme 的主机侧以一条 body 定位的 script 行(`bootThemeInjection`)回应每次 `webserver/index-inject` 收集,`renderIndex` 把它渲染为 `<body>` 起始标签后紧接一段同步内联脚本。订阅是无条件的——没有 web server 的组合根本不会 emit 该事件,ui-theme 照常激活且不贡献任何行。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与不依赖框架的启动页尚未运行。
settings provider 存在时,主机侧会注册 [`ui-theme.preference` settings 分节](2026-08-06-host-backed-web-preferences.md)。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 `system`。浏览器通过 `prefers-color-scheme` 解析 `system`,不支持 `matchMedia` 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:`document.documentElement.style.colorScheme``body[data-ds-dark-theme]`
@@ -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-08-13-oauth-only-providers-withheld.md
2026-08-13-oauth-only-providers-withheld.md: 8c85c2900fd0af7c97a3ac8f04d3c6a2f469f8b0
2026-08-13-oauth-only-providers-withheld.zh.md: 8f05d096a5d474407585bcbd84bd47da8a1abb2e
2026-08-13-oauth-only-providers-withheld.md: 138cae6f56c0d8355d8db3bddd27aede7caf5b09
2026-08-13-oauth-only-providers-withheld.zh.md: da8326d1ea1772623367a4a6a1477686b41d268e
@@ -38,6 +38,8 @@ Resolution is untouched. A profile naming `apiKeyEnv` on an OAuth-only route sti
Two adjacent gaps remain and are recorded in the package README: a route naming no credential still resolves through the catalog provider's own discovery, which reads process environment variables only — not `~/.aws/credentials`, and not the harness credential seam — and the resulting failure is still the catch-all `PI_AI_ERROR`.
The withholding has since been retired: [credential records and authorization flows](../architecture/2026-08-13-credential-records-and-authorization-flows.md) gave the adapter a durable credential store and a login flow, so `openai-codex` is offered again and `catalogProviderTakesApiKey` is gone. The two boundaries below are what kept that reversal to one predicate and one directory filter.
## Testing
Package tests pin both halves of the union: the withheld route is absent from `listConfigurableProviders()` while `anthropic` and `openai` stay, and a stored `openai-codex` profile still produces a full entry with `declared: false`. The existing resolution tests are unchanged and still pass, which is what shows the withholding did not narrow what a hand-written profile can serve. The `models-settings` and `onboarding-usable-provider` web e2e goldens lost exactly the `openai-codex` option line, recorded against the real assembled application.
@@ -38,6 +38,8 @@ resolution 未被触动。在仅 OAuth 的路由上指定 `apiKeyEnv` 的 profil
两处相邻缺口仍在,并记录在包 README 中:不指定凭据的路由仍走 catalog 提供方自带的发现,而它只读进程环境变量——不读 `~/.aws/credentials`,也不读 harness 凭据 seam——且由此产生的失败仍是兜底的 `PI_AI_ERROR`
该扣留其后已被撤销:[凭据记录与授权 flow](../architecture/2026-08-13-credential-records-and-authorization-flows.md) 为适配器补上了可持久的凭据存储与登录 flow,因此 `openai-codex` 重新被提供,`catalogProviderTakesApiKey` 也已删除。下文那两条边界正是把这次反转限制在一个判定函数与一个目录过滤器之内的原因。
## 测试
包测试钉住联合的两半:不予提供的路由不出现在 `listConfigurableProviders()` 中,而 `anthropic``openai` 仍在;已存储的 `openai-codex` profile 仍产出完整条目且 `declared: false`。既有的 resolution 测试未改动且依然通过,这正是「不提供」没有收窄手写 profile 可服务范围的证据。`models-settings``onboarding-usable-provider` 两条 web e2e golden 恰好各少了 `openai-codex` 这一行选项,录自真实装配的应用。
@@ -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-19-high-cache-hit-decimal-display.md
2026-08-19-high-cache-hit-decimal-display.md: 952d838fdf330175915e13ef767fd80d145506b2
2026-08-19-high-cache-hit-decimal-display.zh.md: 4a72becd608e756507f3d5a6fd051b4b2b437964
@@ -0,0 +1,50 @@
# Agent Note: High cache-hit decimal display
Status: implemented
English | [中文](2026-08-19-high-cache-hit-decimal-display.zh.md)
## Problem
The Web conversation stats line rounded every non-empty cache-hit ratio to an integer. Once the actual ratio passed 99%, the display hid further progress, and a ratio of at least 99.5% appeared as 100% even while uncached input or cache writes remained.
Users therefore could not distinguish a nearly complete cache hit from a true full hit.
## Decision
`StatsLine` continues to derive the ratio from the whole-session `tokenUsage` projection owned by `@deepseek-ai/dsh-token-meter`; the projection remains the only owner of the uncached-input, cache-read, cache-write, and output counts ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). The presentation layer changes only the text inserted into the existing `stats.cacheHit` locale template.
| Actual ratio | Display |
|---|---|
| No billed input | Cache-hit group omitted |
| Integer rounding is below 100% | Rounded integer |
| Non-full ratio whose current rounding is 100% | Minimum decimal precision whose rounded result is below 100% |
| 100% | `100%` |
Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` uses exact small-factor comparisons over the safe-integer token counts, then scales the near-full gap only while the intermediate remains within that range. This avoids floating-point tie errors without imposing a precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip.
## Ownership and lifecycle
Token-meter continues to fold usage from the complete durable session log. `StatsLine` performs a synchronous display derivation whenever the standard projection value changes. It introduces no setting, stored percentage, event, wire field, client state, or recovery path.
Live updates, reload replay, and reconnect recovery all restore the same `tokenUsage` counts and run the same display function. A missing projection still omits every token group, and a zero input denominator still omits only the cache-hit group.
## Verification
The component spec pins the zero denominator, ordinary integer rounding, half-step rounding at several decimal precisions, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call.
## Alternatives considered
**Keep integer rounding for every ratio.** Rejected because it hides all movement above 99% and still reports some non-full hits as 100%.
**Truncate the high band to one decimal.** Rejected because `99.95%`, `99.995%`, and still closer ratios all collapse to `99.9%` instead of retaining the minimum precision that distinguishes them from a full hit.
**Cap precision and use a substitute such as `<100%`.** Rejected because the exact cumulative counts can produce the required numeric result, and a cap would make display behavior depend on an arbitrary presentation limit.
**Show one decimal at every ratio.** Rejected because the additional low-band motion adds noise and changes the established display where integer precision is sufficient.
**Persist a display percentage in token-meter.** Rejected because the projection already carries the exact counts, while presentation precision belongs to the Web stats line. A second stored value would duplicate derivable state and expand replay and wire responsibilities.
## Consequences
High cache-hit sessions remain visually stable until integer rounding would falsely report a full hit, then expose only the decimal places needed to preserve that distinction. Extremely close non-full ratios can therefore produce long decimal strings; this is the accepted cost of having no arbitrary precision cap or nonnumeric fallback. Every delivery and recovery path stays on the existing durable projection lifecycle.
@@ -0,0 +1,50 @@
# Agent Note: 高缓存命中率的小数显示
Status: implemented
[English](2026-08-19-high-cache-hit-decimal-display.md) | 中文
## 问题
Web 会话统计行会把所有非空缓存命中率舍入为整数。真实比率超过 99% 后,显示会隐藏后续提升;比率达到 99.5% 时,即使仍有未缓存输入或缓存写入,也会显示为 100%。
用户因此无法区分接近完整的缓存命中与真实满命中。
## 决策
`StatsLine` 继续从 `@deepseek-ai/dsh-token-meter` 所拥有的完整会话 `tokenUsage` 投影派生比率;该投影仍是未缓存输入、缓存读取、缓存写入与输出计数的唯一所有方([投影决策](../architecture/2026-07-29-projected-token-usage-and-request-context.md))。展示层只改变插入现有 `stats.cacheHit` locale 模板的文本。
| 真实比率 | 显示结果 |
|---|---|
| 没有计费输入 | 省略缓存命中分组 |
| 整数舍入结果低于 100% | 舍入后的整数 |
| 当前舍入结果为 100% 的非满命中 | 舍入结果低于 100% 所需的最少小数位 |
| 100% | `100%` |
所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%``99.49%` 仍显示为 `99%`,而 `99.5%``99.95%``99.995%` 分别保留一位、两位与三位小数。`StatsLine` 对安全整数 token 计数执行精确的小因子比较,并且只在中间值仍处于该范围内时缩放接近满命中的差值。该算法既避开浮点临界值误差,也不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。
## 归属与生命周期
token-meter 继续从完整持久会话日志折叠用量。标准投影值变化时,`StatsLine` 同步派生显示文本。本决策不引入设置、持久百分比、事件、协议字段、客户端状态或恢复路径。
实时更新、刷新回放与重连恢复都会还原同一组 `tokenUsage` 计数,并运行同一个显示函数。投影缺失时仍会省略全部 token 分组;输入分母为零时仍只省略缓存命中分组。
## 验证
组件测试固定了零分母、普通整数舍入、多个小数精度上的半步舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。
## 备选方案
**对所有比率继续使用整数舍入。** 不予采纳,因为它会隐藏 99% 以上的全部变化,并继续把部分非满命中显示为 100%。
**把高位区间向下截取到一位小数。** 不予采纳,因为 `99.95%``99.995%` 以及更接近满命中的比率都会坍缩为 `99.9%`,无法保留区分真实满命中所需的最少精度。
**限制精度并使用 `<100%` 等替代文案。** 不予采纳,因为精确累计计数能够产生所需的数值结果,而精度上限会让显示行为依赖任意的展示限制。
**所有比率都显示一位小数。** 不予采纳,因为低位区间的额外变化会增加无效抖动,并改变整数精度已经足够的既有显示。
**在 token-meter 中持久化显示百分比。** 不予采纳,因为投影已经携带精确计数,而展示精度属于 Web 统计行。第二个持久值会复制可派生状态,并扩大回放与协议职责。
## 后果
高缓存命中率会保持稳定的整数显示,直到整数舍入会错误地报告满命中;此时界面只展示维持区分所需的小数位。极接近满命中的非满比率可能因此产生较长的小数字符串,这是不设置任意精度上限或非数值回退所接受的代价。所有交付与恢复路径继续沿用既有持久投影生命周期。
@@ -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-19-web-markdown-wide-table-view.md
2026-08-19-web-markdown-wide-table-view.md: fc025f6755caa2ca160b02f0c03ae80fd04cc1c7
2026-08-19-web-markdown-wide-table-view.zh.md: 4d13a29f69fac31f9796dae064726d4d3a779ffd
@@ -0,0 +1,37 @@
# Agent Note: Web markdown tables fill the column by count, wide ones break out
Status: implemented
English | [中文](2026-08-19-web-markdown-wide-table-view.zh.md)
## Problem
`MarkdownText` rendered every GFM table at its natural width (`.tableScroll table { width: max-content; max-width: max-content }`, `packages/client/ui-primitives/src/markdown/MarkdownText.module.css`), so any table wider than the 748px message column could only be read through horizontal scrolling. A three-column table whose cells could comfortably wrap still forced a scroll, and a genuinely wide table could never use more than the message column even when the transcript around it had hundreds of spare pixels. Issue #1761 (with external feedback dsh-external/issues#520) asks for wrap-first adaptation and a wider view for tables that need it. The deepsuite chat product solved the same problem CSS-first; per review direction this change mirrors that solution instead of the interactive expand-dialog approach first drafted here.
## Decision
**Column count picks the sizing arm, statically, in the renderer.** `renderTable` reads the parsed column count (the `align` array, header-row fallback): tables under four columns — and any table inside a blockquote (`inBlockquote` render-context flag) — get the module's `tableFill` class, `table { width: 100%; max-width: none }`, filling the column with cells wrapping down to their existing `min-width: 100px` floor. Four-or-more-column tables keep the natural-width rules and instead carry the stable global hook class `md-table-wide` (the `md-code-block` precedent), so a hosting layout can widen them. This is deepsuite chat's discriminator (`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` plus its blockquote exemption) computed in the renderer, which already knows the column count. No measurement, observers, or interaction state anywhere.
**The chat transcript widens hooked tables with container-query CSS.** ChatView's `.scroll` declares `container-type: inline-size`, and `AssistantMarkdown.module.css` gives `.body :global(.md-table-wide)` the breakout: `--dsh-table-spare` is the per-side spare width `max(0px, (100cqw - --dsh-chat-content-width) / 2)`, `--dsh-table-lead` adds the wrapper's own indent (`min(--dsh-chat-content-width, 100cqw) - 100%`), and width/negative-margin/lead-padding combine so the wrapper's scroll area spans the full transcript while the table content keeps starting at the message column's left edge. `100cqw` is the CSS stand-in for deepsuite chat's JS-measured `--dsl-virtual-list-width`; the `max(0px, …)` clamp replaces its below-SM JS gate, degrading continuously to the plain in-column scroll when the transcript is narrower than the message column. Scoping the rule under `AssistantMarkdown .body` keeps tool cards, compaction rows, and every other `MarkdownText` surface at plain in-column behavior.
**The parity fixtures change deliberately.** The table-containing markdown-dom fixtures pin the discriminator: `tableScroll tableFill` for narrow and blockquote tables, `tableScroll md-table-wide` for wide ones, and a new `table-wide-and-blockquote` corpus document pins both arms of the blockquote exemption.
## Alternatives considered
**Overflow-measured chrome: a ResizeObserver-gated expand entry opening the table in a `Modal` wide view.** Implemented first, then rejected on review direction in favor of deepsuite chat parity: the CSS solution needs no per-table observers, no dialog state that the streaming finalize swap would drop, no label plumbing through the cordis-free package, and gives the wide view permanently instead of behind an interaction.
**`:has()`-based column counting in CSS, as deepsuite chat does.** Rejected: their wrapper is generic while this renderer already walks the table node, so the count is available statically; a class is cheaper than a `:has()` selector re-evaluated on DOM changes and pins the decision in the DOM for fixtures.
**Breaking out to the viewport rather than the transcript.** Rejected: the conversation column pins `overflow-x: hidden` (the one-axis contract in `apps/web/tests/conversation-column-overflow.e2e.ts`), and anything wider than the transcript box would clip; the transcript width is exactly the space the layout actually has.
## Consequences
An ordinary wide table reads in place with wrapped cells; a many-column table keeps its readable natural width, spans the whole transcript where the layout has spare width, and scrolls for the remainder — with no interaction required and nothing to restore. A wide table's horizontal bar reveals on hover instead of staying painted: Chromium never repaints state-conditioned scrollbar styles (neither hover-conditioned `::-webkit-scrollbar*` rules nor a `:hover` `scrollbar-color` change reaches the painted bar — measured headed and headless), so the reveal toggles `overflow-x` itself (`hidden` at rest, `auto` on hover or focus), with resting `padding-bottom` matching the themed bar height so the appearing bar replaces it without moving content below. Resting `overflow-x: hidden` drops Chromium's implicit scroller focusability, so wide wrappers carry an explicit `tabindex="0"` (a `:focus-visible` ring marks them, and focus restores scrolling for arrow keys). Two knowledge edges: `container-type: inline-size` on ChatView's `.scroll` makes it the nearest query container for anything inside the transcript that later uses container units, and sub-four-column tables now always stretch to the full column width (deepsuite chat behavior) rather than shrink-wrapping short content.
## Testing
The markdown-dom parity fixtures pin the wrapper classes per arm, including the new `table-wide-and-blockquote` document; `markdown-render-units.client.spec.tsx` covers the hand-built rowless/align-less fallback. `apps/web/tests/markdown-wide-table.e2e.ts` seeds a closed turn with three tables (three-column fill, twelve-column wide, long-token/CJK long-cell) and, in real Chromium, pins the relations golden across viewport stops — fill and long-cell tables fill the column with no residual scroll at every stop and grow taller as the column narrows, the wide table always scrolls, breaks out past the message column exactly at the stops where the transcript is wider than it, keeps its content left-aligned with the fill table's under the breakout, and clamps to neutral at the narrow stop — plus arrow-key scrolling of the focused wrapper, a zoom arm, and a deviceScaleFactor-2 arm that must report the same relations.
## Related
- [Web markdown incremental AST renderer](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) — the renderer and DOM-parity contract this change extends.
@@ -0,0 +1,37 @@
# Agent NoteWeb markdown 表格按列数填充消息列,宽表突破列宽
Status: implemented
[English](2026-08-19-web-markdown-wide-table-view.md) | 中文
## 问题
`MarkdownText` 把每个 GFM 表格都按自然宽度渲染(`.tableScroll table { width: max-content; max-width: max-content }``packages/client/ui-primitives/src/markdown/MarkdownText.module.css`),于是任何比 748px 消息列更宽的表格都只能靠横向滚动阅读。单元格本可以舒适换行的三列表格也被迫滚动;而真正宽的表格即便转录区周围有几百像素的空余,也永远只能用消息列那么宽。Issue #1761(含外部反馈 dsh-external/issues#520)要求先换行适应,并为需要的表格提供更宽的视图。deepsuite chat 产品已用 CSS 优先的方式解决了同一问题;按评审方向,本变更对齐该方案,替换此前起草的交互式展开对话框方案。
## 决定
**列数在渲染器里静态决定尺寸分支。**`renderTable` 读取解析出的列数(`align` 数组,缺省回退表头行):不足四列的表格——以及 blockquote 内的任何表格(渲染上下文的 `inBlockquote` 标志)——获得模块的 `tableFill` 类,`table { width: 100%; max-width: none }`,填满消息列,单元格按既有 `min-width: 100px` 下限换行。四列及以上的表格保持自然宽度规则,改挂稳定的全局钩子类 `md-table-wide`(沿用 `md-code-block` 先例),供宿主布局加宽。这正是 deepsuite chat 的判别式(`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` 及其 blockquote 豁免),只是移到已经掌握列数的渲染器里计算。全程没有测量、observer 或交互状态。
**聊天转录区用容器查询 CSS 加宽挂钩表格。**ChatView 的 `.scroll` 声明 `container-type: inline-size``AssistantMarkdown.module.css``.body :global(.md-table-wide)` 定义突破:`--dsh-table-spare` 是单侧空余宽度 `max(0px, (100cqw - --dsh-chat-content-width) / 2)``--dsh-table-lead` 再加上包裹层自身的缩进(`min(--dsh-chat-content-width, 100cqw) - 100%`),宽度/负 margin/前导 padding 组合起来,让包裹层的滚动区横跨整个转录区,而表格内容仍从消息列左缘起排。`100cqw` 是 deepsuite chat 用 JS 测量的 `--dsl-virtual-list-width` 的 CSS 等价物;`max(0px, …)` 钳制取代其 below-SM 的 JS 开关,转录区窄于消息列时连续退化为普通列内滚动。规则限定在 `AssistantMarkdown .body` 之下,工具卡片、压缩行等其他 `MarkdownText` 表面保持普通列内行为。
**一致性 fixture 的变化是有意的。**含表格的 markdown-dom fixture pin 住判别结果:窄表与 blockquote 表为 `tableScroll tableFill`,宽表为 `tableScroll md-table-wide`;新增的 `table-wide-and-blockquote` 语料文档同时 pin 住 blockquote 豁免的两个分支。
## 曾考虑的替代方案
**实测溢出的交互件:ResizeObserver 门控的展开入口,用 `Modal` 打开宽视图。**先行实现,后按评审方向否决、改为对齐 deepsuite chatCSS 方案不需要逐表 observer,没有会被流式定稿替换丢弃的对话框状态,不用穿过 cordis-free 包的文案管道,且宽视图是常驻的而非藏在交互后面。
**像 deepsuite chat 那样用 `:has()` 在 CSS 里数列。**否决:它们的包裹层是通用组件,而本渲染器本来就在遍历表格节点,列数是静态可得的;类名比随 DOM 变化反复求值的 `:has()` 选择器更便宜,还把决定固化进 DOM 供 fixture pin 住。
**突破到视口宽而不是转录区宽。**否决:会话列 pin 死了 `overflow-x: hidden``apps/web/tests/conversation-column-overflow.e2e.ts` 的单轴契约),超出转录区盒子的部分会被裁剪;转录区宽度正是布局实际拥有的空间。
## 后果
普通宽表原地换行阅读;多列表格保持可读的自然宽度,在布局有空余处横跨整个转录区,剩余部分滚动——无需任何交互,也没有状态要恢复。宽表的横向滚动条悬停才出现、不再常驻:Chromium 从不重绘状态条件化的滚动条样式(悬停条件化的 `::-webkit-scrollbar*` 规则和 `:hover` 下的 `scrollbar-color` 变化都到不了已绘制的滚动条——有头与无头模式均已实测),因此显隐切换的是 `overflow-x` 本身(静止 `hidden`,悬停或聚焦 `auto`),静止时的 `padding-bottom` 与主题滚动条高度一致,出现的滚动条恰好顶替它、下方内容不动。静止的 `overflow-x: hidden` 会失去 Chromium 对滚动容器的隐式可聚焦性,因此宽表包裹层带显式 `tabindex="0"``:focus-visible` 有焦点圈,聚焦后方向键可滚)。两个需要知道的点:ChatView `.scroll` 上的 `container-type: inline-size` 使它成为转录区内后续使用容器单位的最近查询容器;不足四列的表格现在总是拉伸到整列宽(deepsuite chat 行为),而不是按内容收缩。
## 测试
markdown-dom 一致性 fixture 按分支 pin 住包裹层类名,含新增的 `table-wide-and-blockquote` 文档;`markdown-render-units.client.spec.tsx` 覆盖手工树的无行无 align 兜底。`apps/web/tests/markdown-wide-table.e2e.ts` seed 一个含三个表格的已关闭轮次(三列填充表、十二列宽表、长 token/中文长单元格表),在真实 Chromium 中跨视口档 pin 关系 golden——填充表与长单元格表在每一档都填满消息列、无残余滚动、随列变窄而变高;宽表始终滚动、恰好在转录区宽于消息列的档位突破列宽、突破时内容与填充表左对齐、窄档钳制为中性——外加聚焦包裹层的方向键滚动、缩放分支、以及必须报告相同关系的 deviceScaleFactor-2 分支。
## 相关
- [Web markdown 增量 AST 渲染器](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) —— 本变更所扩展的渲染器与 DOM 一致性契约。
@@ -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-20-multiline-question-answer-field.md
2026-08-20-multiline-question-answer-field.md: 5e411d6728205ce1b72ef8355bbf2493f28873eb
2026-08-20-multiline-question-answer-field.zh.md: 6b98f017bbee2e8d89810c5b193b3707c85d1e95
@@ -0,0 +1,45 @@
# Agent Note: Multi-line answers in the question composer
Status: implemented
English | [中文](2026-08-20-multiline-question-answer-field.zh.md)
## Problem
`ask_user_question` offers a free-text answer beside the model's own options. On a question that carried options, that answer was a single-line `<input>`: a long sentence scrolled sideways inside one 24px line, Shift+Enter did nothing, and an answer with structure — two requirements, a short list, a paragraph — could not be typed at all. The optionless question already used a textarea, but a fixed 64140px box that neither followed the draft nor opened wider.
The chat composer next to it grows with the draft and takes Shift+Enter as a newline. A user who has just typed a multi-line prompt there meets a field that silently flattens the same answer.
## Decision
Both question shapes answer into one `AnswerField`: a `<textarea rows={1}>` sharing a single CSS-grid cell with a hidden mirror `<div>` that renders the draft plus a trailing newline.
The mirror sits in normal flow and so sizes the grid row; the textarea stretches to that row, and `rows={1}` keeps the control's own intrinsic height out of the row sizing, leaving the mirror the only input to the height. Soft wraps are invisible to a `'\n'` count, so the mirror is what makes a wrapped answer grow the box rather than scroll one line. The trailing newline covers the last line the textarea's caret can reach and the block container drops. Mirror and textarea must keep identical type, padding, and wrapping rules; a divergence sizes the box wrong for the text being typed.
Growth stops at the mirror's `max-height` of six lines, and past that the textarea scrolls itself. The mirror takes `box-sizing: content-box` against the card-wide `border-box` so that cap counts text lines rather than text plus padding: the optionless variant carries 16px of vertical padding, which under `border-box` spends two thirds of a line and delivers the last one as an 8px sliver, while the inline variant has no padding and would land on a different line count from the same declaration. It is the only scrollport in the stack: unlike the chat composer, this field paints its own glyphs, so there is no second layer whose scroll offset would have to match.
Enter continues the flow and submits the batch on the last question, Shift+Enter breaks the line, and the IME guard is unchanged — Enter during composition confirms the candidate without advancing. The `variant` prop names which of the two looks the field takes, so the field owns both and neither caller assembles one out of class names.
## Alternatives considered
**`field-sizing: content`.** Rejected for the same reason [the composer's Safari recovery](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md) rejected it: Safari reproduces a stale intrinsic height after a deletion crosses a wrap threshold. The mirror is a plain block whose height Safari computes correctly, and it is already the technique this repository runs in the chat composer.
**Resize in JS on every keystroke** — set `height: auto`, read `scrollHeight`, write it back. Rejected: it pays two forced layouts per keystroke and reintroduces the stale-geometry class of defect the mirror avoids, in exchange for no capability the mirror lacks.
**Reuse the InputBar stack verbatim.** Rejected because that stack carries a decoration backdrop between mirror and textarea, which forces both layers into one outer scrollport so the caret and the glyphs cannot drift apart. This field has no backdrop, so letting the textarea own its own scroll removes the outer scrollport and that obligation with it.
**A separate expand-to-dialog entry for long answers.** Rejected as unnecessary: growing in place already satisfies the requirement, and a dialog would take the options the answer is an alternative to off screen at the moment the user is weighing them.
**Uncapped growth.** Rejected because the card tops out at `min(60vh, 520px)` and still owes that budget to the title, the option rows, and the footer actions; an unbounded field pushes the choices the answer belongs to out of view.
**A per-variant cap that absorbs each variant's padding.** Rejected because it couples the line count to a padding value: changing `.customBlock`'s inset would silently change how many lines the field grows to. `content-box` states the intent once, in the units the cap is written in.
## Testing
Component tests pin the round trip: both shapes render a textarea, the mirror follows the draft, Shift+Enter never advances the flow, and line breaks reach the answer batch verbatim. The assembled `question-composer` web e2e measures the live engine — a soft-wrapped draft grows the field without scrolling it, two Shift+Enter presses leave `"\n\n"` in a taller field with the question still open, and a draft past the cap scrolls instead of growing at exactly six text lines in both variants.
## Consequences
An answer can now carry the structure the question asks for, and the field the user sees behaves like the chat composer above it. The cost is a second element per field and the standing obligation to keep mirror and textarea metrics identical, which the JSDoc at `AnswerField` states and the e2e growth assertion detects.
The [single-select highlight item](https://github.com/deepseek-harness/deepseek-harness/issues/1687) of the same issue is untouched: focusing the custom field still leaves the previously chosen option visually selected until the first character lands.
@@ -0,0 +1,45 @@
# Agent Note: Multi-line answers in the question composer
Status: implemented
[English](2026-08-20-multiline-question-answer-field.md) | 中文
## Problem
`ask_user_question`在模型给出的选项旁提供一个自由文本答案。在带选项的问题上,该答案曾是单行 `<input>`:长句子在 24px 的一行内横向滚动,Shift+Enter 毫无作用,带结构的答案——两条要求、一个短列表、一段话——根本无法输入。无选项问题虽已使用 textarea,但那是固定 64–140px 的框,既不跟随草稿,也无法展开。
紧邻它的聊天输入框会随草稿增高,并把 Shift+Enter 当作换行。刚在那里敲完多行提示词的用户,转身遇到的却是一个会悄悄抹平同一个答案的输入框。
## Decision
两种问题形状都写入同一个 `AnswerField`:一个 `<textarea rows={1}>`,与一个渲染「草稿 + 结尾换行」的隐藏镜像 `<div>` 共享同一个 CSS grid 单元格。
镜像位于常规流中,因而决定 grid 行的高度;textarea 拉伸到该行,而 `rows={1}` 使控件自身的固有高度不参与行尺寸计算,于是镜像是高度的唯一输入。软换行对 `'\n'` 计数不可见,因此正是镜像让换行后的答案把输入框撑高,而不是在一行内滚动。结尾的换行补上 textarea 光标可达、而块级容器会丢弃的最后一行。镜像与 textarea 必须保持完全一致的字体、内边距与换行规则;一旦偏离,输入框的高度就与正在输入的文本不符。
增高在镜像 `max-height` 的六行处停止,此后由 textarea 自身滚动。镜像用 `box-sizing: content-box` 覆盖卡片范围内的 `border-box`,使该上限计量的是文本行而不是「文本加内边距」:无选项变体带 16px 纵向内边距,在 `border-box` 下这会吃掉三分之二行、并把最后一行变成 8px 残条,而 inline 变体没有内边距,同一条声明会落到不同的行数上。它是这个结构里唯一的滚动容器:与聊天输入框不同,本输入框自己绘制字形,因此不存在第二个需要对齐滚动偏移的图层。
Enter 继续流程并在最后一题提交整批,Shift+Enter 换行,IME 保护不变——组合输入期间按 Enter 只确认候选,不前进。`variant` 属性指明该输入框采用两种外观中的哪一种,因此外观归输入框自己所有,任何调用方都不必用类名拼装。
## Alternatives considered
**`field-sizing: content`。** 与[聊天输入框的 Safari 恢复方案](../bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md)出于同一理由否决:当删除跨过换行阈值后,Safari 会复现陈旧的固有高度。镜像是一个 Safari 能正确计算高度的普通块,而且它已经是本仓库在聊天输入框中运行的技术。
**在 JS 中逐次按键重算高度**——置 `height: auto`、读 `scrollHeight`、再写回。否决:每次按键要付两次强制布局,并重新引入镜像所避开的那类陈旧几何缺陷,换来的却是镜像本就具备的能力。
**原样复用 InputBar 的图层结构。** 否决,因为该结构在镜像与 textarea 之间还有一层装饰背板,这迫使两个图层共用一个外层滚动容器,以免光标与字形彼此漂移。本输入框没有背板,因此让 textarea 自己滚动,既去掉了外层滚动容器,也一并去掉了那项对齐义务。
**为长答案单独提供展开为对话框的入口。** 作为多余方案否决:就地增高已满足需求,而对话框会在用户正权衡选项时,把答案所要替代的那些选项挤出屏幕。
**不设增高上限。** 否决,因为卡片上限为 `min(60vh, 520px)`,还要把这份额度分给标题、选项行与底部操作;不设上限的输入框会把答案所属的选项挤出视野。
**按变体各自吸收内边距的上限。** 否决,因为那会把行数与某个内边距值耦合:改动 `.customBlock` 的内边距会悄悄改变输入框能长到几行。`content-box` 只需声明一次意图,且与上限本身的单位一致。
## Testing
组件测试固定了整条往返:两种形状都渲染 textarea、镜像跟随草稿、Shift+Enter 绝不前进流程、换行原样进入答案批次。组装后的 `question-composer` web e2e 则在真实引擎上测量——软换行的草稿把输入框撑高而不滚动,两次 Shift+Enter 之后 `"\n\n"` 留在更高的输入框里且问题仍未关闭,超过上限的草稿在两种变体下都恰好于六行文本处改为滚动而不再增高。
## Consequences
答案现在能承载问题所要求的结构,用户看到的输入框与其上方的聊天输入框行为一致。代价是每个输入框多一个元素,以及保持镜像与 textarea 度量完全一致的长期义务——`AnswerField` 的 JSDoc 陈述了这项义务,e2e 的增高断言负责发现违反。
同一 Issue 中的[单选高亮项](https://github.com/deepseek-harness/deepseek-harness/issues/1687)未被触及:聚焦自定义输入框时,先前选中的选项在第一个字符落下之前仍保持视觉选中。
@@ -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-10-event-directed-pr-review-status.md
2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1
2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7
2026-08-10-event-directed-pr-review-status.md: 3ed6038929d3c2c1e9cd82182978262ee363f5ab
2026-08-10-event-directed-pr-review-status.zh.md: 1fa8650057e53ab894c597b712720a3ee7a5c46a
@@ -12,7 +12,7 @@ A monotonic projection also cannot return an automation-owned Issue from `In rev
## Decision
The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed.
The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions run their lifecycle job but no-op (they never reach the Project token step), while dismissed reviews are not subscribed.
Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
@@ -22,7 +22,7 @@ The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves`
## Verification
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger.
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the job-level absence of `if` plus the step-level gate on the token/board steps (so approved/commented reviews pass without minting a token), and the separate `ready_for_review` policy trigger.
## Alternatives considered
@@ -12,7 +12,7 @@ Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责
## 决策
Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review``pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state``changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。
Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review``pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state``changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,生命周期作业会运行但空操作(不会走到创建 Project token 一步);dismissed 评审则不在订阅范围内。
工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox``Backlog``Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
@@ -22,7 +22,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
## 验证
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、job 级无 `if` 且 token/看板步骤带 step 级门控(使 approved/commented 评审以 pass 呈现且不铸 token),以及独立的 `ready_for_review` 策略触发器。
## 考虑过的替代方案
@@ -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-10-npm-release-sequences.md
2026-08-10-npm-release-sequences.md: efeda91b6a85e1316c563cc04411878122096953
2026-08-10-npm-release-sequences.zh.md: d905ac4b58691890d2aad955b87713a278dcb4f8
2026-08-10-npm-release-sequences.md: 487f2dbe717375b2adb7daf18550799544d2e147
2026-08-10-npm-release-sequences.zh.md: 051c1c9fd0e8d5330477c5e3053c55f1b51d1802
@@ -22,8 +22,8 @@ Two hard blockers sat in the way. All 217 workspace manifests set `private: true
| Sequence | Members | Version baseline | Tag | Workflow |
|---|---|---|---|---|
| dsh | Publish set: non-experimental `packages/*/*` + `apps/*`; private experimental packages join only the shared version bump | one version for the publish set, private dsh packages, and workspace root, `0.0.x` | `dsh-v<version>` | `release.yml` |
| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor-<package>-v<version>` (one per package) | `release-vendor.yml` |
| dsh | Publish set: non-experimental `packages/*/*` + `apps/*`; private experimental packages join only the shared version bump | one version for the publish set, private dsh packages, and workspace root, `0.0.x` | `dsh-v<version>` | `release.yml` (pack) / `release-publish.yml` (publish) |
| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor-<package>-v<version>` (one per package) | `release-vendor.yml` (pack) / `release-vendor-publish.yml` (publish) |
| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v<version>` | `landlock-run-release.yml` |
All three publish to the `@deepseek-ai` scope on npmjs.com, and access is per sequence rather than per scope: the vendored framework and the native packages are `public`, the dsh family is `restricted` ([rationale](2026-08-13-public-vendor-and-native-sequences.md)). No publish path passes `--access`, because one flag cannot serve sequences that disagree and would override the manifest that owns the level.
@@ -105,13 +105,13 @@ The entity in this domain is a **release family**: a set of packages sharing one
The dsh family applies the repository's publication payload policy, which rejects sources and declaration maps. The vendored family keeps upstream's payload, because those manifests export `./src/*` and dropping `src` would publish an export map pointing at absent files.
### Workflow shape: pack everything at once, then publish as one set
### Workflow shape: pack on PR/push, publish from a manual dispatch workflow
The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; the `publish` job downloads that artifact and publishes each entry in order. The release set is one unit — half the packages can never reach the registry while the other half is still building.
The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; it lives in `release.yml` / `release-vendor.yml`. The release set is one unit — half the packages can never reach the registry while the other half is still building.
`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. `publish` is a manual dispatch, sits behind the `npm-publish` environment for human approval, and neither builds nor rebuilds — it uploads the bytes pack produced. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish job carries the global group, because dist-tags are shared registry state.
`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the `publish` job carries the global `Release-publish` group, because dist-tags are shared registry state.
A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so `release.yml` packs the vendored family for verification while publishing only its own set.
A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so the dsh `pack` job packs the vendored family for verification while publishing only the dsh set. The publish workflow (`release-publish.yml`) repacks the current tree and publishes only the dsh set.
The verification also packs the Landlock entry, which `dsh-sandbox-local` declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency.
@@ -22,8 +22,8 @@ Status: implemented
| 序列 | 成员 | 版本基线 | tag | workflow |
|---|---|---|---|---|
| dsh | 发布集:非 experimental 的 `packages/*/*` + `apps/*`;私有实验性包仅加入共享版本 bump | 发布集、私有 dsh 包与 workspace 根共用一个 `0.0.x` | `dsh-v<版本>` | `release.yml` |
| vendored framework | `vendor/*` 九个包 | 每包各自一条版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml` |
| dsh | 发布集:非 experimental 的 `packages/*/*` + `apps/*`;私有实验性包仅加入共享版本 bump | 发布集、私有 dsh 包与 workspace 根共用一个 `0.0.x` | `dsh-v<版本>` | `release.yml`pack/ `release-publish.yml`(发布) |
| vendored framework | `vendor/*` 九个包 | 每包各自一条版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml`pack/ `release-vendor-publish.yml`(发布) |
| native | `native/landlock-run/packages/*` | 自己的 `0.0.x` | `landlock-run-v<版本>` | `landlock-run-release.yml` |
三组一律发到 npmjs.com 的 `@deepseek-ai` scope,且 access 按序列而非按 scope 区分:vendored 框架与 native 包是 `public`dsh 族是 `restricted`[理由](2026-08-13-public-vendor-and-native-sequences.md))。没有任何发布路径传 `--access`——一个选项无法服务级别互不相同的序列,且会覆盖真正拥有该级别的 manifest。
@@ -105,13 +105,13 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 `./src/*`,去掉 `src` 会发出一个导出映射指向不存在文件的包。
### workflow 形状:一次性 pack 全部,再统一 publish
### workflow 形状:PR/push 上 pack,从手动 dispatch 工作流发布
`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;`publish` job 下载那一份 artifact,按顺序逐个发布。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。
`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。
`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。`publish` 是手动 dispatch,挂在 `npm-publish` environment 后面等人工审批,且既不构建也不重建——它上传的就是 pack 产出的字节。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局分组落在 publish job 上,因为 dist-tag 是共享的 registry 状态。
`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在 `publish` job 上,因为 dist-tag 是共享的 registry 状态。
dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份
dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 dsh 的 `pack` job 为验证而打包 vendored 族,发布的仍只有 dsh 那一份。发布工作流(`release-publish.yml`)重新打包当前树,只发布 dsh 族
验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。
+7 -1
View File
@@ -36,7 +36,11 @@ concurrency:
jobs:
lifecycle:
name: Issue lifecycle
if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}
# Runs on every pull_request_review event so the check reports success rather
# than a gray "skipped" segment. The token-creating and board-mutating steps
# are gated at step level (a skipped step does not gray the job): only a
# changes_requested review drives the Project board; approved/commented
# reviews never mint a write-capable App token.
runs-on: ubuntu-latest
steps:
- name: Check out trusted policy
@@ -46,6 +50,7 @@ jobs:
persist-credentials: false
- name: Create project token
id: app-token
if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
@@ -53,6 +58,7 @@ jobs:
owner: deepseek-harness
repositories: deepseek-harness
- name: Handle repository event
if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: node .github/issue-management/policy.mjs lifecycle
+131
View File
@@ -0,0 +1,131 @@
# Publish the dsh release sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a dsh-v* tag, and it
# must never appear as a PR check. It repacks the current tree before publishing
# so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (dsh)
on:
workflow_dispatch:
permissions:
contents: read
env:
PRIMARY_NODE_VERSION: '24'
DSH_TELEMETRY_DISABLED: '1'
jobs:
pack:
name: Pack npm tarballs
runs-on: ubuntu-24.04
steps:
# Complete history: the release scripts read tags.
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family dsh
- name: Build
run: pnpm run build:official
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
# The harness packages declare the vendored framework as a peer, and this
# verification must not depend on the registry already carrying matching
# versions — one pull request may bump both families before either
# publishes — so it installs that family's pack output too. Only dist/npm
# is published.
- name: Pack the vendored framework for verification
run: pnpm run release:pack --family vendor --out dist/npm-vendor
# dsh-sandbox-local declares the Landlock entry as a runtime dependency, so
# the verification needs its tarball. Its platform packages stay out: they
# are optional, and building them needs a musl toolchain per architecture.
- name: Pack the Landlock entry for verification
run: |
pnpm --dir native/landlock-run run build:ts
pnpm --dir native/landlock-run/packages/entry pack --pack-destination "$PWD/dist/npm-landlock"
- name: Verify packed install
run: pnpm run release:verify-packed-install --family dsh --from dist/npm --from dist/npm-vendor --from dist/npm-landlock
- uses: actions/upload-artifact@v4
with:
name: dsh-npm-tarballs
path: dist/npm/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
needs: pack
runs-on: ubuntu-24.04
# Required reviewers and the allowed tags live on the environment; this is
# the only job in the sequence that can write to the registry.
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only. There is no build
# step: publication uploads the bytes the pack job produced.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: dsh-npm-tarballs
path: dist/npm
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family dsh --from dist/npm
@@ -0,0 +1,114 @@
# Publish the vendored framework sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a vendor-* tag, and
# it must never appear as a PR check. It repacks the current tree before
# publishing so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (vendor)
on:
workflow_dispatch:
permissions:
contents: read
env:
PRIMARY_NODE_VERSION: '24'
DSH_TELEMETRY_DISABLED: '1'
jobs:
pack:
name: Pack npm tarballs
runs-on: ubuntu-24.04
steps:
# Complete history: the release scripts read tags.
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family vendor
# The vendored packages publish their own sources and build outputs; the
# host build produces what their manifests select.
- name: Build
run: pnpm run build:lib:host
- name: Pack release tarballs
run: pnpm run release:pack --family vendor --out dist/npm-vendor
- name: Verify packed install
run: pnpm run release:verify-packed-install --family vendor --from dist/npm-vendor
- uses: actions/upload-artifact@v4
with:
name: vendor-npm-tarballs
path: dist/npm-vendor/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
needs: pack
runs-on: ubuntu-24.04
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only; no build step.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: vendor-npm-tarballs
path: dist/npm-vendor
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family vendor --from dist/npm-vendor
+7 -54
View File
@@ -1,10 +1,11 @@
# Pack and publish the vendored framework sequence: the nine rescoped Cordis
# packages under vendor/, each on its own version line. This sequence releases
# independently of dsh and of the native packages.
# Pack the vendored framework sequence: the nine rescoped Cordis packages under
# vendor/, each on its own version line. This sequence releases independently of
# dsh and of the native packages.
#
# Pack runs without credentials on every pull request and master push.
# Publication is a manual dispatch from a vendor-* tag; a vendor release can
# carry several versions, so each package has its own tag.
# Publication is a manual workflow_dispatch of release-vendor-publish.yml from a
# vendor-* tag; a vendor release can carry several versions, so each package has
# its own tag.
name: Release (vendor)
on:
@@ -12,19 +13,12 @@ on:
push:
branches: [master]
workflow_dispatch:
inputs:
publish:
description: Publish the packed tarballs to npm. Must run from a vendor-* tag.
required: true
type: boolean
default: false
permissions:
contents: read
concurrency:
# Pack runs per ref so concurrent pull requests never displace each
# other; the publish job below serializes the shared dist-tag state.
# Pack runs per ref so concurrent pull requests never displace each other.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -70,8 +64,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: ${{ inputs.publish }}
run: pnpm run release:verify --family vendor
# The vendored packages publish their own sources and build outputs; the
@@ -91,42 +83,3 @@ jobs:
path: dist/npm-vendor/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
if: inputs.publish
needs: pack
runs-on: ubuntu-24.04
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only; no build step.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: vendor-npm-tarballs
path: dist/npm-vendor
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family vendor --from dist/npm-vendor
+6 -57
View File
@@ -1,10 +1,10 @@
# Pack and publish the dsh release sequence: every package under packages/ plus
# the apps/ entries, all on one version. The vendored framework and the native
# packages are separate sequences with their own workflows and version lines.
# Pack the dsh release sequence: every package under packages/ plus the apps/
# entries, all on one version. The vendored framework and the native packages are
# separate sequences with their own workflows and version lines.
#
# Pack runs without credentials on every pull request and master push, so a
# pull request proves the whole publish set still packs. Publication is a
# manual dispatch from a dsh-v* tag and consumes exactly the packed bytes.
# pull request proves the whole publish set still packs. Publication is a manual
# workflow_dispatch of release-publish.yml from a dsh-v* tag.
name: Release (dsh)
on:
@@ -12,19 +12,12 @@ on:
push:
branches: [master]
workflow_dispatch:
inputs:
publish:
description: Publish the packed tarballs to npm. Must run from a dsh-v* tag.
required: true
type: boolean
default: false
permissions:
contents: read
concurrency:
# Pack runs per ref so concurrent pull requests never displace each
# other; the publish job below serializes the shared dist-tag state.
# Pack runs per ref so concurrent pull requests never displace each other.
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
@@ -70,8 +63,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Verify release version
env:
RELEASE_PUBLISH: ${{ inputs.publish }}
run: pnpm run release:verify --family dsh
- name: Build
@@ -105,45 +96,3 @@ jobs:
path: dist/npm/*
if-no-files-found: error
retention-days: 7
publish:
name: Publish to npm
if: inputs.publish
needs: pack
runs-on: ubuntu-24.04
# Required reviewers and the allowed tags live on the environment; this is
# the only step in the sequence that can write to the registry.
environment: npm-publish
concurrency:
group: Release-publish
cancel-in-progress: false
permissions:
contents: read
steps:
# Checkout and install carry the release scripts only. There is no build
# step: publication uploads the bytes the pack job produced.
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Install (immutable, no package scripts)
run: pnpm install --frozen-lockfile --ignore-scripts
- uses: actions/download-artifact@v4
with:
name: dsh-npm-tarballs
path: dist/npm
- name: Publish tarballs
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm run release:publish --family dsh --from dist/npm
+1 -1
View File
@@ -37,7 +37,7 @@ packages/ @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
session/ durable session data: persistence, projection, titles, telemetry
identity/ anonymous identity
settings/ user-settings capability + file provider
credentials/ credential-reference capability + env/.env provider
credentials/ credential/authorization capabilities + env/.env provider
acp/ automation-only Agent Client Protocol server
interaction/ approval/interaction capabilities, permission, commands, ask-user
boot/ shared app-bin glue
+1 -1
View File
@@ -426,7 +426,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
})
const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
createEnvironmentProbeProfile(home, project)
try {
const result = await runBuiltBin(
+4 -5
View File
@@ -13,7 +13,7 @@ import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { act, cleanup } from '@testing-library/react'
import { afterEach, beforeEach, vi } from 'vitest'
import { injectBootManifest, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
import { bootInjections, orderByModuleGraph } from '@deepseek-ai/dsh-client-modules'
import type { ClientModuleLoaderTarget, WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
@@ -194,10 +194,9 @@ export function mountAssembledApp(search = '?fixture'): void {
root.id = 'root'
document.body.appendChild(root)
win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ bundlePath: _bundlePath, ...plugin }) => plugin) }
const html = injectBootManifest('<head></head>', win.__DSH_BOOT__)
const facadeSource = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
if (facadeSource === undefined) throw new Error('missing injected ModuleLoader facade')
;(0, eval)(facadeSource)
const [facadeRow] = bootInjections(win.__DSH_BOOT__)
if (facadeRow?.kind !== 'script') throw new Error('missing injected ModuleLoader facade row')
;(0, eval)(facadeRow.text)
// Mirror the blocking Host-injected scripts before the Vite entry calls create().
for (const id of ['@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-runtime']) {
const plugin = PLUGINS.find(candidate => candidate.id === id)
+6 -2
View File
@@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md')
@@ -45,7 +46,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
const sessionEvents: SessionEvent[] = []
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS })
scaffold = await launchWebScaffold(MODE === 'record'
? {}
: { replayFixture: FIXTURE, replayOverride: REPLAY_OVERRIDE, paceMs: REPLAY_PACE_MS })
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
browser = await chromium.launch()
page = await newEnglishPage(browser)
@@ -206,6 +209,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('Cache hit 99.5%', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Host: the session's durable header cwd is the folder the workspace
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
// send went through workspace materialization rather than a bare
@@ -271,7 +275,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
])
})
})
+482
View File
@@ -0,0 +1,482 @@
// Web e2e scenario: markdown tables in the message column, deepsuite-chat
// parity. Tables under four columns (and long-cell tables) fill the 748px
// message column and wrap; four-or-more-column tables keep their natural
// width, scroll horizontally inside their wrapper, and — through the
// renderer's `md-table-wide` hook plus AssistantMarkdown's container-query
// breakout — span the whole transcript width instead of clipping at the
// message column, with the table content still starting at the message
// column's left edge. When the transcript is narrower than the message
// column the breakout clamps to neutral and the plain in-column scroll
// remains.
//
// Only a real engine lays out CSS tables and resolves container-query
// units, so the fill/scroll/breakout relations, the lead-padding alignment,
// arrow-key scrolling, and the zoom/DPR arms are all measured in Chromium
// across viewport stops. The golden records relations and booleans, never
// pixels: absolute widths document the platform, not the behavior.
//
// Zero model calls: the transcript is a closed turn assembled through the
// Session API and seeded cold; a stray stream would fail loud with
// NO_ADAPTER.
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import {
SESSION_FORMAT_VERSION,
Session,
SessionId,
} from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory,
compareOrRefreshGolden,
launchWebScaffold,
seedSession,
watchConsole,
webSnapshotMode,
type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-wide-table', import.meta.url))
const GEOMETRY_EXPECTED = fileURLToPath(
new URL('./snapshots/markdown-wide-table/geometry.expected.md', import.meta.url),
)
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-wide-table-web-e2e'
/** Painted into the final paragraph; the open barrier waits for it. */
const TAIL_MARKER = 'MWT_TABLES_DONE'
/**
* First-header-cell markers identify each table without depending on CSS
* module hashes or DOM order.
*/
const FILL_MARKER = 'MWT_FILL_C1'
const WIDE_MARKER = 'MWT_WIDE_C01'
const LONG_CELL_MARKER = 'MWT_LONGCELL_F1'
const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER]
/** Golden-facing names, in {@link MARKERS} order. */
const TABLE_NAMES = ['fill', 'wide', 'long-cell']
/**
* Viewport sweep. The wide stops leave the transcript far wider than the
* 748px message column, so the breakout relation holds with a fat margin on
* every platform; the narrow stop drops the transcript below the message
* column, which must clamp the breakout to neutral. The sidebar is collapsed
* for the whole sweep (see beforeAll), so the transcript width follows the
* viewport identically on overlay- and classic-scrollbar platforms.
*/
const WIDTHS = [1680, 1100, 640]
/** A sentence long enough that three of them cannot sit unwrapped in the 748px column. */
const SENTENCE = 'This cell carries one full sentence so the unwrapped table is far wider than the message column.'
/** Unbroken path-like token (no scheme, so GFM does not autolink it and no anchor joins the tab order). */
const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3)
const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。'
/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */
function tablesMarkdown(): string {
const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)]
const wideRow = (row: number): string[] =>
Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`)
return [
'Three markdown tables exercise the wide-table layout rules.',
'',
`| ${FILL_MARKER} | Current approach | Proposed approach |`,
'| --- | --- | --- |',
`| Rendering | ${SENTENCE} | ${SENTENCE} |`,
`| Memory | ${SENTENCE} | ${SENTENCE} |`,
'',
`| ${wideHeader.join(' | ')} |`,
`|${' --- |'.repeat(12)}`,
`| ${wideRow(1).join(' | ')} |`,
`| ${wideRow(2).join(' | ')} |`,
'',
`| ${LONG_CELL_MARKER} | Value |`,
'| --- | --- |',
`| path | ${LONG_TOKEN} |`,
`| 说明 | ${CJK_SENTENCE} |`,
'',
TAIL_MARKER,
].join('\n')
}
/** Build one closed, invariant-checked session fixture carrying the three tables. */
function wideTableFixture(): string {
const session = Session.create(SessionId('markdown-wide-table-source'))
const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Show the wide-table layout scenarios.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', {
title: 'Markdown wide tables',
messageSeqs: [user.seq],
source: { kind: 'fallback' },
})
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: tablesMarkdown() }],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const header = {
type: 'session',
version: SESSION_FORMAT_VERSION,
id: '{{sessionId}}',
createdAt: 0,
cwd: '{{cwd}}',
}
return [
JSON.stringify(header),
// Spaced event times, as the sibling markdown fixtures pin them.
...session.events.map(event => JSON.stringify({
...event,
time: eventTimeOrigin + event.seq * 1_000,
})),
'',
].join('\n')
}
/** One table's layout relations at the current viewport. */
interface TableReading {
/** Identifying first-header-cell marker. */
marker: string
/** `scrollWidth - clientWidth` of the wrapper: the residual horizontal scroll. */
overflow: number
/** Wrapper content width. */
clientWidth: number
/** Rendered wrapper height; wrapping shows up as growth when the column narrows. */
height: number
/** Renderer marked the table with the `md-table-wide` breakout hook. */
wideHook: boolean
/** Resolved lead padding (the breakout's alignment compensation). */
paddingLeft: number
/** The table's own left x, for the content-alignment relation. */
tableLeft: number
}
/** Read all three tables' relations in one pass. */
function readTables(page: Page): Promise<TableReading[]> {
return page.evaluate((markers) => {
const wrappers = [...document.querySelectorAll<HTMLElement>('[class*="tableScroll"]')]
return markers.map((marker) => {
const wrapper = wrappers.find(candidate => candidate.textContent?.includes(marker) ?? false)
if (wrapper === undefined) throw new Error(`table wrapper ${marker} not rendered`)
const table = wrapper.querySelector('table')
if (table === null) throw new Error(`table ${marker} not rendered`)
return {
marker,
overflow: wrapper.scrollWidth - wrapper.clientWidth,
clientWidth: wrapper.clientWidth,
height: wrapper.getBoundingClientRect().height,
wideHook: wrapper.classList.contains('md-table-wide'),
paddingLeft: Number.parseFloat(getComputedStyle(wrapper).paddingLeft),
tableLeft: table.getBoundingClientRect().left,
}
})
}, MARKERS)
}
/** A sweep stop: the three tables' readings at one viewport width. */
interface TableStop {
width: number
tables: TableReading[]
}
/**
* Close the details pane so the transcript spans the viewport. Open, it pins
* the transcript to exactly the message column and every breakout relation
* would go vacuous.
* @param target - the page whose pane to close.
*/
async function closeDetailsPane(target: Page): Promise<void> {
await target.getByRole('button', { name: 'Close details', exact: true }).waitFor({ timeout: 10_000 })
await target.evaluate(() => {
document.querySelector<HTMLElement>('button[aria-label="Close details"]')?.click()
})
// Closed details resolve to zero width but never unmount (ui-layout
// columns contract), so the settled signal is the frame's collapse marker,
// not the button's detachment.
await target.waitForSelector('[data-details-collapsed]', { timeout: 5_000 })
}
/**
* Render the golden body: relations only. `fills` is the wrap-first claim
* (the wrapper has no residual horizontal scroll), `scrolls` the many-column
* fallback, and `breaks out` whether the wide wrapper spans past the message
* column (compared against the fill table, which by construction is exactly
* the message column's width).
* @param stops - the measured stops, in sweep order.
* @param wrapTighter - per table name, whether the block grew taller at the
* narrowest stop than at the widest (the proof wrapping engaged).
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(stops: TableStop[], wrapTighter: Map<string, boolean>): string {
return [
'# Markdown wide-table relations',
'',
'| viewport | table | fills the column | scrolls | breaks out past the column |',
'| --- | --- | --- | --- | --- |',
...stops.flatMap((stop) => {
const columnWidth = stop.tables[0]!.clientWidth
return stop.tables.map((table, index) =>
`| ${String(stop.width)}px | ${TABLE_NAMES[index]} | ${String(table.overflow <= 1)} `
+ `| ${String(table.overflow > 1)} | ${String(table.clientWidth > columnWidth + 8)} |`,
)
}),
'',
'Wrap-first engagement (taller at the narrowest stop than at the widest):',
'',
...[...wrapTighter.entries()].map(([name, tighter]) => `- ${name}: ${String(tighter)}`),
].join('\n')
}
describe('web e2e: markdown tables fill the column, wide ones break out and scroll', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, wideTableFixture(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
// Collapse the sidebar and close the details pane for the whole sweep:
// classic-scrollbar platforms (Linux CI) lose ~15px of layout width,
// which shifts how much of a narrow viewport the panes leave the
// transcript and lands the narrow stop's readings far from the macOS
// ones — and the details pane alone pins the transcript to exactly the
// message column, which would make every breakout relation vacuous.
// With both out of the equation the transcript follows the viewport
// identically on every platform, which is what keeps one committed
// golden true for all lanes.
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
// JS click: after the transcript scrolled to its tail, the pane's close
// button can sit under the sticky header where a pointer click is
// intercepted; the pane itself is scaffolding, not the behavior under
// test, so actionability adds nothing here.
await closeDetailsPane(page)
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
/**
* Resize to a viewport and read the tables once layout settles (the frame
* eases its column tracks, so a read straight after a resize can catch a
* mid-transition width).
* @param width - viewport width to settle at.
* @returns the three tables' readings at that width.
*/
const settleAt = async (width: number): Promise<TableReading[]> => {
await page.setViewportSize({ width, height: 900 })
// The wide wrapper follows the transcript width (the fill wrapper caps
// at the message column and would report "settled" mid-transition).
let previousWidth = -1
await expect.poll(async () => {
const current = (await readTables(page))[1]!.clientWidth
const settled = current === previousWidth
previousWidth = current
return settled
}, { timeout: 10_000 }).toBe(true)
return readTables(page)
}
/** Sweep once; every assertion reads the same measurement. */
let swept: Promise<TableStop[]> | undefined
const sweep = (): Promise<TableStop[]> => {
swept ??= (async () => {
const stops: TableStop[] = []
for (const width of WIDTHS) stops.push({ width, tables: await settleAt(width) })
return stops
})()
return swept
}
it('fills narrow tables, scrolls wide ones, and breaks them out where the transcript is wider', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table'))
const stops = await sweep()
for (const stop of stops) {
const [fill, wide, longCell] = stop.tables
const columnWidth = fill!.clientWidth
// #520: an ordinary wide table fills the message column and wraps…
expect(fill!.overflow, `fill viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
// …a long unbroken token and long CJK prose wrap instead of forcing a scroll…
expect(longCell!.overflow, `long-cell viewport ${String(stop.width)}`).toBeLessThanOrEqual(1)
// …and a many-column table keeps its natural width behind the scroll fallback.
expect(wide!.overflow, `wide viewport ${String(stop.width)}`).toBeGreaterThan(1)
// The hook is column-count static, present at every stop.
expect(wide!.wideHook).toBe(true)
expect(fill!.wideHook).toBe(false)
expect(longCell!.wideHook).toBe(false)
if (stop.width > 748) {
// Breakout: the wide wrapper spans past the message column, and its
// lead padding keeps the table content starting at the message
// column's left edge (compared to the fill table's content).
expect(wide!.clientWidth, `wide breakout at ${String(stop.width)}`).toBeGreaterThan(columnWidth + 8)
expect(wide!.paddingLeft, `lead at ${String(stop.width)}`).toBeGreaterThan(0)
expect(Math.abs(wide!.tableLeft - fill!.tableLeft), `alignment at ${String(stop.width)}`).toBeLessThan(1.5)
} else {
// Below the message column there is no spare width: the breakout
// clamps to neutral and the wrapper stays the column's width.
expect(Math.abs(wide!.clientWidth - columnWidth), `neutral at ${String(stop.width)}`).toBeLessThan(1.5)
expect(wide!.paddingLeft, `no lead at ${String(stop.width)}`).toBeLessThan(1.5)
}
}
// Wrap-first engaged for real: the filling tables grow taller as the
// column narrows (the wide table only scrolls, so it is exempt).
const widest = stops[0]!
const narrowest = stops[stops.length - 1]!
expect(narrowest.tables[0]!.height).toBeGreaterThan(widest.tables[0]!.height)
expect(narrowest.tables[2]!.height).toBeGreaterThan(widest.tables[2]!.height)
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps the wide table keyboard-scrollable', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-keyboard'))
await sweep()
await settleAt(1680)
const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
// Chromium makes scrollable containers keyboard-focusable by default;
// arrow keys then scroll the focused wrapper.
await wide.focus()
await page.keyboard.press('ArrowRight')
await page.keyboard.press('ArrowRight')
await expect.poll(() => wide.evaluate(element => element.scrollLeft), { timeout: 5_000 })
.toBeGreaterThan(0)
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('reveals the wide table scrollbar on hover only', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar'))
await sweep()
await settleAt(1680)
const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER })
// Chromium never repaints state-conditioned scrollbar STYLES, so the
// hover reveal toggles overflow-x itself; the resting padding matches
// the bar height so the swap does not move anything below. Both are
// ordinary properties whose computed values follow :hover.
const overflowState = () => wide.evaluate(element => [
getComputedStyle(element).overflowX,
getComputedStyle(element).paddingBottom,
].join(' '))
// Park the pointer away and drop focus: the keyboard case above leaves
// the wrapper focused, and focus-visible also reveals the bar.
await page.mouse.move(4, 4)
await wide.evaluate((element) => { element.blur() })
await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
// Resting hidden overflow keeps the scroll position reachable and intact.
expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0)
await wide.hover()
await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px')
// Pointer leaves: the bar rests hidden again.
await page.mouse.move(4, 4)
await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px')
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('keeps the fill/scroll relations under page zoom', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom'))
await sweep()
await settleAt(1100)
try {
await page.evaluate(() => { document.documentElement.style.zoom = '1.25' })
await expect.poll(async () => {
const [fill, wide, longCell] = await readTables(page)
return fill!.overflow <= 1 && longCell!.overflow <= 1 && wide!.overflow > 1
}, { timeout: 10_000 }).toBe(true)
} finally {
await page.evaluate(() => { document.documentElement.style.zoom = '' })
}
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('reports the same relations on a high-DPI page', async () => {
const hidpiPage = await browser.newPage({
viewport: { width: 1100, height: 900 },
deviceScaleFactor: 2,
locale: 'en-US',
})
const hidpiTripwire = watchConsole(hidpiPage)
try {
onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi'))
await hidpiPage.goto(scaffold.baseUrl, { waitUntil: 'load' })
await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = hidpiPage.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = hidpiPage.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 })
await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
await closeDetailsPane(hidpiPage)
// The pane collapses ease over the layout transition: compare only a
// settled reading (two consecutive equal wide-wrapper widths).
let readings: TableReading[] = []
let previousWide = -1
await expect.poll(async () => {
readings = await readTables(hidpiPage)
const settled = readings[1]!.clientWidth === previousWide
previousWide = readings[1]!.clientWidth
return settled
}, { timeout: 10_000 }).toBe(true)
const baseline = (await sweep()).find(stop => stop.width === 1100)!
const relations = (tables: TableReading[]) => tables.map(table => ({
marker: table.marker,
fills: table.overflow <= 1,
breaksOut: table.clientWidth > tables[0]!.clientWidth + 8,
}))
expect(relations(readings)).toEqual(relations(baseline.tables))
expect(hidpiTripwire.pageErrors).toEqual([])
} finally {
await hidpiPage.close()
}
}, 120_000)
it('matches the committed geometry golden', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-golden'))
const stops = await sweep()
const widest = stops[0]!
const narrowest = stops[stops.length - 1]!
const wrapTighter = new Map<string, boolean>([
['fill', narrowest.tables[0]!.height > widest.tables[0]!.height],
['long-cell', narrowest.tables[2]!.height > widest.tables[2]!.height],
])
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(stops, wrapTighter), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 120_000)
it('commits exactly the fixtures it reads', async () => {
// No model calls, so no replay log: the golden is the whole inventory.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})
+100 -2
View File
@@ -10,10 +10,11 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import type { Browser, Locator, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
@@ -30,6 +31,31 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
const MODE = webSnapshotMode()
// The composer's own growth cap, in text lines (QuestionComposer.module.css
// .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants
// carry different padding, and a cap measured in border-box pixels silently
// means a different line count in each — which is exactly how the optionless
// field came to stop two thirds of a line short.
const CAP_LINES = 6
/**
* Measure a saturated answer field: how many whole text lines it grew to, and
* whether it took over the scrolling once it stopped growing.
* @param field - the composer's custom-answer textarea.
* @returns whole text lines the content box holds, and whether the field scrolls.
*/
async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: boolean }> {
await field.fill('x\n'.repeat(40))
return field.evaluate((el: HTMLTextAreaElement) => {
const style = getComputedStyle(el)
const text = el.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
return {
textLines: Math.round(text / parseFloat(style.lineHeight)),
scrolls: el.scrollHeight > el.clientHeight,
}
})
}
// The options carry long descriptions on purpose: the squeeze assertion below
// needs option copy that WRAPS, which is the only text layout that reproduces a
// collapsed row painting its copy outside its own box.
@@ -41,6 +67,7 @@ describe('web e2e: resident question composer round trip', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
let answeredSession: SessionId | undefined
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
@@ -132,9 +159,38 @@ describe('web e2e: resident question composer round trip', () => {
await page.setViewportSize(original)
}
// Multi-line custom answer: the field is a textarea whose hidden mirror
// owns the box height, so a soft-wrapped or line-broken draft GROWS the
// field instead of scrolling one line, and Shift+Enter breaks the line
// rather than continuing the flow. Measured on the live composer because
// only a real engine soft-wraps; growth stops at the mirror's cap, past
// which the textarea is the one thing that scrolls. Replay only, same as
// the squeeze above: record mode must reach the recording write.
const custom = composer.getByRole('textbox')
if (MODE !== 'record') {
const oneLineHeight = await custom.evaluate(el => el.getBoundingClientRect().height)
await custom.fill('a'.repeat(120))
const wrapped = await custom.evaluate(el => ({
height: el.getBoundingClientRect().height,
scrolls: el.scrollHeight > el.clientHeight,
}))
expect(wrapped.height).toBeGreaterThan(oneLineHeight * 1.5)
expect(wrapped.scrolls).toBe(false)
await custom.fill('')
await custom.press('Shift+Enter')
await custom.press('Shift+Enter')
expect(await custom.inputValue()).toBe('\n\n')
expect(await composer.getByText('Which color do you prefer?').count()).toBeGreaterThan(0)
expect(await custom.evaluate(el => el.getBoundingClientRect().height))
.toBeGreaterThan(oneLineHeight * 2.5)
expect(await capMetrics(custom)).toEqual({ textLines: CAP_LINES, scrolls: true })
await custom.fill('')
}
const blue = composer.getByRole('checkbox', { name: 'Blue' })
await blue.click()
const custom = composer.getByRole('textbox')
await custom.fill('Include accessibility notes')
expect(await blue.getAttribute('aria-checked')).toBe('true')
expect(await custom.inputValue()).toBe('Include accessibility notes')
@@ -149,6 +205,7 @@ describe('web e2e: resident question composer round trip', () => {
await recordFixture(scaffold, sessionId, FIXTURE)
return
}
answeredSession = sessionId
// World state: the tool result carries the chosen answer, and DONE lands.
const results = sessionEvents.filter(e => e.type === 'tool/result')
const answerText = results.flatMap(event => event.data.message.content.flatMap(block =>
@@ -172,6 +229,47 @@ describe('web e2e: resident question composer round trip', () => {
expect(tripwire.warnings).toEqual([])
}, 200_000)
// The fixture's question carries options, so the round trip above only ever
// exercises the inline shape. The optionless shape is the one that carries
// padding, which is where a cap measured in box pixels drifts off the line
// count — so it is asked straight through the user-questions seam (the same
// service the tool calls; no model round is involved in a layout metric).
it.skipIf(MODE === 'record')('grows the optionless answer to the same cap', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-question-optionless'))
const sessionId = answeredSession
expect(sessionId).toBeDefined()
const agent = scaffold.ctx.agents.get(sessionId as SessionId)
expect(agent).toBeDefined()
const asked = scaffold.ctx.userQuestions.ask({
agent: agent as NonNullable<typeof agent>,
questions: [{ id: 'free', header: 'More', question: 'Anything else?' }],
})
const composer = page.locator('[data-question-key]')
await composer.waitFor({ timeout: 30_000 })
const field = composer.getByRole('textbox')
// The empty field reserves its two lines AND the textarea fills that frame:
// a reserved box the control does not fill leaves a strip that looks like
// the field but takes no click.
expect(await field.evaluate((el) => {
const frame = el.parentElement as HTMLElement
const style = getComputedStyle(frame)
const inner = frame.getBoundingClientRect().height
- parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth)
return {
reserved: Math.round(frame.getBoundingClientRect().height),
fills: Math.abs(el.getBoundingClientRect().height - inner) < 0.5,
}
})).toEqual({ reserved: 64, fills: true })
// The same cap the inline shape stops at — the assertion a border-box cap fails.
expect(await capMetrics(field)).toEqual({ textLines: CAP_LINES, scrolls: true })
// Settle the wait so teardown is not racing a pending question.
await composer.getByRole('button', { name: 'Skip this question' }).click()
expect(await asked).toEqual({ answers: [{ id: 'free', selected: [] }] })
await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0)
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl',
@@ -37,6 +37,6 @@
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "6% of context used"
- button "8% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99.5% Input 10K tok · Output 21 tok
@@ -0,0 +1,153 @@
{
"patches": [
{
"at": 0,
"entry": {
"kind": "chunks",
"chunks": [
{
"type": "block-start",
"index": 0,
"blockType": "reasoning"
},
{
"type": "reasoning-delta",
"index": 0,
"text": "The"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " user"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " wants"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " me"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " to"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " reply"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " with"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " a"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " single"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " word"
},
{
"type": "reasoning-delta",
"index": 0,
"text": "."
},
{
"type": "reasoning-delta",
"index": 0,
"text": " Let"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " me"
},
{
"type": "reasoning-delta",
"index": 0,
"text": " comply"
},
{
"type": "reasoning-delta",
"index": 0,
"text": "."
},
{
"type": "block-start",
"index": 1,
"blockType": "text"
},
{
"type": "text-delta",
"index": 1,
"text": "L"
},
{
"type": "text-delta",
"index": 1,
"text": "IGH"
},
{
"type": "text-delta",
"index": 1,
"text": "TH"
},
{
"type": "text-delta",
"index": 1,
"text": "O"
},
{
"type": "text-delta",
"index": 1,
"text": "USE"
},
{
"type": "block-end",
"index": 0,
"block": {
"type": "reasoning",
"text": "The user wants me to reply with a single word. Let me comply."
}
},
{
"type": "block-end",
"index": 1,
"block": {
"type": "text",
"text": "LIGHTHOUSE"
}
},
{
"type": "usage",
"usage": {
"inputTokens": 50,
"outputTokens": 21,
"cacheReadTokens": 9950,
"reasoningTokens": 15
}
},
{
"type": "finish",
"reason": {
"kind": "stop"
}
}
]
}
}
]
}
@@ -0,0 +1,18 @@
# Markdown wide-table relations
| viewport | table | fills the column | scrolls | breaks out past the column |
| --- | --- | --- | --- | --- |
| 1680px | fill | true | false | false |
| 1680px | wide | false | true | true |
| 1680px | long-cell | true | false | false |
| 1100px | fill | true | false | false |
| 1100px | wide | false | true | true |
| 1100px | long-cell | true | false | false |
| 640px | fill | true | false | false |
| 640px | wide | false | true | false |
| 640px | long-cell | true | false | false |
Wrap-first engagement (taller at the narrowest stop than at the widest):
- fill: true
- long-cell: true
@@ -44,6 +44,7 @@
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
@@ -48,6 +48,7 @@
- option "moonshotai-cn"
- option "nvidia"
- option "openai"
- option "openai-codex"
- option "opencode"
- option "opencode-go"
- option "openrouter"
+1
View File
@@ -64,6 +64,7 @@
"tests/message-feedback-layout.e2e.ts",
"tests/markdown-images.e2e.ts",
"tests/reference-composer.e2e.ts",
"tests/markdown-wide-table.e2e.ts",
"tests/math-rendering.e2e.ts",
"tests/markdown-cjk-strong.e2e.ts",
"tests/markdown-inline-code-links.e2e.ts",
+2 -2
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 docs/capability-seams.md
capability-seams.md: a84a6d6e0836524e1f39f2fd067ae1f6570741a5
capability-seams.zh.md: b6b0aba72729f9b9b752347ab00cd76a45c248d9
capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c
capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d
+5
View File
@@ -51,6 +51,8 @@ flowchart LR
pkg_credentials["credentials"]
svc_credentials["ctx.credentials<br/>Credential seam"]
pkg_credentials_local["credentials-local"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -210,6 +212,7 @@ flowchart LR
pkg_approval --> svc_approval
pkg_attachment --> svc_attachments
pkg_attachment_local --> svc_attachments
pkg_authorization --> svc_authorization
pkg_bash_local --> svc_shell
pkg_bash_sandbox --> svc_shell
pkg_code_runtime --> svc_codeRuntime
@@ -315,6 +318,7 @@ flowchart LR
svc_approval --> pkg_tools
svc_attachments --> pkg_host_runtime
svc_attachments --> pkg_llm_pi_ai
svc_authorization --> pkg_llm_pi_ai
svc_clientModules --> pkg_hmr
svc_codeRuntime --> pkg_tools
svc_compaction --> pkg_compaction_basic
@@ -432,6 +436,7 @@ flowchart LR
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
+5
View File
@@ -53,6 +53,8 @@ flowchart LR
pkg_credentials["credentials"]
svc_credentials["ctx.credentials<br/>Credential seam"]
pkg_credentials_local["credentials-local"]
pkg_authorization["authorization"]
svc_authorization["ctx.authorization<br/>Authorization flow registry"]
pkg_session_telemetry["session-telemetry"]
svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -212,6 +214,7 @@ flowchart LR
pkg_approval --> svc_approval
pkg_attachment --> svc_attachments
pkg_attachment_local --> svc_attachments
pkg_authorization --> svc_authorization
pkg_bash_local --> svc_shell
pkg_bash_sandbox --> svc_shell
pkg_code_runtime --> svc_codeRuntime
@@ -317,6 +320,7 @@ flowchart LR
svc_approval --> pkg_tools
svc_attachments --> pkg_host_runtime
svc_attachments --> pkg_llm_pi_ai
svc_authorization --> pkg_llm_pi_ai
svc_clientModules --> pkg_hmr
svc_codeRuntime --> pkg_tools
svc_compaction --> pkg_compaction_basic
@@ -434,6 +438,7 @@ flowchart LR
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 |
| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
| `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |
+2 -2
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 docs/config-catalog.md
config-catalog.md: 183f2ea683f553313ed04f06866d33034167d539
config-catalog.zh.md: 68641ea8c0dcc3e2d58ad66e7e76805c18426926
config-catalog.md: 3595bc650153be25a4156f103af4980c9452f4f0
config-catalog.zh.md: f1212d3af3f2101b1638235ef60c5a1ca49aae9d
+3 -2
View File
@@ -567,7 +567,7 @@ export interface Config {
}
```
Source: [`packages/credentials/credentials-local/src/index.ts:55`](../packages/credentials/credentials-local/src/index.ts)
Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
<a id="deepseek-aidsh-e2b"></a>
@@ -863,7 +863,7 @@ export interface Config {
}
```
Source: [`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts)
Source: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
<a id="deepseek-aidsh-invariants"></a>
@@ -3192,6 +3192,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
- `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
- `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
+3 -2
View File
@@ -569,7 +569,7 @@ export interface Config {
}
```
来源:[`packages/credentials/credentials-local/src/index.ts:55`](../packages/credentials/credentials-local/src/index.ts)
来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
<a id="deepseek-aidsh-e2b"></a>
@@ -865,7 +865,7 @@ export interface Config {
}
```
来源:[`packages/host/webserver/src/index.ts:45`](../packages/host/webserver/src/index.ts)
来源:[`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts)
<a id="deepseek-aidsh-invariants"></a>
@@ -3195,6 +3195,7 @@ export interface Config {
- `@deepseek-ai/dsh-agent`[`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)
- `@deepseek-ai/dsh-api-gateway` — 需要 `typert`[`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)
- `@deepseek-ai/dsh-api-remotes`[`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)
- `@deepseek-ai/dsh-authorization` — 需要 `credentials`[`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)
- `@deepseek-ai/dsh-client-locale`[`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)
- `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`[`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts)
- `@deepseek-ai/dsh-client-runtime`[`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)
+2 -2
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 docs/event-producer-consumer.md
event-producer-consumer.md: d75d64b58b041c117bf2eaa1612c1b4683378a00
event-producer-consumer.zh.md: adfc11782e179077b17199fc4ea6e8f52b3c465b
event-producer-consumer.md: d68f92d317dec2fd05813c1ce487bb88c369bd6e
event-producer-consumer.zh.md: 5b3454e6000f4e1e017cb494423736f2c0f75f31
+4 -1
View File
@@ -22,6 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
@@ -29,7 +30,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |
@@ -57,6 +59,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
+4 -1
View File
@@ -24,6 +24,7 @@
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
| `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
@@ -31,7 +32,8 @@
| `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |
@@ -59,6 +61,7 @@
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
+2 -2
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 docs/module-graph.md
module-graph.md: 3d819a66748242ded418c0cd7574857881f70c7a
module-graph.zh.md: e7509c0a81aba32138b58d14e4e144e6b42d95b3
module-graph.md: f63fce2459a0316cd945d8fc71b2c18848b7b843
module-graph.zh.md: 1194cf700110e82a45aa69663911f62705de1e91
+14 -8
View File
@@ -179,6 +179,7 @@ flowchart TD
pkg_tmux_context["tmux-context"]
end
subgraph group_credentials["packages/credentials"]
pkg_authorization["authorization"]
pkg_credentials["credentials"]
pkg_credentials_local["credentials-local"]
end
@@ -421,13 +422,6 @@ flowchart TD
pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout
pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_launch_environment
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_session --> pkg_brand
pkg_session --> pkg_invariants
pkg_session --> pkg_llm
@@ -441,9 +435,20 @@ flowchart TD
pkg_skill --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_authorization --> pkg_credentials
pkg_authorization --> pkg_invariants
pkg_authorization --> pkg_llm
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_authorization
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_launch_environment
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_agent --> pkg_invariants
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
@@ -1506,12 +1511,13 @@ flowchart TD
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) |
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
+14 -8
View File
@@ -181,6 +181,7 @@ flowchart TD
pkg_tmux_context["tmux-context"]
end
subgraph group_credentials["packages/credentials"]
pkg_authorization["authorization"]
pkg_credentials["credentials"]
pkg_credentials_local["credentials-local"]
end
@@ -423,13 +424,6 @@ flowchart TD
pkg_llm_deepseek --> pkg_llm
pkg_llm_deepseek --> pkg_settings
pkg_llm_deepseek --> pkg_timeout
pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_launch_environment
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_session --> pkg_brand
pkg_session --> pkg_invariants
pkg_session --> pkg_llm
@@ -443,9 +437,20 @@ flowchart TD
pkg_skill --> pkg_scope
pkg_web --> pkg_invariants
pkg_web --> pkg_llm
pkg_authorization --> pkg_credentials
pkg_authorization --> pkg_invariants
pkg_authorization --> pkg_llm
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
pkg_llm_pi_ai --> pkg_attachment
pkg_llm_pi_ai --> pkg_authorization
pkg_llm_pi_ai --> pkg_credentials
pkg_llm_pi_ai --> pkg_invariants
pkg_llm_pi_ai --> pkg_launch_environment
pkg_llm_pi_ai --> pkg_llm
pkg_llm_pi_ai --> pkg_settings
pkg_llm_pi_ai --> pkg_timeout
pkg_agent --> pkg_invariants
pkg_agent --> pkg_llm
pkg_agent --> pkg_scope
@@ -1508,12 +1513,13 @@ flowchart TD
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) |
| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
+2 -2
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 docs/subsystems/client-modules.md
client-modules.md: 52fa4ef73766e8e7e7601e604f78aded9940328f
client-modules.zh.md: e830349be77000759684a76b2834478a803e3e11
client-modules.md: d939cbbafa186b82d9110a6ccc41e9892b53eb95
client-modules.zh.md: 8f407de0bd44ea2cc95b6d29ca7e149648785c0f
+6 -6
View File
@@ -2,13 +2,13 @@
English | [中文](client-modules.zh.md)
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and taps the index render to inject the boot manifest — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and index tap this service registers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
The web plugin table: the Node half of the client module system in [dsh-client-modules](../../packages/client/modules), provided as `ctx.clientModules` (`ClientModuleRegistry`). It scans the host Loader's entries for packages declaring `dsh.client`, composes the `window.__DSH_BOOT__` entry graph, serves each bundle at `/plugins/<id>/client.js`, and answers every index-injection collection with the boot manifest rows — the four faces of one service. It is an optional capability of the web GUI stack, not part of the agent-loop spine, and it is a consumer of [dsh-host-webserver](../../packages/host/webserver): the carrier described in [web-server.md](web-server.md) supplies the prefix route and the `webserver/index-inject` event this service answers. The same package's browser half (`ctx.modules`, the lazy-CJS module table that fetches and materializes these bundles) is kernel machinery documented in the [package README](../../packages/client/modules/README.md), not here.
Source: [`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## The wire
The graph is the wire single source between the Node and browser halves: the host composes `WebBootEntry` rows from scanned packages, injects the graph as the first script in `<head>` (`window.__DSH_BOOT__`, with `<` escaped so plugin-controlled strings cannot break out of the script element), and the shell parses it before booting anything. A page without a valid manifest cannot boot — the browser-side parser throws loud on a missing or malformed graph.
The graph is the wire single source between the Node and browser halves: the host composes `WebBootEntry` rows from scanned packages, publishes the graph as a `global` injection row rendered ahead of later script rows (`globalThis["__DSH_BOOT__"]`, with `<` escaped so plugin-controlled strings cannot break out of the script element), and the shell parses it before booting anything. A page without a valid manifest cannot boot — the browser-side parser throws loud on a missing or malformed graph.
```ts type-equiv
/**
@@ -60,9 +60,9 @@ Scanning is incremental per package; there is no full-rescan code path. Every co
Package metadata — including the negative "not a client package" verdict — is cached per name and never expires: plugin-set changes take effect on restart. A fiber restart reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
## The bundle route and index tap
## The bundle route and index injection
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404 rather than letting the carrier's SPA fallback ship HTML as JavaScript. The index tap injects the current graph on every index render, so a reload always boots against the live composition.
`GET`/`HEAD /plugins/<id>/client.js` serves the registered bundle from disk with `no-cache` (the rev query, not HTTP caching, anchors consistency); other methods are 405. An unknown id — or a registered row whose bundle is unreadable because it has not been built yet — answers a loud 404 rather than letting the carrier's SPA fallback ship HTML as JavaScript. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
## The service
@@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModules``ClientModuleRegistry`
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
onGraphChanged(listener: () => void): () => void
```
Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts)
Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts)
<!-- END GENERATED cordis-surface -->
+6 -6
View File
@@ -2,13 +2,13 @@
[English](client-modules.md) | 中文
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules``ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并经 index 转换(index tap)注入启动 manifest(元数据清单)——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.md) 所述的载体提供本服务注册的前缀路由与 index 转换。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
Web 插件表:[dsh-client-modules](../../packages/client/modules) 中 client 模块系统的 Node 半,以 `ctx.clientModules``ClientModuleRegistry`)形式提供。它扫描宿主 Loader 的 entry,找出声明了 `dsh.client` 的包,组合出 `window.__DSH_BOOT__` entry 图,在 `/plugins/<id>/client.js` 提供各个 bundle,并启动 manifest(元数据清单)行回应每次 index 注入收集——这是同一个服务的四个面。它是 Web GUI 栈的一项可选能力,不属于 agent loop(智能体循环)主干,并且是 [dsh-host-webserver](../../packages/host/webserver) 的消费方:[web-server.md](web-server.md) 所述的载体提供本服务注册的前缀路由与其回应的 `webserver/index-inject` 事件。同一个包的浏览器半(`ctx.modules`,即拉取并物化这些 bundle 的 lazy CJS 模块表)属于内核机件,记录在[包 README](../../packages/client/modules/README.md)中,不在本页。
源码:[`packages/client/modules/src/client/manifest.ts`](../../packages/client/modules/src/client/manifest.ts)
## wire
图是 Node 半与浏览器半之间协议层的唯一真源:宿主从扫描到的包组合出 `WebBootEntry` 行,把图作为 `<head>` 中的第一个脚本注入(`window.__DSH_BOOT__`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错。
图是 Node 半与浏览器半之间协议层的唯一真源:宿主从扫描到的包组合出 `WebBootEntry` 行,把图发布为一条 `global` 注入行、渲染在后续 script 行之前(`globalThis["__DSH_BOOT__"]`,其中 `<` 已转义,插件可控的字符串因此无法逃出 script 元素),壳则在启动任何东西之前先解析它。没有有效 manifest 的页面无法启动——浏览器侧的解析器在图缺失或畸形时大声抛错。
```ts type-equiv
/**
@@ -60,9 +60,9 @@ interface WebBootGraph {
包元数据——包括「非 client 包」这一否定结论——按名缓存且永不过期:插件集合的变更在重启后生效。fiber 重启原样复用其行与 rev;bundle 内容变更只经 `rebuilt()` 到达图。
## bundle 路由与 index 转换
## bundle 路由与 index 注入
`GET`/`HEAD /plugins/<id>/client.js``no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。index 转换在每次 index 渲染时注入当前图,因此刷新页面总是针对实时组合启动。
`GET`/`HEAD /plugins/<id>/client.js``no-cache` 从磁盘提供已注册的 bundle(锚定一致性的是 rev 查询参数,而非 HTTP 缓存);其他方法返回 405。未知 id——或已注册、但 bundle 因尚未构建而不可读的行——回应一个大声的 404,而不是让载体的 SPA 回退把 HTML 当作 JavaScript 发出。注入行在每次 index 渲染时携带当前图,因此刷新页面总是针对实时组合启动。
## 服务
@@ -82,7 +82,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModules``ClientModuleRegistry`
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -122,5 +122,5 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
onGraphChanged(listener: () => void): () => void
```
Source: [`packages/client/modules/src/index.ts:295`](../../packages/client/modules/src/index.ts)
Source: [`packages/client/modules/src/index.ts:282`](../../packages/client/modules/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
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 docs/subsystems/credentials.md
credentials.md: fe480c4311dd5f7e0b1f26996ad53d5f0cd79e7d
credentials.zh.md: 1d8712f8b01a6d6b92e2036172c151d22728f2a4
credentials.md: 459511965d796cd34d1a817110016bcb922d2988
credentials.zh.md: d833f7897008dd9f9c062d8fbefc1e4ba9c0d959
+162 -7
View File
@@ -47,7 +47,7 @@ interface CredentialInfo {
## Change commits
`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
`credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -57,11 +57,76 @@ interface CredentialInfo {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxauthorization--authorizationservice"></a>
### `ctx.authorization``AuthorizationService`
`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
```ts cordis-catalog
/**
* Offer a way to obtain one credential. One flow per key: two plugins
* claiming the same key would each write a record in their own format, and
* whichever ran last would leave the other reading a payload it cannot parse.
*
* @param flow - the key it writes, its label, its methods, and its runner.
* @returns Disposer that withdraws this flow.
* @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
*/
registerFlow(flow: AuthorizationFlow): () => void
/**
* Every registered flow, for a surface listing what can be authorized.
* @returns one entry per flow, in registration order.
*/
list(): readonly AuthorizationEntry[]
/**
* One registered flow.
* @param key - the credential record to ask about.
* @returns the entry, or undefined when no flow claims that key.
*/
describe(key: CredentialKey): AuthorizationEntry | undefined
/**
* Withdraw the attempt running for a key, if any. Separate from the
* request's own signal because a request/response transport answers a Cancel
* button on a second call, with no handle on the first one's signal.
* @param key - the credential record whose attempt should stop.
*/
cancel(key: CredentialKey): void
/**
* Run one attempt to authorize a key, and report how it ended.
*
* One attempt per key at a time. A second caller is refused rather than
* joined: the two would be prompting different humans through the same flow,
* and the second would answer questions the first was asked.
*
* @param request - the key, the method, the surface, and the cancel signal.
* @returns `authorized` once the flow's record is committed during this
* attempt and observed, or `cancelled` when the human declined or the
* caller withdrew.
* @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
* `UNKNOWN_METHOD` when the named method is not one the flow offers,
* `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
* `NOT_COMMITTED` when the flow resolved without committing a record
* during the attempt.
*/
async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
```
Source: [`packages/credentials/authorization/src/index.ts:182`](../../packages/credentials/authorization/src/index.ts)
<a id="ctxcredentials--credentialprovider-abstract-seam"></a>
### `ctx.credentials``CredentialProvider` (abstract seam)
Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
Abstract credential service over two key spaces that answer two questions.
A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock).
```ts cordis-catalog
/**
@@ -99,17 +164,107 @@ abstract set(ref: CredentialRef, value: string): Promise<void>
* @param ref - the reference to remove.
*/
abstract unset(ref: CredentialRef): Promise<void>
/**
* Read one stored record. The value is returned as its owner wrote it; a
* {@link GrantRecord} payload is not interpreted on the way out.
* @param key - the record to read.
* @returns the record, or `undefined` while none is stored.
*/
abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>
/**
* Describe one record for configuration surfaces without exposing its value.
* @param key - the record to describe.
* @returns presence, discriminant, and writability.
*/
abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>
/**
* Enumerate every stored record's address and tag. Unlike the reference
* half, which has no enumeration because configuration surfaces learn which
* references exist from settings schemas, records have no such discovery
* path: a surface that cannot list them cannot show what a user is
* authorized for, nor find an orphan left by an uninstalled plugin.
* @returns every stored record, values excluded.
*/
abstract listRecords(): Promise<readonly CredentialRecordEntry[]>
/**
* Serialized read-modify-write over one record — the only write path.
* `mutate` sees the record as it stands at the moment the write is
* exclusive, and returning `undefined` leaves the entry untouched. Exclusion
* holds across processes where the backing store supports it, which is what
* makes a token refresh safe: two processes rotating one refresh token
* concurrently would otherwise lose whichever wrote first.
* @param key - the record to modify.
* @param mutate - receives the current record and returns its replacement, or `undefined` to leave it.
* @returns the record after the write, or the current one when `mutate` declined.
*/
abstract modifyRecord( key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>, ): Promise<CredentialRecord | undefined>
/**
* Remove one record; removing an absent record is a no-op.
* @param key - the record to remove.
*/
abstract deleteRecord(key: CredentialKey): Promise<void>
```
Source: [`packages/credentials/credentials/src/index.ts:60`](../../packages/credentials/credentials/src/index.ts)
Source: [`packages/credentials/credentials/src/index.ts:177`](../../packages/credentials/credentials/src/index.ts)
<a id="authorization-events"></a>
### `authorization/*` events
<a id="authorizationsettled--emit"></a>
#### `authorization/settled` — emit
One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
```ts cordis-catalog
/**
* One authorization attempt has finished and released its key. Fires for
* every terminal outcome, failures included, so a surface watching a key it
* did not start (a second browser tab) learns the attempt is over.
* @mode emit
* @param key - the credential record the finished attempt was authorizing.
* @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
*/
'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
```
Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
<a id="credentials-events"></a>
### `credentials/*` events
<a id="credentialsupdated--emit"></a>
<a id="credentialsrecord-updated--emit"></a>
#### `credentials/updated` — emit
#### `credentials/record-updated` — emit
Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.
```ts cordis-catalog
/**
* Committed change to a stored credential record: a `modifyRecord` that
* wrote, a `deleteRecord` that removed, or an external edit observed in
* storage. Separate from `credentials/reference-updated` because the two key
* grammars are disjoint — a listener that received both on one event could
* not tell which space a subject belongs to. Listener failures are
* contained on the same terms as `credentials/reference-updated`.
* @param key - the record whose stored value changed.
* @mode emit
*/
'credentials/record-updated'(key: CredentialKey): void
```
Source: [`packages/credentials/credentials/src/types.ts:87`](../../packages/credentials/credentials/src/types.ts)
<a id="credentialsreference-updated--emit"></a>
#### `credentials/reference-updated` — emit
Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
@@ -126,8 +281,8 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
* @param ref - the reference whose stored value changed.
* @mode emit
*/
'credentials/updated'(ref: CredentialRef): void
'credentials/reference-updated'(ref: CredentialRef): void
```
Source: [`packages/credentials/credentials/src/types.ts:29`](../../packages/credentials/credentials/src/types.ts)
Source: [`packages/credentials/credentials/src/types.ts:75`](../../packages/credentials/credentials/src/types.ts)
<!-- END GENERATED cordis-surface -->
+162 -7
View File
@@ -47,7 +47,7 @@ interface CredentialInfo {
## 已提交的变更
`credentials/updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set``unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
`credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set``unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -57,11 +57,76 @@ interface CredentialInfo {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxauthorization--authorizationservice"></a>
### `ctx.authorization``AuthorizationService`
`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
```ts cordis-catalog
/**
* Offer a way to obtain one credential. One flow per key: two plugins
* claiming the same key would each write a record in their own format, and
* whichever ran last would leave the other reading a payload it cannot parse.
*
* @param flow - the key it writes, its label, its methods, and its runner.
* @returns Disposer that withdraws this flow.
* @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
*/
registerFlow(flow: AuthorizationFlow): () => void
/**
* Every registered flow, for a surface listing what can be authorized.
* @returns one entry per flow, in registration order.
*/
list(): readonly AuthorizationEntry[]
/**
* One registered flow.
* @param key - the credential record to ask about.
* @returns the entry, or undefined when no flow claims that key.
*/
describe(key: CredentialKey): AuthorizationEntry | undefined
/**
* Withdraw the attempt running for a key, if any. Separate from the
* request's own signal because a request/response transport answers a Cancel
* button on a second call, with no handle on the first one's signal.
* @param key - the credential record whose attempt should stop.
*/
cancel(key: CredentialKey): void
/**
* Run one attempt to authorize a key, and report how it ended.
*
* One attempt per key at a time. A second caller is refused rather than
* joined: the two would be prompting different humans through the same flow,
* and the second would answer questions the first was asked.
*
* @param request - the key, the method, the surface, and the cancel signal.
* @returns `authorized` once the flow's record is committed during this
* attempt and observed, or `cancelled` when the human declined or the
* caller withdrew.
* @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
* `UNKNOWN_METHOD` when the named method is not one the flow offers,
* `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
* `NOT_COMMITTED` when the flow resolved without committing a record
* during the attempt.
*/
async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
```
Source: [`packages/credentials/authorization/src/index.ts:182`](../../packages/credentials/authorization/src/index.ts)
<a id="ctxcredentials--credentialprovider-abstract-seam"></a>
### `ctx.credentials``CredentialProvider` (abstract seam)
Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
Abstract credential service over two key spaces that answer two questions.
A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock).
```ts cordis-catalog
/**
@@ -99,17 +164,107 @@ abstract set(ref: CredentialRef, value: string): Promise<void>
* @param ref - the reference to remove.
*/
abstract unset(ref: CredentialRef): Promise<void>
/**
* Read one stored record. The value is returned as its owner wrote it; a
* {@link GrantRecord} payload is not interpreted on the way out.
* @param key - the record to read.
* @returns the record, or `undefined` while none is stored.
*/
abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>
/**
* Describe one record for configuration surfaces without exposing its value.
* @param key - the record to describe.
* @returns presence, discriminant, and writability.
*/
abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>
/**
* Enumerate every stored record's address and tag. Unlike the reference
* half, which has no enumeration because configuration surfaces learn which
* references exist from settings schemas, records have no such discovery
* path: a surface that cannot list them cannot show what a user is
* authorized for, nor find an orphan left by an uninstalled plugin.
* @returns every stored record, values excluded.
*/
abstract listRecords(): Promise<readonly CredentialRecordEntry[]>
/**
* Serialized read-modify-write over one record — the only write path.
* `mutate` sees the record as it stands at the moment the write is
* exclusive, and returning `undefined` leaves the entry untouched. Exclusion
* holds across processes where the backing store supports it, which is what
* makes a token refresh safe: two processes rotating one refresh token
* concurrently would otherwise lose whichever wrote first.
* @param key - the record to modify.
* @param mutate - receives the current record and returns its replacement, or `undefined` to leave it.
* @returns the record after the write, or the current one when `mutate` declined.
*/
abstract modifyRecord( key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>, ): Promise<CredentialRecord | undefined>
/**
* Remove one record; removing an absent record is a no-op.
* @param key - the record to remove.
*/
abstract deleteRecord(key: CredentialKey): Promise<void>
```
Source: [`packages/credentials/credentials/src/index.ts:60`](../../packages/credentials/credentials/src/index.ts)
Source: [`packages/credentials/credentials/src/index.ts:177`](../../packages/credentials/credentials/src/index.ts)
<a id="authorization-events"></a>
### `authorization/*` events
<a id="authorizationsettled--emit"></a>
#### `authorization/settled` — emit
One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
```ts cordis-catalog
/**
* One authorization attempt has finished and released its key. Fires for
* every terminal outcome, failures included, so a surface watching a key it
* did not start (a second browser tab) learns the attempt is over.
* @mode emit
* @param key - the credential record the finished attempt was authorizing.
* @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
*/
'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
```
Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
<a id="credentials-events"></a>
### `credentials/*` events
<a id="credentialsupdated--emit"></a>
<a id="credentialsrecord-updated--emit"></a>
#### `credentials/updated` — emit
#### `credentials/record-updated` — emit
Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.
```ts cordis-catalog
/**
* Committed change to a stored credential record: a `modifyRecord` that
* wrote, a `deleteRecord` that removed, or an external edit observed in
* storage. Separate from `credentials/reference-updated` because the two key
* grammars are disjoint — a listener that received both on one event could
* not tell which space a subject belongs to. Listener failures are
* contained on the same terms as `credentials/reference-updated`.
* @param key - the record whose stored value changed.
* @mode emit
*/
'credentials/record-updated'(key: CredentialKey): void
```
Source: [`packages/credentials/credentials/src/types.ts:87`](../../packages/credentials/credentials/src/types.ts)
<a id="credentialsreference-updated--emit"></a>
#### `credentials/reference-updated` — emit
Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
@@ -126,8 +281,8 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
* @param ref - the reference whose stored value changed.
* @mode emit
*/
'credentials/updated'(ref: CredentialRef): void
'credentials/reference-updated'(ref: CredentialRef): void
```
Source: [`packages/credentials/credentials/src/types.ts:29`](../../packages/credentials/credentials/src/types.ts)
Source: [`packages/credentials/credentials/src/types.ts:75`](../../packages/credentials/credentials/src/types.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
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 docs/subsystems/web-server.md
web-server.md: 414863d88cdc34163882cdc3dfe8dc7c1471ac77
web-server.zh.md: d756192ce733a3fe79c9eaf44e5d556c4bac90a8
web-server.md: 6a33eb2bacb7dd9e85a13d7f2ef9cf870b5c44d9
web-server.zh.md: 5dcc4b6e002c4bdeb0d839e67a750f67f40eb0a6
+44 -4
View File
@@ -42,7 +42,7 @@ interface Config {
## The service
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `tapIndex(transform)` adds a pure html-to-html transform applied to every index response — `/` and each SPA fallback — in registration order; [dsh-client-modules](../../packages/client/modules) uses it to inject the boot manifest. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into every index response — `/` and each SPA fallback — before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0.
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
@@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void
registerFallback(handler: WebRoute['handler']): () => void
/**
* Register an index.html transform, applied by the fallback owner to every
* index response ({@link applyIndexTaps}) in registration order.
* Register a raw-HTML index transform, the escape hatch for markup no
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
* registration order after rendering the structured rows.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
@@ -102,7 +103,46 @@ tapIndex(transform: (html: string) => string): () => void
* @returns the transformed body.
*/
applyIndexTaps(html: string): string
/**
* Gather the structured injection table: one `webserver/index-inject` emit,
* every subscriber pushes its current rows. Fresh per call, so subscribers
* read live state (module graph, theme preference) at emit time.
* @returns rows in subscriber activation order.
*/
collectIndexInjections(): IndexInjection[]
/**
* Render one index.html body: the structured injection table first, then
* the raw `tapIndex` transforms over the result.
* @param html - the raw index.html body.
* @returns the transformed body.
*/
renderIndex(html: string): string
```
Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts)
Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts)
<a id="webserver-events"></a>
### `webserver/*` events
<a id="webserverindex-inject--emit"></a>
#### `webserver/index-inject` — emit
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
```ts cordis-catalog
/**
* Collect the structured index injection table. Emitted on every index
* render and every worker boot-payload request; listeners push their
* current rows, so a row's data is read fresh at emit time.
* @param table - Mutable row table; listeners append in activation order.
* @mode emit
*/
'webserver/index-inject'(table: IndexInjection[]): void
```
Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts)
<!-- END GENERATED cordis-surface -->
+44 -4
View File
@@ -42,7 +42,7 @@ interface Config {
## 服务
`WebServer``ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`tapIndex(transform)` 添加一个纯 HTML 到 HTML 转换函数,按注册顺序应用于每个 index 响应(`/` 和每次 SPA 回退)[dsh-client-modules](../../packages/client/modules) 用它注入启动 manifest(元数据清单)。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
`WebServer``ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进每个 index 响应(`/` 和每次 SPA 回退),随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换[dsh-client-modules](../../packages/client/modules) 启动 manifest(元数据清单)行回应该事件`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口。
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()``closeAllConnections()` 配对使用,因为处理器可能像 SSEServer-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.md) 中。
@@ -88,8 +88,9 @@ registerUpgrade(route: WebUpgradeRoute): () => void
registerFallback(handler: WebRoute['handler']): () => void
/**
* Register an index.html transform, applied by the fallback owner to every
* index response ({@link applyIndexTaps}) in registration order.
* Register a raw-HTML index transform, the escape hatch for markup no
* {@link IndexInjection} row expresses: {@link renderIndex} applies taps in
* registration order after rendering the structured rows.
* @param transform - pure html-to-html function.
* @returns the disposer removing the transform.
*/
@@ -102,7 +103,46 @@ tapIndex(transform: (html: string) => string): () => void
* @returns the transformed body.
*/
applyIndexTaps(html: string): string
/**
* Gather the structured injection table: one `webserver/index-inject` emit,
* every subscriber pushes its current rows. Fresh per call, so subscribers
* read live state (module graph, theme preference) at emit time.
* @returns rows in subscriber activation order.
*/
collectIndexInjections(): IndexInjection[]
/**
* Render one index.html body: the structured injection table first, then
* the raw `tapIndex` transforms over the result.
* @param html - the raw index.html body.
* @returns the transformed body.
*/
renderIndex(html: string): string
```
Source: [`packages/host/webserver/src/index.ts:59`](../../packages/host/webserver/src/index.ts)
Source: [`packages/host/webserver/src/index.ts:73`](../../packages/host/webserver/src/index.ts)
<a id="webserver-events"></a>
### `webserver/*` events
<a id="webserverindex-inject--emit"></a>
#### `webserver/index-inject` — emit
Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time.
```ts cordis-catalog
/**
* Collect the structured index injection table. Emitted on every index
* render and every worker boot-payload request; listeners push their
* current rows, so a row's data is read fresh at emit time.
* @param table - Mutable row table; listeners append in activation order.
* @mode emit
*/
'webserver/index-inject'(table: IndexInjection[]): void
```
Source: [`packages/host/webserver/src/index.ts:34`](../../packages/host/webserver/src/index.ts)
<!-- END GENERATED cordis-surface -->
+2 -2
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 packages/README.md
README.md: defbd29942f7733919a9c65a1f8174148919b034
README.zh.md: f47584209d4d54eedec48f5f5c4e90479cf5c7d9
README.md: ad88e895cec423b71dbc3c0e961ddb8901e0f66d
README.zh.md: c8873da697c0b76b633766d11d13be8deee1aae1
+1 -1
View File
@@ -46,7 +46,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable API |
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable API |
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
| [`credentials/`](credentials/README.md) | Credential reference/record seam + env-over-`.env` provider + authorization flows | Product — stable API |
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
| [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |
+1 -1
View File
@@ -46,7 +46,7 @@ npm scope 为 `@deepseek-ai/dsh-*`Cordis `Service` 子类和函数插件通
| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定 API |
| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定 API |
| [`settings/`](settings/README.md) | 用户设置 seam + 基于文件的提供方 | 产品:稳定 API |
| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境变量优先于 `.env` 的提供方 | 产品:稳定 API |
| [`credentials/`](credentials/README.md) | 凭据引用/记录 seam + 环境变量优先于 `.env` 的提供方 + 授权 flow | 产品:稳定 API |
| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API |
| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定 API |
| [`sdk/`](sdk/README.md) | 进程外运行时 SDKJSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API |
+103 -58
View File
@@ -52,6 +52,14 @@ interface RemoteNamespaceHandle {
readonly dispose: TypertDisposer
}
/** One descriptor's mounted variants, for the group disposer to unwind. */
interface InstalledMethod {
readonly descriptor: InvocationDescriptor
readonly token: MountToken
direct: boolean
scoped: boolean
}
/** Typed Remote service augmented by generated direct namespaces. */
export type ClientRemote = TypertClientRemote
@@ -177,9 +185,17 @@ class ClientRemoteService extends Service implements TypertClientRemote {
): Promise<TypertDisposer> {
this.validateContribution(contribution)
const disposeRemote = callerCtx.typert.remotes.register(contribution)
const groups = new Map<string, InvocationDescriptor[]>()
for (const descriptor of contribution.descriptors) {
const group = groups.get(descriptor.namespace)
if (group === undefined) groups.set(descriptor.namespace, [descriptor])
else group.push(descriptor)
}
const installed: TypertDisposer[] = []
try {
for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor))
for (const [namespace, descriptors] of groups) {
installed.push(await this.installNamespace(namespace, descriptors))
}
} catch (error) {
for (const dispose of installed.reverse()) await dispose()
await disposeRemote()
@@ -235,66 +251,47 @@ class ClientRemoteService extends Service implements TypertClientRemote {
}
}
private async install(descriptor: InvocationDescriptor): Promise<TypertDisposer> {
const token: MountToken = { active: true, abort: new AbortController() }
const installed: TypertDisposer[] = []
try {
if (descriptor.invocation.kind === 'direct') {
installed.push(await this.installDirect(descriptor, token))
}
const projection = scopedProjection(descriptor)
if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token))
} catch (error) {
token.active = false
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
throw error
}
return async () => {
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
if (!token.active) return
token.active = false
token.abort.abort()
for (const dispose of installed.reverse()) await dispose()
}
}
private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise<TypertDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
namespace.service.installDirect(descriptor, token)
} catch (error) {
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
}
return async () => {
namespace.service.remove('direct', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace)
}
}
private async installScoped(
descriptor: InvocationDescriptor,
projection: ScopedProjection,
token: MountToken,
/**
* Mount one namespace's descriptor group with no visibility gap: a fresh
* namespace installs its whole group synchronously inside its fiber's
* apply, so a plugin parked on the namespace service never observes it
* without the methods the same contribution carries; an existing namespace
* takes the group in one synchronous step.
* @param name - Remote namespace.
* @param descriptors - Every contribution descriptor naming that namespace.
* @returns disposer unmounting the group and the namespace once empty.
*/
private async installNamespace(
name: string,
descriptors: readonly InvocationDescriptor[],
): Promise<TypertDisposer> {
const namespace = await this.namespace(descriptor.namespace)
try {
namespace.service.installScoped(descriptor, projection, token)
} catch (error) {
await this.disposeNamespace(descriptor.namespace, namespace)
throw error
let namespace = this.namespaces.get(name)
let installed: InstalledMethod[]
if (namespace === undefined) {
({ namespace, installed } = await this.createNamespace(name, descriptors))
} else {
installed = installMethods(namespace.service, descriptors)
}
const handle = namespace
return async () => {
namespace.service.remove('scoped', descriptor.method, token)
await this.disposeNamespace(descriptor.namespace, namespace)
for (const method of [...installed].reverse()) {
/* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */
if (!method.token.active) continue
method.token.active = false
method.token.abort.abort()
if (method.scoped) handle.service.remove('scoped', method.descriptor.method, method.token)
if (method.direct) handle.service.remove('direct', method.descriptor.method, method.token)
}
await this.disposeNamespace(name, handle)
}
}
private async namespace(name: string): Promise<RemoteNamespaceHandle> {
let namespace = this.namespaces.get(name)
if (namespace !== undefined) return namespace
private async createNamespace(
name: string,
descriptors: readonly InvocationDescriptor[],
): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> {
let service: RemoteNamespaceService | undefined
let installed: InstalledMethod[] | undefined
const fiber = this.ownerCtx.plugin({
name: remoteServiceKey(name),
apply: (ctx: Context) => {
@@ -303,6 +300,9 @@ class ClientRemoteService extends Service implements TypertClientRemote {
name,
(direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args),
)
// Same synchronous window as the service registration: a dependent the
// new service unparks runs only after the methods exist.
installed = installMethods(service, descriptors)
},
})
try {
@@ -311,11 +311,13 @@ class ClientRemoteService extends Service implements TypertClientRemote {
await fiber.dispose()
throw error
}
/* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */
if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
namespace = { service, dispose: fiber.dispose }
/* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */
if (service === undefined || installed === undefined) {
throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`)
}
const namespace = { service, dispose: fiber.dispose }
this.namespaces.set(name, namespace)
return namespace
return { namespace, installed }
}
private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise<void> {
@@ -504,6 +506,49 @@ class RemoteNamespaceService extends Service {
}
}
/**
* Install one descriptor group on a namespace service, unwinding the partial
* group when a descriptor is refused.
* @param service - Namespace service taking the methods.
* @param descriptors - Descriptor group of one contribution.
* @returns per-descriptor records for the group disposer.
*/
function installMethods(
service: RemoteNamespaceService,
descriptors: readonly InvocationDescriptor[],
): InstalledMethod[] {
const installed: InstalledMethod[] = []
try {
for (const descriptor of descriptors) {
const method: InstalledMethod = {
descriptor,
token: { active: true, abort: new AbortController() },
direct: false,
scoped: false,
}
installed.push(method)
if (descriptor.invocation.kind === 'direct') {
service.installDirect(descriptor, method.token)
method.direct = true
}
const projection = scopedProjection(descriptor)
if (projection !== undefined) {
service.installScoped(descriptor, projection, method.token)
method.scoped = true
}
}
} catch (error) {
for (const method of [...installed].reverse()) {
method.token.active = false
method.token.abort.abort()
if (method.scoped) service.remove('scoped', method.descriptor.method, method.token)
if (method.direct) service.remove('direct', method.descriptor.method, method.token)
}
throw error
}
return installed
}
const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace'])
function remoteServiceKey(namespace: string): string {
@@ -458,6 +458,91 @@ describe('Client Typert API', () => {
await disposeContext()
})
it('unwinds an already-installed namespace when a later namespace fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...probe } = directDescriptor()
const vault: InvocationDescriptor = {
...probe,
id: '@fixture/vault#vault/seal',
service: 'vault',
namespace: 'vault',
method: 'seal',
}
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'seal') throw new Error('fixture later-namespace failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({ package: '@fixture/two-namespaces', descriptors: [probe, vault] }))
.rejects.toThrow('fixture later-namespace failure')
} finally {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
expect((ctx.remote as unknown as Record<string, unknown>).vault).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
})
it('rolls back an earlier scoped projection when a later descriptor fails to install', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...direct } = directDescriptor()
const failing: InvocationDescriptor = {
...direct,
id: '@fixture/probe#probe/archive',
method: 'archive',
}
const defineProperty = Object.defineProperty
const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => {
if (key === 'archive') throw new Error('fixture trailing failure')
return defineProperty(target, key, attributes)
})
try {
await expect(ctx.remote.$mount({
package: '@fixture/scoped-then-failing',
descriptors: [contextDescriptor(), failing],
})).rejects.toThrow('fixture trailing failure')
} finally {
spy.mockRestore()
}
expect((ctx.remote as unknown as Record<string, unknown>).probe).toBeUndefined()
await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) })
})
it('keeps a namespace another contribution still populates when a group leaves', async () => {
const call = vi.fn<ConnectionHandle['rpc']['call']>()
.mockResolvedValue({ ok: true, value: { renamed: true } })
const ctx = await bench(call)
const { scope: _scope, ...direct } = directDescriptor()
const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-owner', descriptors: [direct] })
const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-owner', descriptors: [contextDescriptor()] })
await disposeDirect()
// The namespace survives its first group: the second contribution still owns methods on it.
const surviving = ctx.get('remote.probe') as unknown as Record<string, unknown> | undefined
expect(surviving).toBeDefined()
expect(surviving?.create).toBeUndefined()
await disposeScoped()
expect(ctx.get('remote.probe')).toBeUndefined()
})
it('unparks a namespace dependent only after its contribution methods exist', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const { scope: _scope, ...direct } = directDescriptor()
let observed: string | undefined
// Parked before the mount: the unpark moment is the observation — the
// atomic-visibility guarantee says the service never appears methodless.
const parked = ctx.inject(['remote.probe'], (probeCtx) => {
observed = typeof (probeCtx.get('remote.probe') as { create?: unknown } | undefined)?.create
})
const dispose = await ctx.remote.$mount({ package: '@fixture/atomic-visibility', descriptors: [direct] })
await parked
expect(observed).toBe('function')
await dispose()
})
it('rejects weak parameter and Context codecs plus malformed scope projections', async () => {
const ctx = await bench(vi.fn<ConnectionHandle['rpc']['call']>())
const direct = directDescriptor()
+1 -1
View File
@@ -17,7 +17,7 @@
export const API_REMOTE_FORWARDED_EVENTS = [
'agent-preset/selected',
'commands/change',
'credentials/updated',
'credentials/reference-updated',
'cordis/request-run',
'cordis/request-run-resolved',
'cordis/dynamic-package',
@@ -78,7 +78,7 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
fallback = handler
return () => { fallback = undefined }
},
applyIndexTaps: (html: string) => html,
renderIndex: (html: string) => html,
} as unknown as WebServer
return { server, seat: () => fallback }
}
+2 -2
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 packages/client/connection/README.md
README.md: f0a707cc5f3a962c7852f7323c727d0a39a57b10
README.zh.md: b529eebf9af93e36b6b92c19964678b7f5a04ea1
README.md: a7562b9dac57930b1abc0b76b9079a6865a38b35
README.zh.md: c18740e98721a9aa7ec95037da45c739c58a06f7
+1 -1
View File
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The exported `ClientTransportHooks` names the page global `__DSH_TRANSPORT__` that replaces the browser carrier wholesale: the served web app leaves it unset and gets HTTP + WebSocket, while a shell owning a different physical transport (the worker preview's postMessage tunnel) provides `createApiClient` and `fetch` — plus `loadBundle` when it also owns bundle bytes — instead of forking the plugin. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
## /api browser-trust fence
+1 -1
View File
@@ -2,7 +2,7 @@
[English](README.md) | 中文
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unaryrespond,并为 `events.mux``events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unaryrespond,并为 `events.mux``events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。导出的 `ClientTransportHooks` 命名了整体替换浏览器载体的页面全局量 `__DSH_TRANSPORT__`served web app 不设置它、走 HTTP + WebSocket;拥有另一种物理传输的壳(worker 预览的 postMessage 隧道)则在此提供 `createApiClient``fetch`——当它同时持有 bundle 字节时再加 `loadBundle`——而不必 fork 本插件。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory``host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate``credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list``agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
## /api 浏览器信任栅栏
+29 -3
View File
@@ -8,7 +8,7 @@ import type { HostDescription, IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
import { createWebConnectionRpc } from './rpc.ts'
import { createWebConnectionRpc, type RpcFetch } from './rpc.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
import type { ClientConnectionRpc } from '../rpc.ts'
@@ -40,6 +40,7 @@ export {
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
export type { ClientConnectionRpc } from '../rpc.ts'
export type { RpcFetch } from './rpc.ts'
/** Observable Host description published by each completed connection handshake. */
export interface HostDescriptionSource {
@@ -52,6 +53,30 @@ export interface HostDescriptionSource {
/** Required services (none — this is the wire root). */
export const inject: string[] = []
/**
* Carrier override installed on the page global before plugin boot. The served
* web app leaves it unset and gets HTTP + WebSocket; a shell that owns a
* different physical transport (the worker preview's postMessage tunnel)
* provides both halves here instead of forking this plugin.
*/
export interface ClientTransportHooks {
/** Build the API carrier: unary calls plus the two downstream event streams. */
createApiClient(): IApiClient
/** Transport for generic unary RPC channels (the Typert gateway). */
fetch: RpcFetch
/**
* Bundle transport for the module system, present when the carrier also owns
* bundle bytes (the worker tunnel). Absent in the served web app, whose
* bundles load over HTTP.
*/
loadBundle?(url: string): Promise<void>
}
/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
interface ClientTransportGlobal {
__DSH_TRANSPORT__?: ClientTransportHooks
}
/**
* The ctx.connection service API: the API client plus a one-shot
* controller starter (the runtime plugin supplies sinks when its object layer
@@ -85,8 +110,9 @@ export function apply(ctx: Context): void {
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const fixtureClient = fixture ? new FixtureApiClient() : undefined
const api: IApiClient = fixtureClient ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__
const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch)
let started = false
let description: HostDescription | undefined
const descriptionListeners = new Set<() => void>()
+7 -2
View File
@@ -12,11 +12,16 @@ const INTERNAL_BASE = 'http://dsh.internal'
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
/** Transport this caller posts through; same signature as the global `fetch`. */
export type RpcFetch = (input: URL, init: RequestInit) => Promise<Response>
/**
* Create the browser-backed generic RPC caller.
* @param doFetch - transport override; defaults to the page's global fetch.
* @returns caller that owns request correlation and response-envelope validation.
*/
export function createWebConnectionRpc(): ClientConnectionRpc {
export function createWebConnectionRpc(doFetch?: RpcFetch): ClientConnectionRpc {
const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init))
return {
async call(channel, endpoint, payload, signal) {
assertTarget(channel, endpoint)
@@ -27,7 +32,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc {
method: endpoint,
payload,
}
const response = await globalThis.fetch(
const response = await send(
new URL(`${channel}/${endpoint}`, resolveBase()),
{
method: 'POST',
+23 -37
View File
@@ -3,10 +3,10 @@
* the host Loader's entries for packages declaring `dsh.client`, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`) in module-graph order, serves
* `/plugins/<id>/client.js` and its source map, taps the index render to
* inject the boot manifest plus the parser-blocking bootstrap preloads, and
* provides the `clientModuleHost` service (the HMR node half's
* registration/notification face).
* `/plugins/<id>/client.js` and its source map, contributes the boot manifest
* plus the parser-blocking bootstrap preloads to the webserver's index
* injection table, and provides the `clientModuleHost` service (the HMR node
* half's registration/notification face).
*
* Scanning is incremental per package there is no full-rescan code path.
* Every cordis `internal/plugin` emission (fiber construction/disposal) marks
@@ -30,7 +30,7 @@ import { dirname, join } from 'node:path'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/cordis-plugin-loader'
import type {} from '@deepseek-ai/dsh-host-webserver'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { optionalStringArray, stripClientSuffix } from './client/manifest.ts'
import type { WebBootEntry, WebBootGraph } from './client/manifest.ts'
@@ -228,31 +228,19 @@ const CLIENT_RUNTIME_ID = '@deepseek-ai/dsh-client-runtime'
/** Ordinary dynamic bundles the HTML parser executes before the Vite shell. */
const PARSER_PRELOAD_IDS = [CLIENT_MODULES_ID, CLIENT_RUNTIME_ID] as const
/** Escape a graph URL before placing it in a quoted HTML attribute. */
function escapeHtmlAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
}
/**
* Inject the boot protocol into index.html. The inline registration queue precedes
* blocking classic scripts for modules' and runtime's ordinary
* The boot protocol as index injection rows. The inline registration queue
* precedes blocking classic scripts for modules' and runtime's ordinary
* `lib/client.js` artifacts. Its `create()` method materializes the modules
* bundle, delegates construction to that bundle, and leaves the same facade
* in live-registration mode. The graph script follows before the shell reads
* it. `<` is escaped in JSON so a plugin-controlled string cannot break out
* of the script element.
* @param html - the index.html source.
* in live-registration mode. The graph global follows before the shell reads
* it.
* @param graph - the composed entry graph.
* @returns the html with the graph script injected.
* @returns head rows in execution order: queue script, preload scripts, graph global.
*/
export function injectBootManifest(html: string, graph: WebBootGraph): string {
const json = JSON.stringify(graph).replaceAll('<', '\\u003c')
export function bootInjections(graph: WebBootGraph): IndexInjection[] {
const bootstrapId = JSON.stringify(CLIENT_MODULES_ID)
const queue = `<script>(()=>{
const queue = `(()=>{
const pendingQueue=[]
window.__ModuleLoader__={
mode:"queue",
@@ -273,21 +261,20 @@ window.__ModuleLoader__={
return exports.createClientModuleSystem(this,{id:registration.id,exports},options)
}
}
})()</script>`
})()`
const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id))
.filter((entry): entry is WebBootEntry => entry !== undefined)
.map(entry => `<script src="${escapeHtmlAttribute(entry.url)}"></script>`)
.join('')
const script = `${queue}${preload}<script>window.__DSH_BOOT__ = ${json}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
// Headless fixture pages may lack <head>; prepending keeps the read-before-shell ordering.
return `${script}${html}`
.map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url }))
return [
{ kind: 'script', placement: 'head', text: queue },
...preload,
{ kind: 'global', name: '__DSH_BOOT__', value: graph },
]
}
/**
* The web plugin table service: incremental `dsh.client` scan + wire composition
* + bundle route + index tap. Construction runs the activation scan
* + bundle route + index injection rows. Construction runs the activation scan
* synchronously a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot activation audit reports it).
@@ -353,10 +340,9 @@ export class ClientModuleRegistry extends Service {
() => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
'client-modules: bundle route',
)
ctx.effect(
() => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)),
'client-modules: boot manifest injection',
)
ctx.on('webserver/index-inject', (table) => {
table.push(...bootInjections(this.composed))
})
}
/**
@@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url'
import { runInNewContext } from 'node:vm'
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it } from 'vitest'
import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
import * as modulesClient from '../src/client/index.ts'
import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts'
import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
const MODULES_ID = '@deepseek-ai/dsh-client-modules'
@@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry {
return constructWithRoute(packageNames).service
}
/** Execute the exact first inline script emitted by the Host HTML transform. */
/** Execute the exact first inline script emitted by the Host boot rows. */
function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } {
const html = injectBootManifest('<html><head></head><body><script type="module" src="/index.js"></script></body></html>', graph)
const html = renderIndexInjections(
'<html><head></head><body><script type="module" src="/index.js"></script></body></html>',
bootInjections(graph),
)
const source = /<head><script>([\s\S]*?)<\/script>/.exec(html)?.[1]
if (source === undefined) throw new Error('missing injected ModuleLoader facade script')
const window: { __ModuleLoader__?: ClientModuleLoaderTarget } = {}
@@ -107,7 +110,7 @@ describe('HTML bootstrap facade', () => {
const facadeAt = html.indexOf('window.__ModuleLoader__=')
const modulesAt = html.indexOf('<script src="/plugins/modules.js?rev=m"></script>')
const runtimeAt = html.indexOf('<script src="/plugins/runtime.js?rev=r"></script>')
const graphAt = html.indexOf('window.__DSH_BOOT__ = ')
const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ')
const entryAt = html.indexOf('<script type="module" src="/index.js"></script>')
expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([
facadeAt, modulesAt, runtimeAt, graphAt, entryAt,
@@ -28,7 +28,7 @@ function forwardedEventContracts(ctx: Context): void {
const bare: typeof namespace = 'plain-string'
void bare; void namespace; void source
})
ctx.remote.$on('credentials/updated', () => {})
ctx.remote.$on('credentials/reference-updated', () => {})
ctx.remote.$on('commands/change', () => {})
ctx.remote.$on('llm/adapters-updated', () => {})
ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
@@ -106,7 +106,7 @@ describe('wire event bridge', () => {
})
bench.sinks?.onHostEnvelope?.({
rpcId: 'r4' as never,
payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
payload: { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
})
// The carrier does not second-guess the name: selecting what a consumer can
// receive is the allowlist's job, and dropping an unsubscribed name is the
@@ -118,7 +118,7 @@ describe('wire event bridge', () => {
expect(seen).toEqual([
['settings/document-updated', 'llm-pi-ai', 7],
['credentials/updated', 'OPENAI_API_KEY'],
['credentials/reference-updated', 'OPENAI_API_KEY'],
['nobody/listening', 'ignored'],
])
})
@@ -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 packages/client/ui-conversation/README.md
README.md: 71dafd1c4d683f2d6da8fefd88aec81d56149481
README.zh.md: e4d4ab990c74229bd2e077b575e7c87bb648ade9
README.md: a90d1d9dd8156fc1063dc7358374f8b1715deb1a
README.zh.md: acab41ad8c95c9e24b39fc875f3047f429f3add2
+1 -1
View File
@@ -40,7 +40,7 @@ Image intake accepts paste and whole-page drop: the bar binds document-level dra
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Every non-empty ratio starts with integer rounding. A non-full ratio adds decimal places only while the current precision would round to 100%, stopping at the minimum precision that remains below 100%; only a full cache hit displays 100%, and the precision has no fixed limit. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
+1 -1
View File
@@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。所有非空比率都先按整数舍入。非满命中只有在当前精度会舍入成 100% 时才增加小数位,并在首次得到低于 100% 的结果时停止;只有完整缓存命中才显示 100%,且精度没有固定上限。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/``chat/``input/``queue/``settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
@@ -17,6 +17,28 @@
gap: 16px;
}
/* Wide markdown tables (the renderer's 4-column md-table-wide hook) span
the whole transcript width instead of clipping at the 748px message
column, while the table content keeps starting at its normal x (the lead
padding compensates the negative margin) deepsuite chat TableWrapper
parity, with 100cqw (the ChatView scroll container) standing in for its
JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero
when the transcript is narrower than the message column, so narrow
viewports keep the plain in-column scroll with no sideways shift.
Percentages resolve against the wrapper's containing block, so a table
indented inside a list still reaches the same transcript edges. */
.body :global(.md-table-wide) {
--dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2));
--dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%);
box-sizing: border-box;
width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare));
/* The base .tableScroll caps at the column (max-width: 100%); the breakout
is exactly the case that must exceed it. */
max-width: none;
margin-left: calc(-1 * var(--dsh-table-lead));
padding-left: var(--dsh-table-lead);
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped {
align-self: flex-start;
@@ -18,6 +18,11 @@
/* Sides = composer clearance + 16px: on narrow viewports the transcript
stays exactly 32px narrower than the input card (the shared width rule). */
padding: 16px calc(var(--dsh-composer-side-clearance) + 16px);
/* Inline-size query container: wide markdown tables size their breakout
against this box's content width (100cqw in AssistantMarkdown's
md-table-wide rule) the CSS stand-in for deepsuite chat's JS-measured
--dsl-virtual-list-width. */
container-type: inline-size;
}
:global([data-conversation-scroll]) .root {
@@ -101,16 +101,64 @@ export function formatDuration(ms: number): string {
return `${Math.floor(whole / 60)}m${whole % 60}s`
}
/** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */
function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number {
const denominatorQuotient = Math.floor(denominator / 200)
const denominatorRemainder = denominator % 200
let lower = 0
let upper = 100
while (lower < upper) {
const candidate = Math.floor((lower + upper + 1) / 2)
const factor = candidate * 2 - 1
const threshold = factor * denominatorQuotient
+ Math.ceil(factor * denominatorRemainder / 200)
if (cacheReadTokens >= threshold) {
lower = candidate
} else {
upper = candidate - 1
}
}
return lower
}
/**
* Cache-hit share of prompt-side input over the whole durable log.
* Display-ready cache-hit share of prompt-side input over the whole durable log.
* @param usage - the session's token-usage projection value.
* @returns rounded integer percent, or null when no input was billed.
* @returns integer text when integer rounding stays below 100, otherwise the
* minimum decimal precision that still rounds below 100; a full hit returns
* 100, and no billed input returns null.
*/
export function cacheHitPercent(usage: TokenUsageProjection): number | null {
export function cacheHitPercent(usage: TokenUsageProjection): string | null {
const denominator = billedInputTokens(usage)
return denominator === 0
? null
: Math.round(usage.cacheReadTokens / denominator * 100)
if (denominator === 0) return null
const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens
if (missedInputTokens === 0) return '100'
const integerPercent = roundedIntegerPercent(usage.cacheReadTokens, denominator)
if (integerPercent < 100) return String(integerPercent)
// At the first distinguishing precision, the rounded result is 100 minus
// one to five units in the final decimal place. Scale only while the next
// multiplication remains at or below the denominator, then derive that
// final digit through exact small-factor comparisons.
let decimalPlaces = 1
let scaledDoubleGap = missedInputTokens * 200
const denominatorTens = Math.floor(denominator / 10)
while (scaledDoubleGap <= denominatorTens) {
scaledDoubleGap *= 10
decimalPlaces += 1
}
const denominatorOnes = denominator % 10
let roundedLoss = 5
for (let loss = 1; loss < 5; loss += 1) {
const factor = loss * 2 + 1
const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10)
if (scaledDoubleGap <= threshold) {
roundedLoss = loss
break
}
}
return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}`
}
/**
@@ -191,6 +191,10 @@ describe('StatsLine', () => {
return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
}
function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) {
return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 }
}
it('renders the grouped stats row and hides a brand-new empty session', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
@@ -205,19 +209,43 @@ describe('StatsLine', () => {
expect(emptyView.container.textContent).toBe('')
})
it.each([
{ actual: '98.6%', tokenUsageValue: tokenUsage(986, 14), expected: 'Cache hit 99%' },
{ actual: '99.1%', tokenUsageValue: tokenUsage(991, 9), expected: 'Cache hit 99%' },
{ actual: '99.49%', tokenUsageValue: tokenUsage(9_949, 51), expected: 'Cache hit 99%' },
{ actual: '99.5%', tokenUsageValue: tokenUsage(995, 5), expected: 'Cache hit 99.5%' },
{ actual: '99.94%', tokenUsageValue: tokenUsage(9_994, 6), expected: 'Cache hit 99.9%' },
{ actual: '99.95%', tokenUsageValue: tokenUsage(9_995, 5), expected: 'Cache hit 99.95%' },
{ actual: '99.955%', tokenUsageValue: tokenUsage(19_991, 9), expected: 'Cache hit 99.96%' },
{ actual: '99.985%', tokenUsageValue: tokenUsage(19_997, 3), expected: 'Cache hit 99.99%' },
{ actual: '99.995%', tokenUsageValue: tokenUsage(19_999, 1), expected: 'Cache hit 99.995%' },
{ actual: '99.9975%', tokenUsageValue: tokenUsage(39_999, 1), expected: 'Cache hit 99.998%' },
{
actual: 'the closest non-full ratio available from safe integer cumulative counts',
tokenUsageValue: tokenUsage(Number.MAX_SAFE_INTEGER - 1, 1),
expected: 'Cache hit 99.99999999999999%',
},
{ actual: '100%', tokenUsageValue: tokenUsage(10_000, 0), expected: 'Cache hit 100%' },
])('formats an actual $actual cache-hit ratio as $expected', ({ tokenUsageValue, expected }) => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsageValue })} />)
expect(view.container.textContent).toContain(expected)
})
it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
vi.useFakeTimers()
// jsdom lays nothing out; fake a row narrower than its content.
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} />)
expect(view.container.textContent).toContain('Cache hit 99.95%')
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(499) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(view.container.querySelector('[role="tooltip"]')?.textContent)
.toBe('1 turns · 1 steps | Cache hit 90% | Input 100 tok · Output 5 tok')
.toBe('1 turns · 1 steps | Cache hit 99.95% | Input 10K tok · Output 1 tok')
})
it('suppresses the tooltip while the row fits without truncation', () => {
@@ -245,9 +273,9 @@ describe('StatsLine', () => {
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source)} t={t} />)
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
expect(view.container.textContent)
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok')
.toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
})
it('renders without ResizeObserver support', () => {
@@ -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 packages/client/ui-primitives/README.md
README.md: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9
README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf
README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81
README.zh.md: 5a78e683f3bb94f8b9d3a64b0d4ba1f3f3df9d85
+1 -1
View File
@@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## Markdown rendering
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output
+1 -1
View File
@@ -14,7 +14,7 @@
## Markdown 渲染
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$``$$…$$``\(…\)``\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
## 终端输出
@@ -177,12 +177,51 @@
overscroll-behavior-x: contain;
}
/* Wide tables reveal their horizontal bar on hover (or keyboard focus)
instead of keeping it painted. Chromium never repaints state-conditioned
scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules
nor a :hover `scrollbar-color` change reaches the painted bar), so the
toggle is `overflow-x` itself a layout change repaints reliably. The
resting padding matches the themed bar's height, so on an overflowing
table the appearing bar exactly replaces it and nothing below shifts.
Wheel and trackpad scrolling need the pointer over the table, which is
already the hover that re-enables `auto`. */
.tableScroll:global(.md-table-wide) {
overflow-x: hidden;
padding-bottom: var(--dsh-scrollbar-width, 8px);
}
.tableScroll:global(.md-table-wide):hover,
.tableScroll:global(.md-table-wide):focus-visible {
overflow-x: auto;
padding-bottom: 0;
}
/* Chromium keyboard-focuses scrollable containers by default; the ring uses
the sheet's link focus color. */
.tableScroll:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary);
}
/* Many-column tables (the renderer's `md-table-wide` hook) keep their
natural width and scroll inside the wrapper; a hosting layout may widen
the wrapper through the hook (deepsuite chat TableWrapper parity). */
.tableScroll table {
border-collapse: collapse;
width: max-content;
max-width: max-content;
}
/* Tables under four columns, and any table inside a blockquote, fill the
column instead: cells wrap down to their minimum readable width, and the
wrapper's scroll only remains for the below-floor case (deepsuite chat
parity: `.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))`). */
.tableFill table {
width: 100%;
max-width: none;
}
.tableScroll th {
text-align: start;
padding: 10px 16px;

Some files were not shown because too many files have changed in this diff Show More