Merge origin/master into feat/cua-basic-onboarding

This commit is contained in:
creatixchu committed 2026-10-19 21:53:59 +08:00
commit 14a422021e
585 files changed
+13693 -2412

No files matched your search

@@ -6,14 +6,14 @@
en: 41f30d473ff53ce2
zh: 6851c4414f5331c1
/agent-note-session-projections-as-a-required-reader-seam/problem:
en: c0c779c717cad546
zh: 5d2916ac86bf5d84
en: fca00eeb7a8d9abd
zh: d7a9766f5322dff9
/agent-note-session-projections-as-a-required-reader-seam/decision:
en: a1c50d2f43334a88
zh: d7736994733454b4
en: 8d38ed9356085c7e
zh: e15ede51415192f3
/agent-note-session-projections-as-a-required-reader-seam/alternatives-considered:
en: aea3da939c90d240
zh: 21e91c481cdd23b7
/agent-note-session-projections-as-a-required-reader-seam/consequences:
en: 73325513ec70fbb2
zh: 610bf34b9f9db420
en: ab45eb87f7c22cdb
zh: e65a97991a45164f
@@ -6,19 +6,17 @@ English | [中文](2026-08-19-session-projection-mandatory-seam.zh.md)
## Problem
An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. Some contributor sites intentionally retain optional `ctx.inject` registration, so their readers need an explicit missing-state rule.
An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value.
## Decision
This decision builds on the split between host projection state and client views in [Session projection state and client views](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md).
Every host reader treats the projection registry and its required key as mandatory state. A plugin either declares `sessionProjections` as a required injection or resolves the registry and key explicitly and throws on the first dependent access. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics.
Every host state owner treats the projection registry and its required key as mandatory state. Owners declare `sessionProjections` as a required injection and await acquisition before exposing service methods; other consumers depend on those services. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics.
A domain contributor may register its unit through `ctx.inject(['sessionProjections'], ...)`. Optional registration controls the child lifecycle only; it does not authorize a reader to substitute a default when the registry or key is absent.
Owners acquire state through [awaited registration and retained handles](2026-10-12-session-projection-registration-handles.md). That decision supersedes optional first-access acquisition and returning a bare disposer. It preserves the required-state rule here: a missing registry cannot silently produce default host state. Client views still contain only consumed fields; business service methods expose the host data each consumer needs.
The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state.
`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; `register()` returns the exact Cordis disposer so a composite domain owner can finish cleanup against projected state before removing its unit. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set.
`onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set.
## Alternatives considered
@@ -30,6 +28,6 @@ The registry provides `stateOf(session, key)` for one typed host state and keeps
## Consequences
- Missing projection composition fails either during activation or at the first dependent host access; it never degrades to a default value.
- Missing projection composition fails during activation; it never degrades to a default value.
- Host consumers avoid repeated whole-registry snapshots and log scans.
- Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set.
@@ -6,19 +6,17 @@ Status: implemented
## 问题
可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。部分贡献位置有意保留可选的 `ctx.inject` 注册形式,因此其读取方需要明确的缺失状态规则。
可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。
## 决策
本决策建立在[会话投影的 host 状态与客户端视图](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。
每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件要么把 `sessionProjections` 声明为必需注入,要么显式解析注册表与 key,并在第一次依赖它们的访问时抛错。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。
每个 host 状态所有者都把投影注册表及其所需 key 视为必需状态。所有者将 `sessionProjections` 声明为必需注入,并在提供服务方法前等待获取完成;其他消费方依赖这些服务。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。
领域贡献方可以通过 `ctx.inject(['sessionProjections'], ...)` 注册单元。可选注册只控制子功能生命周期;它并不允许读取方在注册表或 key 缺失时替换为默认值。
所有者通过[等待注册与保留句柄](2026-10-12-session-projection-registration-handles.zh.md)获取状态。该决策取代可选首次访问获取和返回裸释放函数的方式,保留这里的必需状态规则:缺少注册表不能静默产生默认 host 状态。客户端视图仍只包含消费字段;业务服务方法提供各消费方所需的 host 数据。
注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。
`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;`register()` 返回 Cordis 的原始 disposer,使组合式领域 owner 可以先依据投影状态完成清理,再移除自身单元。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。
`onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。
## 考虑过的替代方案
@@ -30,6 +28,6 @@ Status: implemented
## 后果
- 缺少投影组合会在激活期间或第一次依赖它的 host 访问时失败,绝不会降级为默认值。
- 缺少投影组合会在激活期间失败,绝不会降级为默认值。
- host 消费方避免重复的全注册表快照和日志扫描。
- 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。
@@ -9,8 +9,8 @@
en: edefff7cd986a509
zh: a270839fa86976a2
/agent-note-guided-plugin-installation/decision:
en: 30f1e636973df06a
zh: 0f874b88a5d4d7f5
en: 5448b9f8aaa9a569
zh: 9024c5328c5da3bd
/agent-note-guided-plugin-installation/alternatives-considered:
en: be64b541f4d81e7c
zh: 8600531c08ab4288
@@ -20,7 +20,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added; the dialog closes, and the list scrolls to it when its card is on the current page. Nothing is enabled before the person has seen what was installed.
**Outcomes of the moment are toasts.** A change that waits for the next start, one a higher layer overrides, a cancelled run, and a refused action each toast and retire; nothing stays on the page.
@@ -20,7 +20,7 @@ Status: implemented
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭;其卡片在当前页面时,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
**当下的结果是 toast。** 要等下次启动的变更、被更高层覆盖的变更、被取消的运行、被拒绝的操作,各弹一条 toast 后自行消失;页面上不留任何东西。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-24-shared-time-vocabulary.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md
/agent-note-shared-time-vocabulary-for-wall-clock-selectors:
en: ae04747237709d0c
zh: a3731d98d5e940f9
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/problem:
en: abd0263d14861926
zh: e1736cd08e87f795
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision:
en: 52b45f1ac62e65ab
zh: 7f83cd4fb8dd09a6
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered:
en: 1737484c099fab52
zh: 303b144c52d167bd
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences:
en: 6ec74e7ac065bd5e
zh: 04d029059dea732b
@@ -0,0 +1,51 @@
# Agent Note: Shared time vocabulary for wall-clock selectors
Status: implemented
English | [中文](2026-09-24-shared-time-vocabulary.zh.md)
## Problem
`dsh-util-time` existed for one job and one caller shape: `canonicalClientTimeZone` validated a caller-reported zone at a wire boundary and answered `undefined`, which `dsh-api-session-controller` and `dsh-subagent` mapped onto their own domain errors. Two other consumers had grown parts of the same vocabulary privately. Schedule held a strict RFC 3339 instant parser, a local-calendar parser, a four-digit-year bound and canonical formatter, a local wall-clock resolver, and its own IANA zone canonicalizer in `packages/schedule/schedule/src/domain.ts`, throwing `ScheduleInputError` instead of answering a result. `dsh-time-context` held a second copy of the IANA zone profile in `packages/context/time-context/src/request-zone.ts`, with its own regular expression and its own `Intl.DateTimeFormat` canonicalization, to enforce the stricter policy that a request may only name a zone another process resolves back to the same identity. The IANA profile and the `Intl` canonicalization step therefore existed three times in all, so a change to that profile had to be made and verified once per copy, and the next consumer of a wall-clock selector had no shared entry point to reach for.
## Decision
`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds the parsers, `src/duration.ts` holds the span type, and `src/index.ts` re-exports both and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume.
The library answers discriminated results and carries no failure vocabulary. `parseOffsetInstant`, `fourDigitYearEpoch`, `parseLocalCalendar`, and `canonicalizeTimeZone` answer an `accepted` arm or a `rejected` arm naming a stable reason (`not_rfc3339`, `out_of_range`, `not_a_calendar_date`, `invalid_numeric_offset`, `malformed`, `blank`, `padded`, `not_area_location`, `unsupported`, `not_canonical_shape`). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. `formatFourDigitYearInstant` returns the canonical instant text or `undefined` outside the bound, `isFourDigitYearInstant` answers whether an epoch is representable at all, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is `out_of_range` in both, so year 0 is refused by `parseOffsetInstant` and `parseLocalCalendar` alike, while `resolveLocalInstant` accepts any year a real `Temporal` date carries because its callers derive local dates at the UTC edges. `LocalCalendarTime.year` records that split.
Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they now call the shared parser and map its reason. Schedule still owns the durable record shapes, the fold and transition authority, the injection-resistant framing, and all recurrence arithmetic: the cron engine, the daily and weekly date walks, the latest-due search, and the schedule-specific accepted-decision bound.
`dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers.
`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is `fromMilliseconds` and a whole-part `decompose()`, the two entries the migrated consumer calls.
The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads.
Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included.
## Alternatives considered
**Keep the shared module private to Schedule.** A Schedule-local `time` module beside `domain.ts` would shrink that file without touching a published package, its lockfile entry, its README, or its dependency set. It was rejected because the duplication this change exists to remove is the duplicate *profile*, not the duplicate line count: the zone regular expression, the canonical `Intl` step, and the instant profile would still exist twice, and the next boundary that accepts a wall-clock selector would copy a third.
**Give the library its own error type.** A `TimeInputError` carrying the reason would let callers rethrow instead of mapping, shortening each call site. It was rejected because `util/time`'s existing contract is that each boundary owns its refusal vocabulary, and Schedule's messages reach the model through the tool result `{ code, message }` and the client's message card; a shared error class would put one caller's wording into another caller's surface.
**Have the library return `undefined` instead of reasons.** That is the shape `canonicalClientTimeZone` already has, and it was the smaller change. It was rejected because Schedule must distinguish a malformed instant from a non-existent calendar date from an out-of-range clock field to keep its existing diagnostics, and `canonicalizeTimeZone` must distinguish a bad name from a locally unresolvable one; collapsing those to one falsy value would have forced Schedule to re-derive the distinction with its own regular expressions, restoring the duplication.
**Brand the millisecond count instead of building a class.** `Branded<number>` already exists in this repository for opaque identities, and a branded count is smaller than a class. It was rejected because the brand does not survive the operation that matters: TypeScript accepts `brandedA + brandedB` and answers an unbranded `number`, so a caller who adds a span to an epoch silently loses the unit, which is the exact mistake the type exists to prevent. A class makes that addition a type error, and it also gives the whole-part split one home.
**Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name.
**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need.
## Consequences
The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's `domain.ts` loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the `Intl` canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner.
The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message.
A value type publishes what its consumers call, so the span exposes the constructor and the split that `dsh-time-context` uses and nothing else. A conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it; the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the spans that will ask for a second-unit conversion, and the bound and the freeze the class already carries are what make that addition a small one. The package README records the un-migrated boundaries as a known limitation.
The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). Two samples do change: `NaN` rendered `NaNs` and `+Infinity` rendered `Infinityd NaNs`, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built.
Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch.
@@ -0,0 +1,51 @@
# Agent Note: 面向墙钟选择器的共享时间词汇
Status: implemented
[English](2026-09-24-shared-time-vocabulary.md) | 中文
## Problem
`dsh-util-time` 此前只承担一项职责、面向一种调用方:`canonicalClientTimeZone` 在协议边界校验调用方所报时区并回答 `undefined`,由 `dsh-api-session-controller` 与 `dsh-subagent` 映射成各自的域错误。同一套词汇的其余部分被另外两个消费方各自私下养大。Schedule 在 `packages/schedule/schedule/src/domain.ts` 里持有严格的 RFC 3339 时刻解析器、本地日历解析器、四位年份边界与规范格式化、本地墙钟解析,以及自己的 IANA 时区规范化实现——它抛 `ScheduleInputError` 而不是回答结果。`dsh-time-context` 在 `packages/context/time-context/src/request-zone.ts` 里持有第二份 IANA 时区剖面,配有自己的正则与自己的 `Intl.DateTimeFormat` 规范化,用来执行更严格的策略:一轮请求只能报出另一个进程会解析回同一标识的时区。因此 IANA 剖面与那一步 `Intl` 规范化合计存在三份,该剖面的任何变更都要逐份改、逐份验;而下一个需要墙钟选择器的消费方没有共享入口可用。
## Decision
`dsh-util-time` 拥有各边界所接收的时间词汇。解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,`src/index.ts` 转出两者,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。
本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`out_of_range`、`not_a_calendar_date`、`invalid_numeric_offset`、`malformed`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`isFourDigitYearInstant` 回答某个纪元是否可表示;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。两个严格解析器的原因词汇彼此对称:同一条件在两者中都叫 `out_of_range`,所以年份 0 被 `parseOffsetInstant` 与 `parseLocalCalendar` 同样拒绝;而 `resolveLocalInstant` 接受真实 `Temporal` 日期携带的任何年份,因为它的调用方需要在 UTC 边界推导本地日期。`LocalCalendarTime.year` 记录了这一分工。
Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。
`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。
`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结,不覆盖任何强制转换钩子,且时间段受可精确表示的毫秒范围约束——这正是拆分精确的前提。公开面就是 `fromMilliseconds` 与整数量拆解 `decompose()`,即已迁移消费方所调用的两个入口。
受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。
可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。
## Alternatives considered
**把共享模块保持为 Schedule 私有。** 在 `domain.ts` 旁抽出一个 Schedule 私有的 `time` 模块可以缩小它,而不触碰已发布包、其 lockfile 条目、README 或依赖集合。之所以否决,是因为本次要消除的重复是重复的**剖面**,不是重复的行数:时区正则、`Intl` 规范化步骤与时刻剖面仍会存在两份,下一个接收墙钟选择器的边界仍会复制出第三份。
**让本库自带错误类型。** 一个携带原因的 `TimeInputError` 可以让调用方直接重抛而不做映射,缩短每个调用点。之所以否决,是因为 `util/time` 既有契约规定每个边界拥有自己的拒绝词汇,而 Schedule 的消息会通过工具结果的 `{ code, message }` 与客户端的消息卡片到达模型;共享错误类会把一个调用方的措辞带进另一个调用方的界面。
**让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。
**给毫秒计数加 brand,而不是造类。** 本仓库已有用于不透明标识的 `Branded<number>`,branded 计数也比类更小。之所以否决,是因为 brand 撑不过真正要紧的那步运算:TypeScript 接受 `brandedA + brandedB` 并回答无品牌的 `number`,于是把时间段加到纪元上的调用方会静默丢掉单位,而这正是该类型要防的错误。类会把这种相加变成类型错误,也让整数量拆解只有一个归宿。
**用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。
**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。
## Consequences
时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它仍然导入本库的两个边界常量用于其 UTC 边界日期运算,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。
代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。
值类型只发布消费方会调用的部分,因此这个时间段只暴露 `dsh-time-context` 用到的构造入口与拆解,其余不发布。某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地;`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段就是将来会要求第二个单位换算的那些跨度,而类已经携带的范围约束与冻结正是让这次添加保持很小的原因。包 README 把这些尚未迁移的边界记为已知限制。
耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得。该测量不是提交进仓库的测试:消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出,于是非有限的读数在产生它的地方失败,而不是渲染出一段文本。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。
本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-12-session-projection-registration-handles.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md
/agent-note-asynchronous-projection-registration-and-retained-readers:
en: 693349255afbaec8
zh: 13bcbf3f40b93b2b
/agent-note-asynchronous-projection-registration-and-retained-readers/problem:
en: 47ba548b869be190
zh: c2e5038a56fe741c
/agent-note-asynchronous-projection-registration-and-retained-readers/decision:
en: 9e56d4bcbdc3a291
zh: bebaaab338a15e40
/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered:
en: ee5b0cd715ed1fef
zh: 6b1478dcde21916d
/agent-note-asynchronous-projection-registration-and-retained-readers/consequences:
en: 6f0e04ef7924e2c6
zh: e3730273e323440d
@@ -0,0 +1,31 @@
# Agent Note: Asynchronous projection registration and retained readers
Status: implemented
English | [中文](2026-10-12-session-projection-registration-handles.zh.md)
## Problem
Host readers coupled to registry key lookup lose access when a plugin unloads, although an Agent may still need projected state while its asynchronous cleanup drains. Repeated batch snapshots materialize unrelated client views. A synchronous registration API also makes every consumer assume that acquisition can never require asynchronous initialization.
## Decision
`SessionProjectionRegistry.register` returns a promise for a typed handle. Domain owners await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Each domain service keeps its definitions and handles internal and exposes methods returning business data. Consumers depend on that service rather than registering its definitions or receiving projection readers. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules.
A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle.
AgentRegistry owns turn and step state and exposes `turnBoundaryOf(session)`. AgentLoop acquires its inbox handle once before publishing its factory, reads the last turn before creating an Agent scope, and passes an inbox callback plus that turn number into synchronous Agent construction. The callback retains its handle through cleanup after the loop fiber removes global registrations. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state.
## Alternatives considered
**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order.
**Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness.
**Register the same units for each Agent.** The definitions already contain per-Session cells. Additional references and asynchronous Agent initialization add a creation race without providing independent data ownership.
**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break.
## Consequences
Projection owners must migrate acquisition and retain handles; other consumers use the owning services. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown.
@@ -0,0 +1,31 @@
# Agent Note: 异步投影注册与保留读取句柄
Status: implemented
[English](2026-10-12-session-projection-registration-handles.md) | 中文
## Problem
依赖注册表键查询的 Host 读取方会在插件卸载时失去访问能力,但 Agent 可能仍需在异步清理排空期间读取投影状态。重复的批量快照会具象化无关 Client 视图。同步注册 API 也使每个消费者假定获取过程永远不需要异步初始化。
## Decision
`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。领域所有者在激活期间等待它,再提供依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。领域服务在内部持有定义和句柄,通过方法返回业务数据。消费方依赖该服务,无需注册其定义或接收投影读取句柄。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。
句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。
AgentRegistry 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`。AgentLoop 在发布工厂前只获取一次 inbox 句柄,在创建 Agent 作用域前读取最后轮次,并将 inbox 回调与该轮次编号传入同步 Agent 构造过程。loop fiber 移除全局注册后,回调仍在清理期间保留其句柄。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。
## Alternatives considered
**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。
**返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。
**为每个 Agent 注册相同单元。** 定义已经包含逐 Session 单元。额外引用和异步 Agent 初始化只会引入创建竞态,不提供独立数据所有权。
**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。
## Consequences
投影所有者必须迁移获取方式并保留句柄;其他消费方使用所属服务。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-14-registry-bundle-updates.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-14-registry-bundle-updates.md
/agent-note-registry-bundle-updates:
en: 9b55699b0d5b3c7b
zh: 729a564af144048d
/agent-note-registry-bundle-updates/problem:
en: 47524f94e08192b5
zh: 335104b6fda38f03
/agent-note-registry-bundle-updates/decision:
en: 9201e6cc6cad6b8d
zh: be2332897037c43a
/agent-note-registry-bundle-updates/alternatives-considered:
en: 299b8e211b9c21bf
zh: 72bd106e96b42605
/agent-note-registry-bundle-updates/consequences:
en: 57c30a10a233c3e0
zh: 87cd51e2df8696bd
/agent-note-registry-bundle-updates/verification:
en: ab3442415bff92a6
zh: 89d477c7d14f6098
@@ -0,0 +1,47 @@
# Agent Note: Registry bundle updates
Status: implemented
English | [中文](2026-10-14-registry-bundle-updates.zh.md)
## Problem
Third-party bundles installed from a registry had no update path. The plugin page offered **Update** only for Official bundles, whose target is the running DSH version, so a person learned of a newer third-party release outside DSH and replaced it by uninstalling and installing again. With HMR an install over an installed package is refused, and a manual removal followed by a failed installation leaves the plugin uninstalled.
## Decision
**Versions are read through `pnpm view` in the profile directory.** `listVersions` runs `pnpm view <name> versions time dist-tags` over the same registry plan as `inspect`, so the `.npmrc` chain, proxy, and authentication that the installation uses also decide what the list shows, and a registry that cannot be reached moves the lookup to the next one. pnpm prints a range lookup as its highest match only, so each listed version takes its own `pnpm view <name>@<version> name version engines peerDependencies deprecated dsh` on the registry that answered. `versionListLimit` bounds how many newer versions are read and `versionLookupConcurrency` bounds the subprocesses in flight. Compatibility reuses `evaluatePluginCompatibility` with the profile's exemptions, so the list and the installation judge peers the same way.
**The Host owns the update and its recovery.** `updateBundle` views the target first and refuses a missing version, one that declares no bundle, or one whose `engines.dsh` or DSH peers reject the running version before anything changes. With HMR it then calls `removeBundle` and `installBundle(name@version, { saveExact, registry, enabled })`; when that installation fails, is cancelled, or installs but cannot start, it removes a target that stayed and installs the removed version again from the registry it came from, else over the configured plan. The reinstallation carries no request id, so a cancellation of the update cannot stop it. Without HMR it installs over the running version, which `installBundle` allows there and reports as `restart-required`. `plugin-manager/update-state` announces each step for the dialog's progress list.
**Eligibility is a listed fact.** `BundleInfo.updatable` marks a third-party profile dependency saved as a registry spec under its own name and not as an `npm:` alias, with a semver version and no plugin management; the page shows the Versions section only for those, and both methods refuse every other bundle. The `plugin_manager` tool, which has no update action, omits the field.
**Updates only move forward.** The page lists versions newer than the installed one and `updateBundle` refuses any other, so an update never asks a plugin to read data a newer version wrote.
## Alternatives considered
**Fetch the abbreviated packument over HTTP.** One request returns every version's `peerDependencies` and `deprecated`, but the Host would have to resolve registry, scope, authentication, and proxy settings from the `.npmrc` chain itself and keep that resolution identical to pnpm's. A list from a registry pnpm would not install from shows versions that cannot be installed.
**`pnpm outdated`.** It reports current, wanted, and latest only, judges no DSH peers, and with exact saves wanted equals current.
**Orchestrate removal and installation in the browser.** The Official update did so. A browser that closes or loses its connection between the two calls leaves the package removed with nobody to reinstall it, and the target check before removal would be a second, bypassable copy of the Host's rule.
**List versions while reading bundles.** `listBundles` is offline; adding registry lookups would make opening the plugin page a network operation with one subprocess per version.
## Consequences
- Checking a package costs one `pnpm view` plus one per listed version, each bounded by `inspectTimeoutMs`.
- A target saved by an update, and a reinstallation after a failed one, are exact versions even when the profile had saved a range; with HMR the bundle joins the end of the selection.
- Removal and installation take the profile lock separately, so another operation can run between them; an installation that rejects, for example on a lock timeout, still triggers the reinstallation.
- A version whose dependencies need a new build-script approval fails to install and is reinstalled at the previous version; its scripts are approved through an ordinary installation.
- A reinstallation that also fails leaves the package uninstalled, and the dialog says so.
## Verification
| Coverage | Location |
|---|---|
| Listing, registry fallback, refusals, compatibility, prereleases, deprecation, and the version limit | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| Update through removal and installation, reinstallation after a failed, cancelled, or rejected target, a failed reinstallation, refusal before any change, removal failure, and the no-HMR path | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| `pnpm view` answer parsing | `packages/boot/plugin-manager/tests/versions.spec.ts` |
| Version checks, update state, and step progress in the store | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` |
| Versions section, toolbar check, and every update outcome | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` |
@@ -0,0 +1,47 @@
# Agent Note: 注册表组合包更新
Status: implemented
[English](2026-10-14-registry-bundle-updates.md) | 中文
## 问题
从注册表安装的第三方组合包没有更新途径。插件页只为官方组合包提供 **更新**,其目标是当前运行的 DSH 版本,因此用户只能在 DSH 之外得知第三方新版本,并通过卸载再安装来替换。有 HMR 时,安装到已安装的包之上会被拒绝;手动移除后安装失败,插件就处于未安装状态。
## 决策
**版本通过在 profile 目录中运行 `pnpm view` 读取。** `listVersions` 按与 `inspect` 相同的注册表计划运行 `pnpm view <name> versions time dist-tags`,因此安装所用的 `.npmrc` 链、代理与认证同样决定列表显示什么,连不上的注册表会使查询转向下一个。pnpm 对范围查询只打印最高的匹配版本,因此每个列出的版本在作答的注册表上单独运行一次 `pnpm view <name>@<version> name version engines peerDependencies deprecated dsh`。`versionListLimit` 限制读取的新版本个数,`versionLookupConcurrency` 限制同时运行的子进程个数。兼容性复用 `evaluatePluginCompatibility` 与 profile 的豁免,因此列表与安装以同样方式判断 peer。
**更新及其恢复由 Host 负责。** `updateBundle` 先查询目标版本,在任何改动之前拒绝不存在的版本、未声明组合包的版本,以及 `engines.dsh` 或 DSH peer 拒绝当前运行版本的版本。有 HMR 时,它随后调用 `removeBundle` 与 `installBundle(name@version, { saveExact, registry, enabled })`;该安装失败、被取消,或已安装但无法启动时,先移除仍留下的目标版本,再从被移除版本原来的注册表重新安装它,没有记录时按配置的计划安装。重新安装不带请求 id,因此取消更新无法中止它。没有 HMR 时,它直接安装在运行中的版本之上,`installBundle` 在这种情况下允许这样做并报告 `restart-required`。`plugin-manager/update-state` 通告每一步,供对话框的进度列表使用。
**更新资格是列出的事实。** `BundleInfo.updatable` 标记以自身包名、按注册表 spec 保存(不是 `npm:` 别名)、带 semver 版本且不承载插件管理的第三方 profile 依赖;页面只为这些组合包显示版本区块,两个方法都拒绝其他组合包。没有更新操作的 `plugin_manager` 工具省略该字段。
**更新只向前。** 页面只列出比已安装版本新的版本,`updateBundle` 拒绝其他版本,因此更新不会让插件读取更新版本写入的数据。
## 考虑过的替代方案
**通过 HTTP 获取精简 packument。** 一次请求即可返回每个版本的 `peerDependencies` 与 `deprecated`,但 Host 必须自行从 `.npmrc` 链解析注册表、scope、认证与代理设置,并保持与 pnpm 的解析一致。来自 pnpm 不会从中安装的注册表的列表,会显示无法安装的版本。
**`pnpm outdated`。** 它只报告 current、wanted 与 latest,不判断 DSH peer,而在确切版本保存下 wanted 等于 current。
**在浏览器中编排移除与安装。** 官方组合包的更新就是这样做的。浏览器在两次调用之间关闭或断开连接时,包已被移除且无人重新安装,而移除前的目标检查会成为 Host 规则的第二份、可被绕过的副本。
**在读取组合包时列出版本。** `listBundles` 不联网;加入注册表查询会让打开插件页变成网络操作,且每个版本一个子进程。
## 后果
- 检查一个包需要一次 `pnpm view`,再加上每个列出版本一次,每次都受 `inspectTimeoutMs` 限制。
- 更新保存的目标版本,以及失败后重新安装的版本,都是确切版本,即使 profile 原先保存的是范围;有 HMR 时,组合包排到选中列表末尾。
- 移除与安装分别获取 profile 锁,其他操作可能在两者之间运行;安装被拒绝(例如锁等待超时)时,同样会触发重新安装。
- 依赖需要新的构建脚本批准的版本会安装失败,并重新安装为原版本;其脚本通过普通安装批准。
- 重新安装也失败时,包处于未安装状态,对话框会如实说明。
## 验证
| 覆盖 | 位置 |
|---|---|
| 列表、注册表回退、拒绝、兼容性、预发布、弃用与版本上限 | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| 经移除与安装的更新、目标失败、取消或被拒绝后的重新安装、重新安装失败、改动前的拒绝、移除失败与无 HMR 路径 | `packages/boot/plugin-manager/tests/manager.spec.ts` |
| `pnpm view` 答复解析 | `packages/boot/plugin-manager/tests/versions.spec.ts` |
| store 中的版本检查、更新状态与步骤进度 | `packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts` |
| 版本区块、工具栏检查与每种更新结果 | `packages/client/ui-plugin-manager/tests/components.client.spec.tsx` |
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-15-plugin-detail-documents.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-15-plugin-detail-documents.md
/agent-note-plugin-detail-documents:
en: 2cca350bb3d00987
zh: 02689ad1893d30fe
/agent-note-plugin-detail-documents/problem:
en: 018296714f6bba40
zh: 5926db9dca265dd7
/agent-note-plugin-detail-documents/decision:
en: 638ccade77cb14db
zh: c70a83e2ed0e72f7
/agent-note-plugin-detail-documents/alternatives-considered:
en: 791b7839125f9f99
zh: f75e3abeb9777494
/agent-note-plugin-detail-documents/consequences:
en: 0a37ee01fc43ff26
zh: 156571e1d3181015
@@ -0,0 +1,50 @@
# Agent Note: Plugin detail documents
Status: implemented
English | [中文](2026-10-15-plugin-detail-documents.zh.md)
## Problem
Plugin cards and pages show a one-line description. A user choosing a plugin from a market, or checking what an installed bundle does, needs the longer description that package authors already write as a README, with screenshots, as extension marketplaces of code editors show it. The [market index](2026-10-02-plugin-market-index-format.md) carries list fields only, and an index document cannot carry a Markdown document and its images for every plugin.
Package READMEs of DSH's own packages are maintainer references rather than user descriptions, so an author needs a way to name a separate user document per language. Packages come from local installations, public npm, other registries, public Git hosts, and private repositories that only the user's Git credentials can read.
## Decision
Detail documents are read in a fixed order. For every package the order begins with the Markdown file that `meta.detail` in its `locale/<language>.json` names for the display language, falling back through shorter language prefixes to English, then the package README. A market entry page continues with the README of the repository directory the entry or its package declares, at the default branch. The [document order](../../../../packages/util/plugin-detail/README.md#document-order) of the stateless [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.md) library is the single definition of the package steps; [`plugin-manager`](../../../../packages/boot/plugin-manager/README.md#bundle-detail-documents) and [`plugin-market`](../../../../packages/boot/plugin-market/README.md#plugin-detail-documents) document how they apply it.
`meta.detail` lives in the `locale/<language>.json` files that [plugin display metadata](../../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) already uses, without a package-field fallback, and is read only for detail documents, so a malformed value fails the detail read and leaves the plugin's title, description, and icon intact. The package README stands in for the README npm shows, because npm shows the README file of the published version. A source that exists but fails stops the read with a reason instead of falling through, so a missing `meta.detail` file or an oversized repository is reported rather than replaced by a different document.
Bundle pages of **Plugins** are owned by `plugin-manager` and need no market: `readBundleDetail` reads an installed bundle from its directory, without network access or caching, and an official bundle offered before installation from the npm file CDNs at its offered version. Its image route resolves the bundle and document on every request, so the Host keeps no state. The bundle page shows the document first among its sections.
The Client chooses which npm file CDNs a read asks first: it asks the existing registry comparison of the Plugins page and passes `npmFileSources`, such as `['npmmirror']` when npmmirror answered fastest. The Host orders its configured CDNs by that list and asks no CDN the configuration omits, so the Host services do not depend on the Client package that owns the comparison.
Market entry pages are owned by `plugin-market`, which reads the entry version's source: the npm file CDNs for a public npm version, or the pinned Git commit. Repository files come from GitHub's raw host and the GitLab repository files API; an anonymous request to the repository's smart HTTP endpoint tells a missing file from a repository that needs credentials, and such a repository, or one on a host without an API, is read from a cached partial clone made with the user's Git configuration. Clones are depth-1 bare repositories without file contents until a file is read, limited per command and in total, and deleted when an operation on them fails or is cancelled. Any host of an http(s) repository URL may be cloned, over https, http, or ssh when `url.<base>.insteadOf` rewrites it: Git's credential helpers answer per host, so a clone sends a host only the credentials the user stored for it. The entry page shows the document first, then versions, then source. Neither page gives the document a heading of its own; the document's own headings name it.
The Host reads only addresses it derives from installed packages, configured indexes, package manifests, and fixed CDN and API hosts; a client names a bundle or an index entry, never a URL. Images beside a document are served by authenticated Host routes that accept only image types inside the document's root, with a sandbox CSP so an SVG opened directly cannot run on the API origin. Markdown renders through the shared `DetailDocument` primitive, which omits raw HTML instead of showing it as literal text, because a README's HTML is layout for HTML renderers and reads as noise in text form; absolute HTTP(S) images load in the browser without a referrer, and relative links stay text.
## Alternatives considered
**Bundle details through the plugin market.** An earlier design read bundle documents in `plugin-market` and contributed them through `plugins.detail.section`, so bundle pages showed nothing with the market bundle off and every bundle read went through the market's cache. Installed packages are local files that need no cache, so `plugin-manager` reads them directly with the shared library.
**A clone host allowlist and https-only clones.** Credential helpers, keychains, and `.netrc` already scope credentials to their host, so a market naming an unknown host receives no credentials for other hosts; an allowlist only blocked legitimate intranet repositories.
**Detail fields in the index format.** A generator could publish document URLs, but the Host would then fetch addresses a market chooses, and the index would still not carry images or private documents.
**npm tarball downloads.** A tarball covers every registry but downloads the whole package to read one file; the CDNs serve single files by exact version. Packages of other registries fall back to the entry's repository.
**Proxying absolute images through the Host.** The Host would fetch URLs a document author chooses from the user's machine, including intranet and loopback addresses. Browser loads keep that risk out of the Host, at the cost of disclosing the user's IP address to image hosts.
**Rendering raw HTML, and clickable relative links.** Many READMEs use HTML for layout, but rendering it requires sanitizing untrusted markup, so it is omitted; relative links would need a navigation target for every source. Both are deferred.
**Full or blob-limited clones.** A full clone downloads every file of large repositories, and a blob size limit still downloads every small file. A depth-1 partial clone downloads commits and trees and fetches only the files a document reads.
## Consequences
- Authors write a user document per language under `meta.detail`; other packages show their README.
- An installed bundle without `meta.detail` or a README shows no document; its declared repository is read only on market entry pages.
- A repository README is read at the default branch and can differ from the listed version.
- The first missing file of a public GitHub or GitLab repository costs one anonymous smart HTTP request, cached for `cacheTtlMs`.
- Private repositories work when the user's Git credentials reach the host.
- Unit tests cover the document order, CDN fallback, API and clone reads, size limits, eviction, and image paths with real local repositories; real-composition tests read an installed bundle's localized document through `plugin-manager` and a market entry's document through `plugin-market`, and serve their images through the composed profiles.
@@ -0,0 +1,50 @@
# Agent Note: 插件详细介绍文档
Status: implemented
[English](2026-10-15-plugin-detail-documents.md) | 中文
## 问题
插件卡片和页面只显示一行描述。用户从市场中选择插件,或查看某个已安装组合包(bundle)的用途时,需要包作者已经以 README 形式写好的、带截图的较长描述,与代码编辑器的扩展市场显示的内容相同。[市场索引](2026-10-02-plugin-market-index-format.zh.md)只携带列表字段,一份索引文档也无法为每个插件携带一份 Markdown 文档及其图片。
DSH 自身各包的 README 是面向维护者的参考,而不是面向用户的介绍,因此作者需要一种方式,为每种语言指定一份单独的用户文档。包可能来自本地安装、公共 npm、其他注册表、公共 Git 主机,以及只有用户的 Git 凭据才能读取的私有仓库。
## 决策
详细介绍文档按固定顺序读取。对每个包,顺序首先是其 `locale/<language>.json` 中 `meta.detail` 为显示语言指定的 Markdown 文件,该语言没有时依次回退到更短的语言前缀,最后回退到英文;然后是包的 README。市场条目页面在此之后继续读取条目或其包所声明仓库目录中的 README,读取默认分支上的文件。[文档顺序](../../../../packages/util/plugin-detail/README.zh.md#document-order)属于无状态库 [`dsh-plugin-detail`](../../../../packages/util/plugin-detail/README.zh.md),是包内各步骤的唯一定义;[`plugin-manager`](../../../../packages/boot/plugin-manager/README.zh.md#bundle-detail-documents) 和 [`plugin-market`](../../../../packages/boot/plugin-market/README.zh.md#plugin-detail-documents) 说明它们如何应用该顺序。
`meta.detail` 位于[插件展示元信息](../../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)已经使用的 `locale/<language>.json` 文件中,没有包字段回退,并且只在读取详细介绍文档时读取,因此格式错误的值只让详细介绍的读取失败,插件的标题、描述和图标不受影响。包的 README 代替 npm 显示的 README,因为 npm 显示的就是已发布版本中的 README 文件。存在但读取失败的来源会带着原因终止读取,而不是继续尝试下一个来源,因此缺失的 `meta.detail` 文件或过大的仓库会被报告,而不是被另一份文档替代。
**插件**页的组合包页面由 `plugin-manager` 负责,不需要市场:`readBundleDetail` 从已安装组合包的目录读取文档,不访问网络,也不缓存;当前安装提供、尚未安装的官方组合包按其提供的版本从 npm 文件 CDN 读取。其图片路由在每次请求时解析组合包和文档,因此 Host 不保留状态。组合包页面在其各部分中首先显示该文档。
由 Client 选择一次读取先请求哪些 npm 文件 CDN:它询问插件页现有的注册表比较,并传入 `npmFileSources`,例如 npmmirror 响应最快时传入 `['npmmirror']`。Host 按该列表排列其已配置的 CDN,且不请求配置未列出的 CDN,因此 Host 服务不依赖负责该比较的 Client 包。
市场条目页面由 `plugin-market` 负责,它读取条目版本的来源:公共 npm 版本从 npm 文件 CDN 读取,Git 版本从其固定的提交读取。仓库文件通过 GitHub 的原始文件主机和 GitLab 仓库文件 API 读取;一次不带任何凭据、发往该仓库 smart HTTP 端点的请求区分文件不存在与仓库需要凭据这两种情况,需要凭据的仓库以及所在主机没有 API 的仓库,从使用用户 Git 配置创建并缓存的部分克隆中读取。克隆是深度为 1 的裸仓库,在读取文件前不包含文件内容,按单条命令和总量限制大小,对其执行的操作失败或被取消时即被删除。http(s) 仓库 URL 的任何主机都可以克隆,可通过 https、http,或在 `url.<base>.insteadOf` 改写该 URL 时通过 ssh:Git 的凭据助手按主机给出凭据,因此克隆只向一个主机发送用户为该主机保存的凭据。条目页面先显示文档,然后是版本,然后是来源信息。两个页面都不为文档提供单独的标题;文档由其自身的标题命名。
Host 只读取它从已安装的包、已配置的索引、包 manifest 以及固定的 CDN 和 API 主机推导出的地址;客户端只指定一个组合包或一个索引条目,从不指定 URL。文档旁的图片由需要认证的 Host 路由提供,这些路由只接受文档根目录内的图片类型,并带有 sandbox CSP,使直接打开的 SVG 无法在 API origin 上运行。Markdown 通过共享的 `DetailDocument` 原语渲染,该原语省略原始 HTML,而不是将其显示为原样文本,因为 README 中的 HTML 是供 HTML 渲染器使用的排版,以文本形式显示时只会干扰阅读;绝对 HTTP(S) 地址的图片由浏览器不带 referrer 加载,相对链接保持为文本。
## 考虑过的替代方案
**通过插件市场提供组合包详细介绍。** 早先的设计在 `plugin-market` 中读取组合包文档,并通过 `plugins.detail.section` 提供它们,因此关闭市场组合包时组合包页面不显示任何内容,且每次组合包读取都经过市场的缓存。已安装的包是不需要缓存的本地文件,因此由 `plugin-manager` 使用共享库直接读取。
**克隆主机允许列表和只允许 https 克隆。** 凭据助手、钥匙串和 `.netrc` 已经按主机限定凭据,因此指定未知主机的市场不会得到其他主机的凭据;允许列表只会阻止合法的内网仓库。
**在索引格式中加入详细介绍字段。** 生成器可以发布文档 URL,但这样 Host 就会获取市场选择的地址,而且索引仍然无法携带图片或私有文档。
**下载 npm tarball。** tarball 适用于所有注册表,但为了读取一个文件要下载整个包;CDN 按精确版本提供单个文件。其他注册表的包改为从条目的仓库读取。
**通过 Host 代理绝对地址的图片。** Host 会从用户的机器获取文档作者选择的 URL,包括内网和回环地址。由浏览器加载可以让 Host 避开这一风险,代价是向图片主机暴露用户的 IP 地址。
**渲染原始 HTML,以及可点击的相对链接。** 许多 README 使用 HTML 排版,但渲染它需要清理不可信的标记,因此省略它;相对链接需要为每种来源提供跳转目标。两者都已延后。
**完整克隆或按 blob 大小限制的克隆。** 完整克隆会下载大型仓库的每个文件,按 blob 大小限制仍会下载每个小文件。深度为 1 的部分克隆只下载提交和树对象,并只获取文档读取的文件。
## 后果
- 作者在 `meta.detail` 下为每种语言编写一份用户文档;其他包显示其 README。
- 没有 `meta.detail` 也没有 README 的已安装组合包不显示文档;其声明的仓库只在市场条目页面上读取。
- 仓库 README 读取的是默认分支上的文件,可能与列出的版本不一致。
- 公开 GitHub 或 GitLab 仓库中第一次遇到缺失的文件时,需要发送一次匿名的 smart HTTP 请求,其结果缓存 `cacheTtlMs`。
- 用户的 Git 凭据能访问该主机时,即可读取私有仓库。
- 单元测试使用真实的本地仓库,覆盖文档顺序、CDN 回退、API 与克隆读取、大小限制、淘汰和图片路径;真实组合测试通过 `plugin-manager` 读取已安装组合包的本地化文档,通过 `plugin-market` 读取市场条目的文档,并通过组合后的 profile 提供其图片。
@@ -12,8 +12,11 @@
en: a6dca710f2f43b03
zh: 49b507d106c5ec81
/agent-note-performance-baselines-for-tool-heavy-backend-continuation/calibration-evidence:
en: b1bdf65b8b017b68
zh: 7e8ed6c18bc6aad1
en: 16498006f02baebb
zh: 7b64cb0629ee1753
/agent-note-performance-baselines-for-tool-heavy-backend-continuation/calibration-evidence/sdk-minimal-checkpoint-calibration:
en: f4b52851e35cec56
zh: b419546dda8abf21
/agent-note-performance-baselines-for-tool-heavy-backend-continuation/alternatives-considered:
en: 3a61dff7c926eb22
zh: 8f2633b16be78233
@@ -39,11 +39,11 @@ The implementation reference is the PR #3537 merge on Apple M4 Pro, macOS arm64,
| Tool continuation | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 |
| Child catalog | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 |
Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. SDK time expectations use the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix.
Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. The historical SDK time expectation used the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix.
A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings separately report the first and second parent-catalog observations.
The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its 1,700 ms reference expectation yields a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider.
The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its historical 1,700 ms reference expectation yielded a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider.
The first Linux x64 CI measurement ran on `VM-7-113-ubuntu-ci-10` with Node 24.18.1 (run 34017868081, attempt 1, job 101444810498). The SDK median was 2,753.441 ms against its 4,250 ms budget, and tool-continuation retained-heap median was 22.274 MiB against 28.75 MiB. Request-history and tool-continuation time budgets failed: 785.498 ms against 550 ms and 1,077.285 ms against 850 ms, respectively. The unchanged Session-reopen open phase also failed at 31.6 ms against 30 ms. Attempt 2, job 101447076381 (run 34017868081) passed every benchmark on the same commit and unchanged budgets, but used `VM-7-113-ubuntu-ci-29` with Node 24.19.0. The gate runner suppressed successful child output, so that attempt supplies a passing verdict rather than raw medians. The changed runner and Node version prevent attributing the difference solely to contention or claiming stable repeated CI calibration; neither the budgets nor the shared scale are changed on this evidence.
@@ -53,6 +53,23 @@ Tool continuation also uses a 900 ms expected CI duration with 1.25× headroom (
Baseline request history uses a 600 ms expected CI duration with 1.25× headroom (750 ms). With unchanged implementation, standard two-CPU hosted run 34035306987, job 101492163630 reports totals of 618.598, 618.606, 582.035, 582.304, and 581.832 ms: median 582.304 ms exceeds the historical 550 ms budget. The 220 ms M4 expectation remains historical evidence. The same measured-verdict assertion accepts the recorded unrounded median under 750 ms, rejects it under 550 ms, and rejects a synthetic 900 ms regression. This calibrates the unoptimized baseline only; workload, timing, product code, and memory budgets remain unchanged.
### SDK-minimal checkpoint calibration
The SDK-minimal profile uses awaited checkpoints before steps, model requests, and top-level tools. This adds repeated durable writes to the timed path; the buffered-write reference above is historical. The accepted hosted expectation is 5,500 ms. Applying the existing 1.25 variance headroom gives 6,875 ms, rounded up to a whole second for a 7,000 ms budget. The 2× local-to-CI scale is not applied to a duration measured directly on CI. Other case budgets, shared factors, sample counts, and worker deadlines remain unchanged.
The following four complete attempts of CI run 39198687926 in PR #6369 use the same checkpoint-enabled code, 100 turns, 200 requests, 800 real file reads, zstd persistence, and the original 4,250 ms assertion. Each sample starts a fresh process. All runners expose two CPUs on Linux x64 with Node 24.21.0. Times are milliseconds.
| Attempt / job | CPU | Five raw total times | Median |
|---|---|---|---:|
| 1 / 117886905086 | AMD EPYC 7763 | 4009.703, 3974.780, 4073.098, 3998.816, 4065.456 | 4009.703 |
| 2 / 117898305589 | AMD EPYC 9V74 | 4182.691, 5946.327, 5581.127, 5432.567, 4362.942 | 5432.567 |
| 3 / 117906071823 | Intel Xeon 6973P-C | 2841.519, 2897.759, 2985.475, 2920.282, 2863.954 | 2897.759 |
| 4 / 117922218658 | AMD EPYC 7763 | 4388.420, 4224.595, 4312.915, 4288.871, 4336.641 | 4312.915 |
The upper observed run median is 5,432.567 ms; the slowest single sample is 5,946.327 ms. The 5,500 ms expectation rounds above the upper median, and the final 7,000 ms ceiling leaves approximately 29% above it. Runner variation is retained in the evidence, not filtered into a fastest-run result. The later canceled attempt contributes no completed sample group.
This calibration accepts the measured cost of the stronger durability policy; it is not a performance improvement. Local on/off diagnosis attributes most additional persistence time to frequent file synchronization. The browser input-overlap failures in attempts 1 and 4 are separate failures and receive no exemption from this budget. Deterministic controls use the measured-verdict assertion: the recorded upper-median sample group fails the old 4,250 ms ceiling and passes 7,000 ms, while a synthetic 7,800 ms median remains rejected.
## Alternatives considered
**Repeat existing migration and first-open variants.** Rejected: those twelve cases already distinguish read-only preparation from writable publication. These cases use the current generation and begin or continue actual model work, or enumerate a corpus rather than open one Session.
@@ -39,11 +39,11 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor
| 工具续聊 | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 |
| 子会话目录 | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 |
续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。SDK 时间期望值使用现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求历史基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。
续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。历史 SDK 时间期望值使用了现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求历史基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。
独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别报告第一次和第二次 parent 目录观察。
已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。1,700 ms 参考期望对应 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。
已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。历史 1,700 ms 参考期望对应当时的 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。
首次 Linux x64 CI 测量使用 `VM-7-113-ubuntu-ci-10` 和 Node 24.18.1(run 34017868081,attempt 1,job 101444810498)。SDK 中位数为 2,753.441 ms,预算为 4,250 ms;工具续聊保留堆中位数为 22.274 MiB,预算为 28.75 MiB。请求历史与工具续聊时间预算失败:分别为 785.498 ms 对 550 ms、1,077.285 ms 对 850 ms。未修改的 Session 重开 open 阶段也以 31.6 ms 对 30 ms 失败。Attempt 2,job 101447076381 (run 34017868081) 在同一提交和未修改预算下通过全部基准,但使用 `VM-7-113-ubuntu-ci-29` 和 Node 24.19.0。门禁运行器隐藏成功子进程的输出,因此该次运行只提供通过结论,不提供原始中位数。Runner 与 Node 版本同时变化,不能把差异仅归因于资源争用,也不能宣称已获得稳定的重复 CI 校准;这些证据不改变预算或共享比例。
@@ -53,6 +53,23 @@ SDK fixture 通过 profile patch 显式插入 `fs-local` 和 `str_replace_editor
基线请求历史使用 600 ms CI 期望时间与 1.25× 余量(750 ms)。未修改的实现在标准双 CPU 托管 run 34035306987,job 101492163630 中报告总时间为 618.598、618.606、582.035、582.304 和 581.832 ms:中位数 582.304 ms 超出历史 550 ms 预算。220 ms M4 期望保留为历史证据。与实测判定相同的断言在 750 ms 下接受未经舍入的录制中位数,在 550 ms 下拒绝它,并拒绝合成的 900 ms 回退。此校准仅针对未优化基线;负载、计时、产品代码和内存预算均不改变。
### SDK-minimal 检查点校准
SDK-minimal profile 在 step、模型请求和顶层工具执行前等待检查点。这使重复持久化写入进入计时路径;上方基于缓冲写入的参考值仅作历史记录。已接受的托管期望值为 5,500 ms,乘现有 1.25 波动余量得到 6,875 ms,再向上取整到整秒,预算为 7,000 ms。直接在 CI 测得的时间不再乘本地到 CI 的 2× 比例。其他用例预算、共享系数、样本数量和 worker 截止时间均保持不变。
以下是 PR #6369 中 CI run 39198687926 的四次完整尝试,使用相同的 checkpoint 启用代码、100 轮、200 次请求、800 次真实文件读取、zstd 持久化和原 4,250 ms 断言。每个样本启动新进程。所有 runner 均为 Linux x64、两个可用 CPU、Node 24.21.0。时间单位为毫秒。
| 尝试 / job | CPU | 五个原始总耗时 | 中位数 |
|---|---|---|---:|
| 1 / 117886905086 | AMD EPYC 7763 | 4009.703, 3974.780, 4073.098, 3998.816, 4065.456 | 4009.703 |
| 2 / 117898305589 | AMD EPYC 9V74 | 4182.691, 5946.327, 5581.127, 5432.567, 4362.942 | 5432.567 |
| 3 / 117906071823 | Intel Xeon 6973P-C | 2841.519, 2897.759, 2985.475, 2920.282, 2863.954 | 2897.759 |
| 4 / 117922218658 | AMD EPYC 7763 | 4388.420, 4224.595, 4312.915, 4288.871, 4336.641 | 4312.915 |
实测轮次中位数上界为 5,432.567 ms,最慢单次样本为 5,946.327 ms。5,500 ms 期望值向上取整覆盖该中位数,最终 7,000 ms 上限比它高约 29%。Runner 差异保留在证据中,不筛选为最快运行结果。后续取消的尝试不贡献完整样本组。
此校准接受更强持久化策略的实测成本,不表示性能已改善。本地开关对照将额外持久化时间的大部分归因于频繁的文件同步。第 1、4 次尝试中的浏览器输入重叠失败属于独立问题,不因本预算而豁免。确定性对照与实测判定使用同一断言:记录的最高中位数样本组被旧 4,250 ms 上限拒绝,在 7,000 ms 下通过;合成的 7,800 ms 中位数仍被拒绝。
## 考虑过的替代方案
**重复现有迁移和首次打开变体。** 拒绝:现有十二个用例已经区分只读准备与可写发布。这些用例使用当前代际并开始或继续实际模型工作,或者列举语料集合而不是打开单个 Session。
@@ -18,8 +18,8 @@
en: 1310a89c351e5bfe
zh: e6386ae10b893c2d
/agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints:
en: b4d81575ac574f6f
zh: f4f291ab7ce83094
en: 11937dfa4a349a19
zh: b91a136890fe92e8
/agent-note-performance-baselines-for-large-session-corpora/calibration-evidence:
en: f934c95748dcbfe0
zh: df6a68286c21510f
@@ -37,6 +37,8 @@ The projection cache is part of the list endpoint: each cold row carries cached
Workers are compiled plain-Node processes, each started fresh against a seeded root. They compose the production Session store, projection registry, JSONL persistence, SQLite Session query with `openAt: first-search` on an in-memory index, JSON storage and projection cache, the Web projection set, AgentLoop, and the real `SessionController`. Transport lookups, upload registration, default model selection, and the Workspace registry are inert providers, because no measured path depends on them.
The shared corpus and projection-list controller fixture awaits controller activation and supplies the fixed attachment image policy. Its snapshots therefore include the production `imageLimits` unit; attachment I/O remains outside these workloads. Event corpora, measurement endpoints, and budgets are unchanged.
| Case | Corpus | Samples | Endpoint |
|---|---|---:|---|
| List | 3,000 | 3 | Host boot; first `session.list`; repeated `session.list` in the same process |
@@ -37,6 +37,8 @@ Status: implemented
worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新启动。它们组合生产 Session store、投影注册表、JSONL 持久化、使用 `openAt: first-search` 和内存索引的 SQLite Session query、JSON 存储和投影缓存、Web 投影集合、AgentLoop 以及真实的 `SessionController`。传输查找、上传注册、默认模型选择和 Workspace 注册表使用惰性提供者,因为没有被测路径依赖它们。
语料与 projection-list 共用的控制器夹具等待控制器激活,并提供固定附件图片策略。因此快照包含生产 `imageLimits` 单元;附件 I/O 仍不在这些负载中。事件语料、测量端点和预算保持不变。
| 用例 | 语料 | 样本数 | 终点 |
|---|---|---:|---|
| 列表 | 3,000 | 3 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` |
@@ -21,11 +21,11 @@
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: bfa4b063696492a5
zh: 3cee9b6087dbee91
en: fcc52be9892b2bfc
zh: ac259aba407efcbc
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 7600c1671b5dc73c
zh: b38e3ae76a5a32d1
en: 168bda1a4e9e0325
zh: af9cf7b804db84b7
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations; a 5,000-message sample evaluates the batch once. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
@@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi
## Alternatives considered
- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit.
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
- **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure.
@@ -43,7 +43,7 @@ Status: implemented
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值,5,000 条消息的样本则整批求值一次。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
@@ -54,6 +54,7 @@ Status: implemented
## 考虑过的替代方案
- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。
@@ -15,8 +15,8 @@
en: e6c93d89a25f0d03
zh: 65e1f5842bc74008
/agent-note-session-projections-and-command-lifecycle-logging/proposal/host-projection-registry-dsh-session-projection-new-package:
en: a0f86a588c9dff31
zh: 421803bc8b0dddb5
en: bb52ae77e0302d2d
zh: 75e6a76ce7d02342
/agent-note-session-projections-and-command-lifecycle-logging/proposal/shipped-consumer-the-subagent-identity-unit:
en: 10093535660c3cae
zh: a7f4ff9e71ee6751
@@ -39,8 +39,8 @@
en: 64ad8d08a8ab44e0
zh: fb0a0f7369d0bd23
/agent-note-session-projections-and-command-lifecycle-logging/alternatives-considered:
en: e945e84e8360e0c4
zh: becdeb100a4c5b6b
en: 4776bd01e7ec14f0
zh: 9b1c2db96e1120f6
/agent-note-session-projections-and-command-lifecycle-logging/acceptance-criteria:
en: 9dfcca30fffda225
zh: 0beddb81d32e0ca7
@@ -24,15 +24,14 @@ A state-carrying log event MUST carry the complete post-change state, never a ba
### Host projection registry (`dsh-session-projection`, new package)
A light Service Definition package: merge-extensible host-state and client-view type maps, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other.
A light Service Definition package: local host-state definitions, a merge-extensible client-view type map, the registry service, and zod validation for persisted state and client values. Capability-seam roles: domain host plugins provide projection units, carriers consume them, and neither knows the other.
What a domain registers is a **state-driven computation unit** — a pure fold plus declarations and an optional client view — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the computation. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract.
```ts ignore-check
export interface SessionProjectionStateMap {} // host fold states
export interface SessionProjectionMap {} // client-visible whole values
export interface SessionProjectionMap {} // client-visible whole values
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
export interface ProjectionDefinition<K extends string, S> {
key: K
stateSchema: ZodType<S>
persist?: boolean // host-only units opt in; client-visible units always persist
@@ -54,7 +53,7 @@ declare module 'cordis' {
}
```
- `SessionProjectionStateMap` types host fold states; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md).
- `ProjectionDefinition<K, S>` carries each unit's host state type; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md).
- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
@@ -161,7 +160,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
**Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package.
**A second client DTO table** — rejected: `SessionProjectionMap` remains the single client vocabulary shared by wire and UI. `SessionProjectionStateMap` is not another client view table; it types host fold state so internal state may differ from the value sent to clients.
**A second client DTO table** — rejected: `SessionProjectionMap` remains the single client vocabulary shared by wire and UI. Each unit types its host fold state locally, independently of the value sent to clients.
**Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots).
@@ -24,15 +24,14 @@ Status: proposed
### host 侧投影注册表(`dsh-session-projection`,新包)
一个轻量的 Service Definition 包:merge-extensible 的 host 状态与客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。
一个轻量的 Service Definition 包:局部 host 状态定义、merge-extensible 的客户端视图类型表、注册表服务,以及针对持久状态和客户端值的 zod 校验。能力 seam 的角色如下:领域 host 插件提供投影单元,载体消费这些单元,两侧互不相识。
领域注册的是一个**状态驱动计算单元(state-driven computation unit)**——纯折叠、若干声明及可选客户端视图——绝不是一个不透明的 getter。驱动它是框架的职责(订阅、水位线(watermark)、缓存,以及后续的检查点机制),领域只负责计算。投影服务于所有业务领域(会话标题、plan、goal、权限、todos);命令只是其中一条触发路径,在本约定中没有任何特殊地位。
```ts ignore-check
export interface SessionProjectionStateMap {} // host fold states
export interface SessionProjectionMap {} // client-visible whole values
export interface SessionProjectionMap {} // client-visible whole values
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
export interface ProjectionDefinition<K extends string, S> {
key: K
stateSchema: ZodType<S>
persist?: boolean // host-only units opt in; client-visible units always persist
@@ -54,7 +53,7 @@ declare module 'cordis' {
}
```
- `SessionProjectionStateMap` 描述 host 折叠状态;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)。
- `ProjectionDefinition<K, S>` 携带各单元的宿主状态类型;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)。
- **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
- 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
@@ -161,7 +160,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器
**把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACP(Agent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。
**第二张客户端 DTO 类型表**——不予采纳:`SessionProjectionMap` 仍是协议与 UI 共享的唯一客户端词汇。`SessionProjectionStateMap` 不是另一张客户端视图表;它描述 host 折叠状态,使内部状态可以不同于发往客户端的值。
**第二张客户端 DTO 类型表**——不予采纳:`SessionProjectionMap` 仍是协议与 UI 共享的唯一客户端词汇。各单元在内部声明宿主折叠状态类型,与发往客户端的值相互独立。
**用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot)。
+1 -1
View File
@@ -130,7 +130,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and
- Packages use `@deepseek-ai/dsh-<name>`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
- ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer, directly or through its documented registration handle.
- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read unless the envelope carries `ignorable: true`. Bump `SESSION_FORMAT_VERSION` only when existing discriminators cannot prevent unsafe interpretation; same-version changes require safe reader behavior ([review](.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md)).
- **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
- **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
+1
View File
@@ -1276,6 +1276,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'],
['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'],
['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'],
['session-checkpoint-policy', '@deepseek-ai/dsh-session-checkpoint-policy'],
])
expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal')
expect(stdout).not.toContain('@deepseek-ai/dsh-base')
@@ -0,0 +1,72 @@
/** The shipped minimal profile must stop model dispatch when a durable checkpoint fails. */
import { createServer } from 'node:http'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
import { describe, expect, it, onTestFinished } from 'vitest'
const dshBin = fileURLToPath(new URL('../../../lib/bin.js', import.meta.url))
const fixture = fileURLToPath(new URL('./fixtures/checkpoint-failure.mjs', import.meta.url))
describe.skipIf(!existsSync(dshBin))('sdk-minimal durability checkpoints', () => {
it.each([false, true])('checkpoint storage failure with policy disabled=%s', async (disabled) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-sdk-checkpoint-'))
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
let modelRequests = 0
const server = createServer((request, response) => {
request.resume()
request.on('end', () => {
modelRequests += 1
const events = [
{ type: 'message_start', message: { id: 'checkpoint-response', model: 'deepseek-v4-flash', usage: { input_tokens: 3, output_tokens: 0 } } },
{ type: 'content_block_start', index: 0, content_block: { type: 'text', text: 'OK' } },
{ type: 'content_block_stop', index: 0 },
{ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 1 } },
{ type: 'message_stop' },
]
response.writeHead(200, { 'content-type': 'text/event-stream' })
response.end(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(''))
})
})
onTestFinished(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) reject(error)
else resolve()
})
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('model server has no TCP address')
const patch = join(root, 'checkpoint.patch.yml')
await writeFile(patch, JSON.stringify([
{ id: 'session-log-deepseek', config: { enabled: false } },
{ insert: [{ id: 'checkpoint-failure', name: fixture }] },
...disabled ? [{ id: 'session-checkpoint-policy', disabled: true }] : [],
]))
const harness = new DeepSeekHarness({
dshBin, profile: 'sdk-minimal', dshHome: join(root, 'home'),
cwd: root, processCwd: root, patches: [patch],
env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-checkpoint-test', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}` },
})
onTestFinished(async () => { await harness.close() })
const result = await harness.run('Reply OK without calling tools.')
expect(modelRequests).toBe(disabled ? 1 : 0)
const terminal = result.events.findLast(event => event.type === 'turn/end')
expect(terminal).toMatchObject({ data: { reason: { kind: disabled ? 'completed' : 'error' } } })
if (disabled) {
expect(result.finalResponse).toBe('OK')
} else {
expect(terminal).toMatchObject({
data: { reason: { kind: 'error', error: { message: 'sdk checkpoint persistence unavailable' } } },
})
}
})
})
@@ -0,0 +1,13 @@
/** Reject durable checkpoints without changing background JSONL writes. */
export const name = 'sdk-checkpoint-failure'
export const inject = ['sessionPersistence']
/**
* Install a storage-failure signal at the public session flush event.
* @param ctx - Plugin context owning the flush listener.
*/
export function apply(ctx) {
ctx.on('session/flush', () => {
throw new Error('sdk checkpoint persistence unavailable')
})
}
+9 -4
View File
@@ -1,3 +1,5 @@
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
import { subagentModelSelectionProjectionDefinition } from '@deepseek-ai/dsh-tool-subagent/src/model-selection-state.ts'
import { createRequire } from 'node:module'
import { randomUUID } from 'node:crypto'
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'
@@ -327,13 +329,14 @@ describe('the shipped Web composition', () => {
sessionId: SessionId('preset-model-selection-enabled'),
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined),
})
const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
try {
expect(toolNames(ctx, disabled.agent)).toContain('list_subagent_models')
expect(toolParameterNames(ctx, disabled.agent, 'subagent')).toEqual(expect.arrayContaining([
'model', 'provider', 'reasoning_effort',
]))
expect(ctx.sessionProjections.stateOf(disabled.agent.session, 'subagentModelSelectionPolicy')).toBeNull()
expect(ctx.sessionProjections.stateOf(enabled.agent.session, 'subagentModelSelectionPolicy')).toEqual([
expect(policy.stateOf(disabled.agent.session)).toBeNull()
expect(policy.stateOf(enabled.agent.session)).toEqual([
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
])
expect(toolNames(ctx, enabled.agent)).toContain('list_subagent_models')
@@ -344,6 +347,7 @@ describe('the shipped Web composition', () => {
expect(toolParameterNames(ctx, agent, 'subagent_fork')).toEqual(['cwd', 'description', 'prompt'])
}
} finally {
await policy.dispose()
await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false })
await enabled.dispose()
await disabled.dispose()
@@ -879,7 +883,7 @@ describe('a switch survives the session', () => {
// The header keeps the creation fact; the log carries what it runs.
expect(handle.agent.session.header.agentPreset).toBe('standard')
expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal')
expect(ctx.sessionProjections.checkpoint(handle.agent.session).agentPreset?.val).toBe('minimal')
} finally {
await handle.dispose()
}
@@ -894,7 +898,8 @@ describe('a forked session', () => {
meta: { agentPreset: 'minimal' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
})
const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined
const projection = await ctx.sessionProjections.register(agentPresetProjectionDefinition)
const inherited = projection.stateOf(parent.agent.session) ?? undefined
const child = await ctx.agents.create({
sessionId: SessionId('preset-fork-child'),
seed: [],
+3
View File
@@ -55,6 +55,9 @@ async function main(): Promise<void> {
await running?.shutdown.shutdown(0)
await send({ type: 'shutdown-complete' })
if (process.connected) process.disconnect()
// A handle a disposed plugin left open would otherwise keep the Host alive past the shell's graceful
// deadline; a startup failure keeps its own diagnostic exit.
if (running !== undefined) process.exit()
})()
process.on('message', (message: unknown) => {
if (typeof message !== 'object' || message === null || !('type' in message)) return
+4 -4
View File
@@ -54,11 +54,11 @@
en: ac31ebdd9de54f34
zh: 921e534d5c4b3a16
/deepseek-harness-desktop/windows-installer-interface:
en: 8cb4b7babbd8714d
zh: b4aebc1d2d152e01
en: 9b90a2651a6d2b52
zh: 86d4d9c4358aad80
/deepseek-harness-desktop/updates:
en: 4788b6b60489ef92
zh: 6312c5590efa27f1
en: 87db37529b51da62
zh: 4fc0d752e437e7d6
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2e57457c44c85b5f
zh: 5b8510a3dc4c6c9d
+5 -3
View File
@@ -255,9 +255,9 @@ The Windows installer uses native NSIS pages with light and dark palettes, syste
The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation.
Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform.
Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The progress page keeps one opaque pixel buffer for its lifetime, draws static artwork once, and copies repainted pixels with GDI. The preparation hook leaves production dependency collection to electron-builder on every platform.
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The renderer receives caption colors from the NSIS theme; pixel checks print into an off-screen bitmap and also run with the progress child hidden. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed.
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
@@ -269,13 +269,15 @@ Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --com
On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once when startup opens the workspace directly, without enabling always-on-top. Startup into the welcome page discards that request so a later login keeps its normal activation behavior. Ordinary launches and other platforms do not use this foregrounding step.
On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window.
Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace.
Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes.
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. Ordinary quit asks about interruptible work as described under Closing the window and quitting, then hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path.
The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. The Host exits as soon as it acknowledges completed teardown, so handles left open by plugins do not count against the graceful deadline. Ordinary quit asks about interruptible work as described under Closing the window and quitting, then hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path.
If task teardown fails after confirmed Host exit, installation is refused and the shell restores the current-version Host before allowing another restart confirmation. Installer launch failure after a clean Host stop uses the same recovery. After the replacement Host authenticates, the shell reloads the existing application URL so the Web page obtains its current port, cookie, and boot injections; page-load failure opens native fatal recovery. An unconfirmed process exit never permits a replacement Host. The downloaded target remains available for retry. A known mandatory policy remains blocking throughout recovery; unsuccessful Host restoration opens the native fatal-recovery dialog.
+5 -3
View File
@@ -255,9 +255,9 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影
主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。
Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。
Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。进度页在其生命周期内保留一个不透明像素缓冲,只绘制一次静态图像,并通过 GDI 复制重绘的像素。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。绘制器接收 NSIS 主题传入的标题栏颜色;像素检查绘制到离屏位图,并在隐藏进度子窗口时验证。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
@@ -269,13 +269,15 @@ Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APP
Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。
macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。常规退出先按"关闭窗口与退出"一节所述询问可中断的工作,再在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。
左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。Host 确认收尾完成后立即退出,插件遗留的打开句柄不会占用正常收尾期限。常规退出先按"关闭窗口与退出"一节所述询问可中断的工作,再在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。
若任务收尾失败但已确认 Host 退出,安装会被拒绝,壳会在允许再次确认重启前恢复当前版本的 Host。Host 正常停止后的安装器启动失败使用同一恢复路径。替代 Host 启动并完成认证后,壳重新加载原有应用地址,让 Web 页面获取当前端口、Cookie 和启动注入数据;页面加载失败时打开原生致命故障恢复弹窗。未确认进程退出时,绝不允许启动替代 Host。已下载目标保留以供重试。已知强更策略在恢复过程中继续阻塞;Host 恢复失败打开原生致命故障恢复弹窗。
@@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib'
/** Electron-builder fields asserted by the Desktop release tests. */
export interface DesktopElectronBuilderConfig {
readonly appId: string
readonly copyright: string
readonly artifactName: string
readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }]
readonly directories: {
@@ -43,6 +44,7 @@ export interface DesktopElectronBuilderConfig {
}
}
readonly nsis: {
readonly uninstallDisplayName: '${productName}'
readonly include: string
readonly oneClick: false
readonly perMachine: false
+1 -1
View File
@@ -62,7 +62,7 @@ Function InstallerProgressShow
StrCpy $0 1
StrCpy $1 "$PLUGINSDIR\brand-dark-2x.bmp"
${EndIf}
System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c'
System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, i $InstallerControlHover, i $InstallerControlPressed, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c'
Pop $InstallerProgressWindow
${If} $InstallerProgressWindow == 0
MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)"
+11
View File
@@ -18,6 +18,8 @@ Var InstallerEditFrame
Var InstallerBrowse
Var InstallerLaunch
Var InstallerExpanded
Var InstallerMinimizeButton
Var InstallerCloseButton
!include "${__FILEDIR__}\path.nsh"
!include "${__FILEDIR__}\drawing.nsh"
@@ -59,11 +61,13 @@ Function InstallerCreate
!insertmacro InstallerControlColors $4
${NSD_CreateButton} 0 0 0 0 "−"
Pop $4
StrCpy $InstallerMinimizeButton $4
!insertmacro InstallerPlace $4 504 8 40 32
${NSD_OnClick} $4 InstallerMinimize
${NSD_OnNotify} $4 InstallerPaintButton
${NSD_CreateButton} 0 0 0 0 "×"
Pop $4
StrCpy $InstallerCloseButton $4
!insertmacro InstallerPlace $4 548 8 40 32
${NSD_OnClick} $4 InstallerClose
${NSD_OnNotify} $4 InstallerPaintButton
@@ -321,6 +325,13 @@ Function InstallerPaintButton
${If} $R1 != 0
StrCpy $R3 $InstallerControlHover
${EndIf}
${If} $R0 == $InstallerMinimizeButton
${OrIf} $R0 == $InstallerCloseButton
IntOp $R1 $R9 & 1
${If} $R1 != 0
StrCpy $R3 $InstallerControlPressed
${EndIf}
${EndIf}
${EndIf}
System::Call 'gdiplus::GdipCreateSolidFill(i R3, *p .R1)'
System::Call 'gdiplus::GdipCreateFromHDC(p R4, *p .R5)'
+7 -1
View File
@@ -22,6 +22,9 @@
!define INSTALLER_PRIMARY_HOVER 0xFF2D3135
!define INSTALLER_PRIMARY_PRESSED 0xFF000000
!define INSTALLER_CONTROL_HOVER 0xFFEEF0F2
!define INSTALLER_CONTROL_PRESSED 0xFFD4DBE9
!define INSTALLER_DARK_CONTROL_HOVER 0xFF303034
!define INSTALLER_DARK_CONTROL_PRESSED 0xFF45454B
!define INSTALLER_TRACK_COLOR 0xFFE9ECF2
!define INSTALLER_TEXT_COLORREF 0x15110F
@@ -35,6 +38,7 @@ Var InstallerPrimary
Var InstallerPrimaryHover
Var InstallerPrimaryPressed
Var InstallerControlHover
Var InstallerControlPressed
Var InstallerTrack
Var InstallerButtonText
Var InstallerBorder
@@ -70,7 +74,8 @@ Function InstallerResolveTheme
StrCpy $InstallerPrimary 0xFFF9FAFB
StrCpy $InstallerPrimaryHover 0xFFE9ECF2
StrCpy $InstallerPrimaryPressed 0xFFD4DBE9
StrCpy $InstallerControlHover 0xFF303034
StrCpy $InstallerControlHover ${INSTALLER_DARK_CONTROL_HOVER}
StrCpy $InstallerControlPressed ${INSTALLER_DARK_CONTROL_PRESSED}
StrCpy $InstallerTrack 0xFF61666B
StrCpy $InstallerButtonText 0x15110F
StrCpy $InstallerBorder 0xFF61666B
@@ -84,6 +89,7 @@ Function InstallerResolveTheme
StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER}
StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED}
StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER}
StrCpy $InstallerControlPressed ${INSTALLER_CONTROL_PRESSED}
StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR}
StrCpy $InstallerButtonText 0xFFFFFF
StrCpy $InstallerBorder 0xFFBBC0C8
+164 -35
View File
@@ -113,7 +113,24 @@ struct ProgressPage {
UINT dpi;
ULONG_PTR gdiplus;
Image* brand;
Bitmap* buffer;
HDC bufferDc;
HBITMAP bufferBitmap;
HGDIOBJ previousBitmap;
UINT pressedCommand;
UINT hoveredCommand;
ARGB controlHover;
ARGB controlPressed;
WCHAR captions[5][128];
~ProgressPage() {
delete buffer;
delete brand;
if (previousBitmap) SelectObject(bufferDc, previousBitmap);
if (bufferBitmap) DeleteObject(bufferBitmap);
if (bufferDc) DeleteDC(bufferDc);
if (gdiplus) GdiplusShutdown(gdiplus);
}
};
// NSIS shows its page after MUI's SHOW callback; keep its controls off screen.
@@ -139,6 +156,45 @@ static void FillProgress(Graphics& graphics, Brush& brush, REAL width) {
graphics.FillPath(&brush, &path);
}
static UINT CaptionCommand(const ProgressPage& page, LPARAM position) {
const int x = static_cast<short>(LOWORD(position));
const int y = static_cast<short>(HIWORD(position));
if (y < 0 || y >= MulDiv(48, page.dpi, 96) || x < MulDiv(504, page.dpi, 96) || x >= MulDiv(600, page.dpi, 96)) return 0;
return x >= MulDiv(548, page.dpi, 96) ? SC_CLOSE : SC_MINIMIZE;
}
static void PaintCaptionButtons(Graphics& graphics, const ProgressPage& page) {
SolidBrush background(page.dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.FillRectangle(&background, 504, 0, 96, 48);
SolidBrush ink(page.dark ? Color(255, 255, 255, 255) : Color(255, 15, 17, 21));
FontFamily family(L"Microsoft YaHei UI");
Font font(&family, 16, FontStyleRegular, UnitPixel);
StringFormat centered;
centered.SetAlignment(StringAlignmentCenter);
centered.SetLineAlignment(StringAlignmentCenter);
for (UINT command : {SC_MINIMIZE, SC_CLOSE}) {
const int x = command == SC_MINIMIZE ? 504 : 548;
if (page.hoveredCommand == command && (!page.pressedCommand || page.pressedCommand == command)) {
const bool pressed = page.pressedCommand == command;
SolidBrush fill(Color(pressed ? page.controlPressed : page.controlHover));
GraphicsPath path;
path.AddArc(x, 8, 20, 20, 180, 90);
path.AddArc(x + 20, 8, 20, 20, 270, 90);
path.AddArc(x + 20, 20, 20, 20, 0, 90);
path.AddArc(x, 20, 20, 20, 90, 90);
path.CloseFigure();
graphics.FillPath(&fill, &path);
}
graphics.DrawString(command == SC_MINIMIZE ? L"\x2212" : L"\x00d7", -1,
&font, RectF(static_cast<REAL>(x), 8, 40, 32), &centered, &ink);
}
}
static void InvalidateCaptionButtons(HWND window, const ProgressPage& page) {
RECT bounds{MulDiv(504, page.dpi, 96), 0, MulDiv(600, page.dpi, 96), MulDiv(48, page.dpi, 96)};
InvalidateRect(window, &bounds, FALSE);
}
static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
auto* page = reinterpret_cast<ProgressPage*>(GetWindowLongPtrW(window, GWLP_USERDATA));
if (message == WM_CREATE) {
@@ -148,29 +204,68 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
}
if (message == WM_ERASEBKGND) return 1;
if (message == WM_TIMER) { InvalidateRect(window, nullptr, FALSE); return 0; }
if (message == WM_LBUTTONDOWN && page) {
const int x = LOWORD(lparam) * 96 / page->dpi;
const int y = HIWORD(lparam) * 96 / page->dpi;
if (y < 48) {
HWND parent = GetParent(window);
if (x >= 548) PostMessageW(parent, WM_CLOSE, 0, 0);
else if (x >= 504) ShowWindow(parent, SW_MINIMIZE);
else { ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); }
if (message == WM_MOUSEMOVE && page) {
TRACKMOUSEEVENT tracking{sizeof(tracking), TME_LEAVE, window, 0};
TrackMouseEvent(&tracking);
const UINT hovered = CaptionCommand(*page, lparam);
if (page->hoveredCommand != hovered) {
page->hoveredCommand = hovered;
InvalidateCaptionButtons(window, *page);
}
return 0;
}
if (message == WM_PAINT && page) {
PAINTSTRUCT paint;
HDC dc = BeginPaint(window, &paint);
if (message == WM_MOUSELEAVE && page) {
page->hoveredCommand = 0;
InvalidateCaptionButtons(window, *page);
return 0;
}
if (message == WM_LBUTTONDOWN && page) {
const int x = static_cast<short>(LOWORD(lparam));
const int y = static_cast<short>(HIWORD(lparam));
page->pressedCommand = CaptionCommand(*page, lparam);
if (page->pressedCommand) {
page->hoveredCommand = page->pressedCommand;
SetCapture(window);
InvalidateCaptionButtons(window, *page);
}
else if (x >= 0 && x < MulDiv(504, page->dpi, 96) && y >= 0 && y < MulDiv(48, page->dpi, 96)) {
HWND parent = GetParent(window);
ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0);
}
return 0;
}
if (message == WM_LBUTTONUP && page) {
const UINT command = page->pressedCommand;
page->pressedCommand = 0;
page->hoveredCommand = CaptionCommand(*page, lparam);
InvalidateCaptionButtons(window, *page);
const bool activate = command && GetCapture() == window && CaptionCommand(*page, lparam) == command;
if (GetCapture() == window) ReleaseCapture();
if (activate) {
if (command == SC_CLOSE) PostMessageW(GetParent(window), WM_CLOSE, 0, 0);
else ShowWindow(GetParent(window), SW_MINIMIZE);
}
return 0;
}
if ((message == WM_CAPTURECHANGED || message == WM_CANCELMODE) && page) {
page->pressedCommand = 0;
InvalidateCaptionButtons(window, *page);
if (message == WM_CANCELMODE && GetCapture() == window) ReleaseCapture();
return 0;
}
if ((message == WM_PAINT || message == WM_PRINTCLIENT) && page) {
PAINTSTRUCT paint = {};
const bool printing = message == WM_PRINTCLIENT;
HDC dc = printing ? reinterpret_cast<HDC>(wparam) : BeginPaint(window, &paint);
if (printing) GetClientRect(window, &paint.rcPaint);
{
Bitmap buffer(MulDiv(600, page->dpi, 96), MulDiv(600, page->dpi, 96), PixelFormat32bppPARGB);
Graphics graphics(&buffer);
Graphics graphics(page->buffer);
graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f);
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
PaintCaptionButtons(graphics, *page);
SolidBrush background(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.FillRectangle(&background, 48, 480, 504, 56);
const int stage = static_cast<int>(reinterpret_cast<INT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.Stage")));
const double fraction = reinterpret_cast<UINT_PTR>(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0;
page->progress.Advance(stage, fraction, GetTickCount64());
@@ -188,28 +283,25 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L
wsprintfW(caption, page->captions[page->progress.CaptionStage()], percent);
SetWindowTextW(window, caption);
graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), &centered, &ink);
Font controls(&family, 16, FontStyleRegular, UnitPixel);
graphics.DrawString(L"\x2212", -1, &controls, RectF(504, 8, 40, 32), &centered, &ink);
graphics.DrawString(L"\x00d7", -1, &controls, RectF(548, 8, 40, 32), &centered, &ink);
Graphics screen(dc);
screen.DrawImage(&buffer, 0, 0);
}
EndPaint(window, &paint);
BitBlt(dc, paint.rcPaint.left, paint.rcPaint.top,
paint.rcPaint.right - paint.rcPaint.left, paint.rcPaint.bottom - paint.rcPaint.top,
page->bufferDc, paint.rcPaint.left, paint.rcPaint.top, SRCCOPY);
if (!printing) EndPaint(window, &paint);
if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast<HANDLE>(100));
return 0;
}
if (message == WM_NCDESTROY && page) {
KillTimer(window, 1);
delete page->brand;
GdiplusShutdown(page->gdiplus);
delete page;
}
return DefWindowProcW(window, message, wparam, lparam);
}
// Runs on the NSIS UI thread; the stock installation section runs on its worker.
// Caption colors are opaque ARGB from NSIS; failure preserves the stock page's visibility.
extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
BOOL dark, UINT dpi, ARGB controlHover, ARGB controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning) {
HINSTANCE module = GetModuleHandleW(nullptr);
WNDCLASSW type = {};
@@ -222,22 +314,56 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent,
if (!page) return nullptr;
GdiplusStartupInput startup;
if (GdiplusStartup(&page->gdiplus, &startup, nullptr) != Ok) { delete page; return nullptr; }
HWND stockPage = GetParent(source);
if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
GdiplusShutdown(page->gdiplus); delete page; return nullptr;
}
ShowWindow(stockPage, SW_HIDE);
page->dark = dark != FALSE;
page->dpi = dpi;
page->controlHover = controlHover;
page->controlPressed = controlPressed;
const WCHAR* captions[] = {preparing, extracting, copying, registering, cleaning};
for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128);
page->brand = new Image(brand);
if (page->brand->GetLastStatus() != Ok) {
delete page->brand; GdiplusShutdown(page->gdiplus); delete page; return nullptr;
if (!page->brand || page->brand->GetLastStatus() != Ok) {
delete page; return nullptr;
}
HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE,
const int size = MulDiv(600, dpi, 96);
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = size;
info.bmiHeader.biHeight = -size;
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
info.bmiHeader.biCompression = BI_RGB;
void* pixels = nullptr;
page->bufferDc = CreateCompatibleDC(nullptr);
page->bufferBitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0);
if (!page->bufferDc || !page->bufferBitmap) { delete page; return nullptr; }
page->previousBitmap = SelectObject(page->bufferDc, page->bufferBitmap);
if (!page->previousBitmap) { delete page; return nullptr; }
// Opaque pixels are shared by GDI+ drawing and the window's GDI blit.
page->buffer = new Bitmap(size, size, size * 4, PixelFormat32bppPARGB, static_cast<BYTE*>(pixels));
if (!page->buffer || page->buffer->GetLastStatus() != Ok) { delete page; return nullptr; }
{
// Static artwork stays in the buffer for this page's lifetime.
Graphics graphics(page->buffer);
graphics.ScaleTransform(dpi / 96.0f, dpi / 96.0f);
graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255));
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic);
graphics.SetPixelOffsetMode(PixelOffsetModeHalf);
graphics.DrawImage(page->brand, Rect(0, 174, 600, 196));
PaintCaptionButtons(graphics, *page);
}
delete page->brand;
page->brand = nullptr;
HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD,
0, 0, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), parent, nullptr, module, page);
if (!window) { delete page->brand; GdiplusShutdown(page->gdiplus); delete page; }
if (!window) { delete page; return nullptr; }
// Keep the stock page available until the replacement is fully constructed.
HWND stockPage = GetParent(source);
if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) {
DestroyWindow(window); return nullptr;
}
ShowWindow(stockPage, SW_HIDE);
ShowWindow(window, SW_SHOW);
return window;
}
@@ -268,6 +394,8 @@ extern "C" __declspec(dllexport) BOOL __cdecl InstallerFinishProgress(HWND windo
static LRESULT CALLBACK FrameProc(HWND window, UINT message, WPARAM wparam,
LPARAM lparam, UINT_PTR id, DWORD_PTR) {
if (message == WM_NCCALCSIZE && wparam) return 0;
// Activation must not repaint the standard frame over the custom client area.
if (message == WM_NCACTIVATE) return DefSubclassProc(window, message, wparam, -1);
if (message == WM_NCHITTEST) {
const LRESULT hit = DefSubclassProc(window, message, wparam, lparam);
// The installer has a fixed size; its page provides the drag area.
@@ -291,7 +419,8 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window
ShowWindow(child, SW_HIDE);
}
}
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME);
// DWM retains native transitions; WM_NCCALCSIZE removes the visible caption.
SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME | WS_CAPTION);
const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));
if (FAILED(result)) return result;
+3
View File
@@ -4,6 +4,9 @@
"version": "0.2.1-rc.2",
"private": true,
"license": "MIT",
"author": {
"name": "Hangzhou DeepSeek Artificial Intelligence Co., Ltd."
},
"type": "module",
"main": "lib/main.js",
"scripts": {
+2 -2
View File
@@ -51,8 +51,8 @@
en: a7aa3066e649ffe8
zh: 4334637910058c09
/desktop-packaging/installation-and-release-verification:
en: 0006b6b1ae4998f4
zh: 2bab6918b0c37b25
en: eb35eb973b796643
zh: c1c52311632b7296
/desktop-packaging/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
+2
View File
@@ -274,6 +274,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
## Installation and release verification
Windows lists the installed application by product name without a version suffix; the version remains in the separate version field. Desktop's `package.json` `author.name` supplies the installer publisher and executable company name, independently of the signing certificate. Windows and macOS retain product-name copyright text. Installation or upgrade with a rebuilt installer applies the new metadata; existing installations retain their previous values until then.
Keep the generated installer, its version and packaging records together. Review signatures and runtime checks before installation, then follow the [installed-update qualification procedure](../tests/installed-update/README.md) for installed launch and upgrade evidence. The [Desktop test record](../tests/README.md) owns verification limits; a successful workflow alone does not establish production preset, new-workstation or installed-upgrade acceptance.
## Dev Note
+2
View File
@@ -274,6 +274,8 @@ macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/
## 安装与发布验收
Windows 以不带版本后缀的产品名称显示已安装应用,版本保留在单独的版本字段中。Desktop 的 `package.json` 中的 `author.name` 提供安装包发布者和可执行文件公司名称,与签名证书相互独立。Windows 和 macOS 的版权文本仍使用产品名称。重新构建的安装包在安装或升级时写入新元数据;既有安装在此之前保留原值。
一起保留生成的安装包、版本和打包记录。安装前检查签名与运行时验证结果,再按[已安装更新验收流程](../tests/installed-update/README.zh.md)记录安装后启动及升级证据。[Desktop 测试记录](../tests/README.zh.md)维护验证限制;工作流成功本身不代表 production preset、新工作站或已安装升级验收通过。
## 开发备注
@@ -21,6 +21,7 @@ function render(view) {
'stop-failed': messages.updateStopFailed,
'tasks-changed': messages.updateTasksChanged,
'tasks-unavailable': messages.updateTasksUnavailable,
'fullscreen-timeout': messages.updateFullscreenTimeout,
}
const fallback = failed || update.phase === 'idle' || view.error !== undefined
let title = policy.title ?? messages.mandatoryTitle
@@ -114,6 +114,8 @@ export function createElectronBuilderConfig(
...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty },
},
productName: 'DeepSeek Harness',
// The default copyright uses author.name; this line names the product instead.
copyright: `Copyright © ${new Date().getFullYear()} \${productName}`,
// Unsigned builds carry their own suffix so a shared file can never pass for a release artifact.
artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${output.suffix}.\${ext}`,
directories: { output: output.directory },
@@ -250,6 +252,7 @@ export function createElectronBuilderConfig(
target: ['AppImage'],
},
nsis: {
uninstallDisplayName: '${productName}',
installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'),
include: fileURLToPath(new URL('./installer.nsh', import.meta.url)),
@@ -14,7 +14,7 @@ $visualStudio = & $vswhere -latest -products '*' -requires Microsoft.VisualStudi
if (-not $visualStudio) { throw 'Visual Studio C++ Build Tools are missing.' }
$vcvars = Join-Path $visualStudio 'VC/Auxiliary/Build/vcvars32.bat'
$compileScript = Join-Path $output 'compile-frame.cmd'
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib gdi32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
[IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default)
& $env:ComSpec /d /c $compileScript
if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' }
@@ -50,6 +50,12 @@ if ($TestProgress -or $CompileProgressOnly) {
& $presentationExecutable
if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' }
}
$renderSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-render.cpp'))
$renderExecutable = Join-Path $output 'render-test.exe'
$renderScript = Join-Path $output 'compile-render-test.cmd'
[IO.File]::WriteAllLines($renderScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link "{3}" user32.lib gdi32.lib' -f $renderSource, (Join-Path $output 'render-test.obj'), $renderExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default)
& $env:ComSpec /d /c $renderScript
if ($LASTEXITCODE -ne 0) { throw 'Installer render test compilation failed.' }
$reportSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-extract-report.cpp'))
$reportExecutable = Join-Path $output 'extract-report-test.exe'
$reportScript = Join-Path $output 'compile-extract-report-test.cmd'
@@ -58,10 +58,12 @@ try {
join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions)
const progressTest = join(output, 'ui', 'progress-test.exe')
const presentationTest = join(output, 'ui', 'presentation-test.exe')
const renderTest = join(output, 'ui', 'render-test.exe')
const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe')
if (sign) {
await sign({ path: progressTest, hash: 'sha256', isNest: false })
await sign({ path: presentationTest, hash: 'sha256', isNest: false })
await sign({ path: renderTest, hash: 'sha256', isNest: false })
await sign({ path: cleanupTest, hash: 'sha256', isNest: false })
await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
installWindowsNsisBootstrapSigner({ sign })
@@ -69,6 +71,23 @@ try {
if (!uninstallOnly) {
await execute(progressTest, [], childOptions)
await execute(presentationTest, [], childOptions)
const renderResults = []
const themeSource = await readFile(join(appRoot, 'installer', 'theme.nsh'), 'utf8')
const captionColor = (name) => {
const match = themeSource.match(new RegExp(`^!define ${name} (0x[0-9A-Fa-f]{8})\\r?$`, 'm'))
if (!match) throw new Error(`Missing installer caption color: ${name}`)
return match[1]
}
for (const dpi of [96, 192]) {
for (const theme of ['light', 'dark']) {
const brand = theme === 'dark' ? 'brand-dark-2x.bmp' : 'brand-2x.bmp'
const prefix = theme === 'dark' ? 'INSTALLER_DARK_CONTROL' : 'INSTALLER_CONTROL'
const result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme,
captionColor(`${prefix}_HOVER`), captionColor(`${prefix}_PRESSED`)], childOptions)
renderResults.push(result.stdout)
}
}
await writeFile(join(output, 'render-results.log'), renderResults.join(''))
}
await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions)
const payloadSource = join(output, 'payload.nsi')
+2 -1
View File
@@ -34,7 +34,7 @@ export const DESKTOP_IPC = {
} as const
/** Desktop release update state rendered by desktop-owned UI. */
export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable'
export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' | 'fullscreen-timeout'
export interface DesktopUpdateState {
readonly phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' | 'ready' | 'error'
@@ -59,6 +59,7 @@ export type DesktopUpdateFailureKind =
| 'stop-failed'
| 'tasks-changed'
| 'tasks-unavailable'
| 'fullscreen-timeout'
/** Semantic status content; actions open main-process confirmation dialogs only. */
export interface DesktopUpdatePresentation {
+2
View File
@@ -128,6 +128,7 @@ export const en = {
updateStopTasks: 'Stop tasks and update',
updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.',
updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.',
updateFullscreenTimeout: 'Could not exit fullscreen. Exit fullscreen, then retry the update.',
updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.',
updateTechnicalDetails: 'View technical details',
updateTitle: 'DeepSeek Harness Update',
@@ -299,6 +300,7 @@ export const zh = {
updateStopTasks: '停止任务并更新',
updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。',
updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。',
updateFullscreenTimeout: '无法退出全屏,请退出全屏后重试更新',
updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。',
updateTechnicalDetails: '查看技术详情',
updateTitle: 'DeepSeek Harness 更新',
+3 -2
View File
@@ -35,6 +35,7 @@ import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLoc
import { claimDesktopSingleInstance } from './single-instance.ts'
import { desktopUpdateChannel } from './update-channel.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { prepareDesktopUpdateWindow } from './update-window.ts'
import { DesktopCommandManager } from './command-management.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
import { DesktopFatalRecovery } from './fatal-recovery.ts'
@@ -595,6 +596,8 @@ async function main(): Promise<void> {
const host = backend.host
if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
const active = await host.updateTasks('inspect')
const parent = currentDialogWindow()
if (parent === undefined || !await prepareDesktopUpdateWindow(parent, locale.messages.updateFullscreenTimeout)) return false
const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform)
const confirmation: Electron.MessageBoxOptions = {
type: active ? 'warning' : 'info', title: locale.messages.updateTitle,
@@ -606,8 +609,6 @@ async function main(): Promise<void> {
if (isMandatory()) {
if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false
} else {
const parent = currentDialogWindow()
if (parent === undefined) return false
const result = await updateDialog.show(parent, confirmation)
if (result.response !== 0 || isMandatory()) return false
}
+1
View File
@@ -33,6 +33,7 @@ export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: D
'stop-failed': messages.updateStopFailed,
'tasks-changed': messages.updateTasksChanged,
'tasks-unavailable': messages.updateTasksUnavailable,
'fullscreen-timeout': messages.updateFullscreenTimeout,
}
return summaries[kind]
}
+34
View File
@@ -0,0 +1,34 @@
/** Native window preparation before an update confirmation can stop the Host. */
import type { BrowserWindow } from 'electron'
import { DesktopUpdatePreparationError } from './update-error.ts'
type UpdateWindow = Pick<BrowserWindow, 'isDestroyed' | 'isFullScreen' | 'setFullScreen'> & Pick<NodeJS.EventEmitter, 'once' | 'off'>
const FULLSCREEN_EXIT_TIMEOUT_MS = 5000
/**
* Leave macOS fullscreen before displaying installation confirmation.
* @param window - Product window that owns the update confirmation.
* @param timeoutMessage - Locale-owned guidance to exit fullscreen and retry.
* @returns Whether the window survived the completed native transition; destruction cancels installation.
* @throws {DesktopUpdatePreparationError} If macOS does not report completion within five seconds.
*/
export async function prepareDesktopUpdateWindow(window: UpdateWindow, timeoutMessage: string): Promise<boolean> {
if (window.isDestroyed()) return false
if (process.platform !== 'darwin' || !window.isFullScreen()) return true
return new Promise<boolean>((resolve, reject) => {
const cleanup = (): void => {
clearTimeout(deadline)
window.off('leave-full-screen', left)
window.off('closed', closed)
}
const left = (): void => { cleanup(); resolve(true) }
const closed = (): void => { cleanup(); resolve(false) }
// Bound a missing native completion event; elapsed time never authorizes installation.
const deadline = setTimeout(() => { cleanup(); reject(new DesktopUpdatePreparationError('fullscreen-timeout', timeoutMessage)) }, FULLSCREEN_EXIT_TIMEOUT_MS)
window.once('leave-full-screen', left)
window.once('closed', closed)
try { window.setFullScreen(false) }
catch (error) { cleanup(); throw error }
})
}
+15 -2
View File
@@ -1,7 +1,8 @@
import { tmpdir } from 'node:os'
import { readFileSync } from 'node:fs'
import { Arch, Platform } from 'electron-builder'
import { Packager } from 'app-builder-lib'
import { AppInfo, Packager } from 'app-builder-lib'
import { expandMacro } from 'app-builder-lib/out/util/macroExpander.js'
import { describe, expect, it, vi } from 'vitest'
const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => undefined) }))
@@ -19,7 +20,7 @@ describe('installer preparation preserves application dependencies', () => {
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.com',
}, platform, 'x64')).toThrow('DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN')
})
it.each(['win32', 'darwin'] as const)('keeps electron-builder responsible for node_modules on %s', async (platform) => {
it.each(['win32', 'darwin'] as const)('preserves installer metadata and dependency ownership on %s', async (platform) => {
execute.mockClear()
const env = {
DSH_DESKTOP_APP_ID: 'com.example.installer',
@@ -37,6 +38,18 @@ describe('installer preparation preserves application dependencies', () => {
try {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(env, platform, 'x64')
const metadataPackager = new Packager({ projectDir: tmpdir() })
const metadata: unknown = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
Object.defineProperties(metadataPackager, {
config: { value: config },
metadata: { value: metadata },
})
const appInfo = new AppInfo(metadataPackager, undefined)
expect(appInfo.companyName).toBe('Hangzhou DeepSeek Artificial Intelligence Co., Ltd.')
expect(appInfo.copyright).toBe(`Copyright © ${new Date().getFullYear()} DeepSeek Harness`)
if (platform === 'win32') {
expect(expandMacro(config.nsis.uninstallDisplayName, null, appInfo, {}, false)).toBe('DeepSeek Harness')
}
const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png')
expect(aboutIcon).toBeDefined()
expect(readFileSync(aboutIcon!.from)).toEqual(readFileSync(new URL('../resources/icon-windows.png', import.meta.url)))
+79 -2
View File
@@ -1786,6 +1786,84 @@ describe('desktop main startup', () => {
expect(host.stop).not.toHaveBeenCalled()
})
it.each([false, true])('waits for macOS to leave fullscreen before installation confirmation, mandatory: %s', async (mandatory) => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
if (mandatory) {
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
} })))
}
const host = await readyForUpdate()
if (mandatory) await harness.policyBlocked.promise
const window = harness.windows[0]!
const dialogOwner = mandatory ? harness.windows.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window
const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') }
window.fullscreen = true
window.setFullScreen.mockImplementation(() => {})
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
const preparing = harness.prepareUpdate()
await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) })
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
if (mandatory) expect(harness.handlers.get(MANDATORY_IPC.status)!({ sender: dialogOwner.webContents, senderFrame: dialogOwner.webContents.mainFrame })).not.toHaveProperty('confirmation')
expect(host.stop).not.toHaveBeenCalled()
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
window.fullscreen = false
window.emit('leave-full-screen')
if (mandatory) await answerMandatory('install', dialogOwner)
await host.stopping.promise
host.exited.resolve()
await expect(preparing).resolves.toBe(true)
expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave)
expect(window.listenerCount('closed')).toBe(baseline.closed)
})
it('keeps the Host running after a fullscreen timeout and accepts a later retry', async () => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
const host = await readyForUpdate()
const window = harness.windows[0]!
window.fullscreen = true
window.setFullScreen.mockImplementation(() => {})
const preparing = harness.prepareUpdate()
const rejected = expect(preparing).rejects.toMatchObject({ kind: 'fullscreen-timeout',
message: 'Could not exit fullscreen. Exit fullscreen, then retry the update.' })
await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) })
await vi.advanceTimersByTimeAsync(5000)
await rejected
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
expect(host.updateTasks.mock.calls).toEqual([['inspect']])
expect(host.stop).not.toHaveBeenCalled()
window.setFullScreen.mockImplementation((flag) => { window.fullscreen = flag; window.emit('leave-full-screen') })
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
const retrying = harness.prepareUpdate()
await host.stopping.promise
host.exited.resolve()
await expect(retrying).resolves.toBe(true)
})
it.each([false, true])('cancels installation when the fullscreen product window closes, mandatory: %s', async (mandatory) => {
vi.stubGlobal('process', { ...process, platform: 'darwin' })
if (mandatory) {
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: {
show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/',
} })))
}
const host = await readyForUpdate()
if (mandatory) await harness.policyBlocked.promise
const window = harness.windows[0]!
const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') }
window.fullscreen = true
window.setFullScreen.mockImplementation(() => {})
const preparing = harness.prepareUpdate()
await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) })
window.destroy()
await expect(preparing).resolves.toBe(false)
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
expect(host.stop).not.toHaveBeenCalled()
expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave)
})
it('waits for Platform storage cleanup before the installer takes over', async () => {
const host = await readyForUpdate()
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
@@ -1830,8 +1908,7 @@ describe('desktop main startup', () => {
expect(host.stop).not.toHaveBeenCalled()
})
async function answerMandatory(action: 'install' | 'later') {
const modal = harness.windows[0]!
async function answerMandatory(action: 'install' | 'later', modal = harness.windows[0]!) {
const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
await vi.waitFor(() => {
expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation')
@@ -73,13 +73,13 @@ function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alp
}
describe('desktop update coordinator', () => {
it('keeps safe preparation diagnostics separate and clears them on an explicit retry', async () => {
it.each(['stop-failed', 'fullscreen-timeout'] as const)('keeps safe %s diagnostics separate and clears them on an explicit retry', async (kind) => {
const f = fixture()
await f.coordinator.check()
await f.coordinator.download('1.1.0-rc.2')
f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError('stop-failed', zh.updateStopFailed, 'exit 0; shutdown acknowledged false'))
f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError(kind, zh.updateStopFailed, 'exit 0; shutdown acknowledged false'))
expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'error', version: '1.1.0-rc.2',
failedOperation: 'install', preparationFailure: 'stop-failed', message: zh.updateStopFailed,
failedOperation: 'install', preparationFailure: kind, message: zh.updateStopFailed,
technicalDetails: 'exit 0; shutdown acknowledged false' })
expect(f.quitAndInstall).not.toHaveBeenCalled()
f.beforeRestart.mockResolvedValueOnce(false)
@@ -243,3 +243,19 @@ it.each(['en', 'zh-CN'])('records mandatory update guidance and actions across i
})
await expect(JSON.stringify(snapshots, null, 2) + '\n').toMatchFileSnapshot(`./expected/update-guidance-${language}.json`)
})
it.each(['en', 'zh-CN'])('shows fullscreen recovery guidance directly in the mandatory update page: %s', async (language) => {
const p = page('mandatory-update')
const locale = resolveDesktopLocale(language)
const state: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false },
update: { phase: 'error', failedOperation: 'install', preparationFailure: 'fullscreen-timeout',
version: '0.1.6-nightly.1', message: 'different shell locale' } }
const action = vi.fn(async () => {})
Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: {
status: async () => state, action, subscribe: () => () => {},
} satisfies MandatoryUpdateApi })
p.run()
await expect.poll(() => p.element('error').textContent).toBe(locale.messages.updateFullscreenTimeout)
expect((p.element('technical-details') as HTMLDetailsElement).open).toBe(false)
expect(action).not.toHaveBeenCalled()
})
@@ -32,6 +32,7 @@ it('keeps raw diagnostics out of error tooltips in both locales', () => {
expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure: 'stop-failed',
message: 'translated or changed copy', technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateStopFailed)
for (const [preparationFailure, expected] of [
['fullscreen-timeout', messages.updateFullscreenTimeout],
['tasks-changed', messages.updateTasksChanged], ['tasks-unavailable', messages.updateTasksUnavailable],
] as const) {
expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure,
+69
View File
@@ -0,0 +1,69 @@
import { EventEmitter } from 'node:events'
import { afterEach, expect, it, vi } from 'vitest'
import { prepareDesktopUpdateWindow } from '../src/update-window.ts'
import { en } from '../src/locale.ts'
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() })
function setup(platform = 'darwin', fullscreen = true, destroyed = false) {
vi.stubGlobal('process', { ...process, platform })
const window = Object.assign(new EventEmitter(), {
isDestroyed: () => destroyed,
isFullScreen: () => fullscreen,
setFullScreen: vi.fn(),
})
return { window, prepare: () => prepareDesktopUpdateWindow(window, en.updateFullscreenTimeout) }
}
it.each(['darwin', 'win32', 'linux'])('does not transition an ordinary %s window', async (platform) => {
const f = setup(platform, false)
await expect(f.prepare()).resolves.toBe(true)
expect(f.window.setFullScreen).not.toHaveBeenCalled()
})
it('preserves Windows fullscreen', async () => {
const windows = setup('win32')
await expect(windows.prepare()).resolves.toBe(true)
expect(windows.window.setFullScreen).not.toHaveBeenCalled()
})
it('refuses a destroyed product window', async () => {
const destroyed = setup('darwin', true, true)
await expect(destroyed.prepare()).resolves.toBe(false)
expect(destroyed.window.setFullScreen).not.toHaveBeenCalled()
})
it.each(['leave-full-screen', 'closed'])('settles on %s and releases both listeners and the deadline', async (event) => {
vi.useFakeTimers()
const f = setup()
const preparing = f.prepare()
expect(f.window.setFullScreen).toHaveBeenCalledWith(false)
f.window.emit(event)
await expect(preparing).resolves.toBe(event === 'leave-full-screen')
expect(f.window.eventNames()).toEqual([])
expect(vi.getTimerCount()).toBe(0)
})
it('rejects a stalled native transition and allows a later attempt to complete', async () => {
vi.useFakeTimers()
const f = setup()
const failure = expect(f.prepare()).rejects.toMatchObject({ kind: 'fullscreen-timeout', message: en.updateFullscreenTimeout })
await vi.advanceTimersByTimeAsync(5000)
await failure
expect(f.window.eventNames()).toEqual([])
expect(vi.getTimerCount()).toBe(0)
const retry = f.prepare()
f.window.emit('leave-full-screen')
await expect(retry).resolves.toBe(true)
expect(vi.getTimerCount()).toBe(0)
})
it('releases native listeners when requesting the transition throws', async () => {
vi.useFakeTimers()
const f = setup()
const nativeError = new Error('native window unavailable')
f.window.setFullScreen.mockImplementation(() => { throw nativeError })
await expect(f.prepare()).rejects.toBe(nativeError)
expect(f.window.eventNames()).toEqual([])
expect(vi.getTimerCount()).toBe(0)
})
+5
View File
@@ -184,6 +184,11 @@ describe.skipIf(!existsSync(builtHost))('built Desktop welcome flow', () => {
await backend!.account.cancel(waiting.attempt!.id)
expect((await fetch(late, { redirect: 'manual' })).status).not.toBe(302)
expect(await status()).toMatchObject({ loggedIn: false })
// A handle left open after acknowledged teardown must not fail the update handoff.
vi.stubEnv('NODE_OPTIONS', `--import=data:text/javascript,${encodeURIComponent(
"process.on('message', message => { if (message?.type === 'shutdown') setInterval(() => {}, 1000) })")}`)
await restart()
await host!.stop(true)
} finally {
await host?.stop()
await platform.close()
@@ -0,0 +1,210 @@
// Exercises the shipped progress window without extracting or installing a payload.
#define WIN32_LEAN_AND_MEAN
#define UNICODE
#define NOMINMAX
#include <windows.h>
#include <algorithm>
#include <cstdio>
#include <vector>
extern "C" __declspec(dllimport) HRESULT __cdecl InstallerApplyFrame(HWND window);
extern "C" __declspec(dllimport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source,
BOOL dark, UINT dpi, DWORD controlHover, DWORD controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting,
const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning);
extern "C" __declspec(dllimport) BOOL __cdecl InstallerFinishProgress(HWND window);
static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) {
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
return DefWindowProcW(window, message, wparam, lparam);
}
int wmain(int argc, wchar_t** argv) {
if (argc < 6) {
std::fprintf(stderr, "Usage: render-test.exe <brand.bmp> <dpi> <light|dark> <hover-argb> <pressed-argb> [interactive-title]\n");
return 1;
}
const int dpi = _wtoi(argv[2]);
if (dpi < 96 || dpi > 384) return 2;
const bool dark = wcscmp(argv[3], L"dark") == 0;
if (!dark && wcscmp(argv[3], L"light") != 0) return 2;
wchar_t* end = nullptr;
const DWORD hoverArgb = wcstoul(argv[4], &end, 0);
if (!*argv[4] || *end || (hoverArgb >> 24) != 255) return 2;
const DWORD pressedArgb = wcstoul(argv[5], &end, 0);
if (!*argv[5] || *end || (pressedArgb >> 24) != 255) return 2;
const bool interactive = argc > 6;
SetProcessDPIAware();
WNDCLASSW type = {};
type.lpfnWndProc = WindowProc;
type.hInstance = GetModuleHandleW(nullptr);
type.lpszClassName = L"HarnessInstallerRenderTest";
type.hCursor = LoadCursorW(nullptr, IDC_ARROW);
if (!RegisterClassW(&type)) return 3;
HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[6] : L"Installer rendering test",
WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX, 100, 100, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96),
nullptr, nullptr, type.hInstance, nullptr);
if (!window || FAILED(InstallerApplyFrame(window))) return 4;
HWND stock = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr);
HWND source = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, stock, nullptr, type.hInstance, nullptr);
SetPropW(window, L"HarnessInstaller.Stage", reinterpret_cast<HANDLE>(1));
SetPropW(window, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(50));
const auto createProgress = [&]() {
return InstallerShowProgress(window, source, dark, static_cast<UINT>(dpi), hoverArgb, pressedArgb, argv[1],
L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%");
};
ShowWindow(window, SW_SHOWNOACTIVATE);
bool clientFrame = true;
for (int x : {8, 300, 580}) {
POINT point{MulDiv(x, dpi, 96), MulDiv(24, dpi, 96)};
ClientToScreen(window, &point);
clientFrame = SendMessageW(window, WM_NCHITTEST, 0, MAKELPARAM(point.x, point.y)) == HTCLIENT && clientFrame;
}
HWND failed = InstallerShowProgress(window, source, dark, static_cast<UINT>(dpi), hoverArgb, pressedArgb, L"",
L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%");
ShowWindow(stock, SW_SHOW);
const bool failurePreservesStock = !failed && IsWindowVisible(stock);
if (failed) DestroyWindow(failed);
// Warm retained font and window-animation resources before checking page-owned GDI objects.
HWND warmup = createProgress();
if (!warmup) { DestroyWindow(window); return 5; }
UpdateWindow(warmup);
ShowWindow(window, SW_MINIMIZE);
ShowWindow(window, SW_RESTORE);
UpdateWindow(warmup);
DestroyWindow(warmup);
const DWORD initialGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);
HWND progress = createProgress();
if (!progress) { DestroyWindow(window); return 5; }
UpdateWindow(progress);
if (interactive) {
MSG message;
BOOL result;
while ((result = GetMessageW(&message, nullptr, 0, 0)) > 0) {
TranslateMessage(&message);
DispatchMessageW(&message);
}
return result == 0 ? 0 : 7;
}
RECT originalBounds, restoredBounds;
GetWindowRect(window, &originalBounds);
const auto captionPixel = [&](int x) -> COLORREF {
// Print into a private bitmap: visible-window clipping must not affect pixel assertions.
HDC dc = CreateCompatibleDC(nullptr);
BITMAPINFO info = {};
info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
info.bmiHeader.biWidth = MulDiv(600, dpi, 96);
info.bmiHeader.biHeight = -MulDiv(600, dpi, 96);
info.bmiHeader.biPlanes = 1;
info.bmiHeader.biBitCount = 32;
void* pixels = nullptr;
HBITMAP bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0);
if (!dc || !bitmap) {
if (dc) DeleteDC(dc);
if (bitmap) DeleteObject(bitmap);
return CLR_INVALID;
}
HGDIOBJ previous = SelectObject(dc, bitmap);
if (!previous) { DeleteObject(bitmap); DeleteDC(dc); return CLR_INVALID; }
SendMessageW(progress, WM_PRINTCLIENT, reinterpret_cast<WPARAM>(dc), PRF_CLIENT);
const COLORREF pixel = GetPixel(dc, MulDiv(x + 8, dpi, 96), MulDiv(24, dpi, 96));
SelectObject(dc, previous);
DeleteObject(bitmap);
DeleteDC(dc);
return pixel;
};
const COLORREF normalColor = dark ? RGB(21, 21, 23) : RGB(255, 255, 255);
const auto colorref = [](DWORD argb) { return RGB((argb >> 16) & 255, (argb >> 8) & 255, argb & 255); };
const COLORREF hoverColor = colorref(hoverArgb);
const COLORREF pressedColor = colorref(pressedArgb);
const auto expectPixel = [&](int x, COLORREF expected) {
const COLORREF actual = captionPixel(x);
if (actual != CLR_INVALID && actual == expected) return true;
std::fprintf(stderr, "caption pixel x=%d dpi=%d expected=0x%08lx actual=0x%08lx\n", x, dpi, expected, actual);
return false;
};
bool captionStates = true;
ShowWindow(progress, SW_HIDE);
for (int x : {504, 548}) {
const LPARAM point = MAKELPARAM(MulDiv(x + 20, dpi, 96), MulDiv(24, dpi, 96));
captionStates = expectPixel(x, normalColor) && captionStates;
SendMessageW(progress, WM_MOUSEMOVE, 0, point);
captionStates = expectPixel(x, hoverColor) && captionStates;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, point);
captionStates = expectPixel(x, pressedColor) && captionStates;
const int other = x == 504 ? 548 : 504;
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(MulDiv(other + 20, dpi, 96), MulDiv(24, dpi, 96)));
captionStates = expectPixel(other, normalColor) && captionStates;
captionStates = expectPixel(x, normalColor) && captionStates;
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(-1, -1));
captionStates = expectPixel(x, normalColor) && captionStates;
SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, point);
captionStates = expectPixel(x, pressedColor) && captionStates;
SendMessageW(progress, WM_CANCELMODE, 0, 0);
captionStates = expectPixel(x, hoverColor) && captionStates;
SendMessageW(progress, WM_MOUSELEAVE, 0, 0);
captionStates = expectPixel(x, normalColor) && captionStates;
}
ShowWindow(progress, SW_SHOW);
const LPARAM minimizePoint = MAKELPARAM(MulDiv(524, dpi, 96), MulDiv(24, dpi, 96));
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
bool buttonRelease = !IsIconic(window) && GetCapture() == progress;
SendMessageW(progress, WM_LBUTTONUP, 0, MAKELPARAM(-1, -1));
buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
ReleaseCapture();
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
buttonRelease = buttonRelease && !IsIconic(window);
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
SendMessageW(progress, WM_CANCELMODE, 0, 0);
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint);
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
const bool minimized = IsIconic(window) != FALSE;
ShowWindow(window, SW_RESTORE);
GetWindowRect(window, &restoredBounds);
const bool restored = !IsIconic(window) && EqualRect(&originalBounds, &restoredBounds);
const LPARAM closePoint = MAKELPARAM(MulDiv(568, dpi, 96), MulDiv(24, dpi, 96));
MSG closeMessage;
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint);
bool closeRelease = !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint);
closeRelease = closeRelease && !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint);
SendMessageW(progress, WM_LBUTTONUP, 0, closePoint);
closeRelease = closeRelease && PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE);
LARGE_INTEGER frequency, started, finished;
QueryPerformanceFrequency(&frequency);
std::vector<double> samples;
// Full repaint is the exposure/restore path; fixture startup is excluded.
constexpr int frameCount = 300;
for (int frame = 0; frame < frameCount; ++frame) {
InvalidateRect(progress, nullptr, FALSE);
QueryPerformanceCounter(&started);
UpdateWindow(progress);
QueryPerformanceCounter(&finished);
samples.push_back(1000.0 * (finished.QuadPart - started.QuadPart) / frequency.QuadPart);
}
double total = 0;
for (double sample : samples) total += sample;
std::printf("samples_ms=[");
for (size_t index = 0; index < samples.size(); ++index) {
std::printf("%s%.3f", index ? "," : "", samples[index]);
}
std::printf("]\n");
std::sort(samples.begin(), samples.end());
std::printf("fixture=standalone-progress dpi=%d dark=%d frames=%d screen=%dx%d foreground=%d mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n",
dpi, dark, frameCount, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), GetForegroundWindow() == window,
total / samples.size(), samples[samples.size() / 2], samples[samples.size() * 95 / 100], samples.back());
const bool completed = InstallerFinishProgress(progress) &&
GetPropW(window, L"HarnessInstaller.CompletedPercent") == reinterpret_cast<HANDLE>(100);
WCHAR caption[160] = {};
GetWindowTextW(progress, caption, ARRAYSIZE(caption));
const bool finalCaption = wcscmp(caption, L"Extracting 100%") == 0;
DestroyWindow(progress);
const DWORD finalGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS);
DestroyWindow(window);
std::printf("client_frame=%d stock_preserved=%d completed=%d final_caption=%d caption_states=%d button_release=%d close_release=%d minimized=%d restored=%d gdi_before=%lu gdi_after=%lu\n",
clientFrame, failurePreservesStock, completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi);
return clientFrame && failurePreservesStock && completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6;
}
+3 -3
View File
@@ -466,7 +466,7 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without
expect(await composer.innerText()).toBe(draft)
expect(await picker.getAttribute('aria-haspopup')).toBe('menu')
expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0)
expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis')
expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis')
expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis')
expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name))
.toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager']))
@@ -492,9 +492,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without
const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)!
expect(next.id).not.toBe(creator.id)
expect(next.session.header.agentPreset).toBe('standard')
expect(scaffold.ctx.sessionProjections.stateOf(next.session, 'agentPreset')).toBe('standard')
expect(scaffold.ctx.sessionProjections.checkpoint(next.session).agentPreset?.val).toBe('standard')
expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard')
expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis')
expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis')
await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor()
expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools })
expect(scaffold.ctx.agentPresets.defaultId).toBe('standard')
+3 -2
View File
@@ -69,10 +69,11 @@ describe('web e2e: blank Session reuse under the requested preset', () => {
function liveComposition(sessionId: SessionId) {
const agent = scaffold.ctx.agents.get(sessionId)
if (agent === undefined) return undefined
const projections = scaffold.ctx.sessionProjections.snapshot(agent.session).values
return {
mounted: scaffold.ctx.agentPresets.composedPreset(agent.ctx),
projected: scaffold.ctx.sessionProjections.stateOf(agent.session, 'agentPreset'),
plan: scaffold.ctx.sessionProjections.stateOf(agent.session, 'plan'),
projected: projections.agentPreset,
plan: projections.plan,
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ describe.each([false, true])('web e2e: selected blank writer held: %s', (held) =
if (!held) {
expect(selectedId).toBe(HELD)
const reclaimed = scaffold.ctx.agents.get(HELD)!
expect(scaffold.ctx.sessionProjections.stateOf(reclaimed.session, 'plan')).toMatchObject({ active: true })
expect(scaffold.ctx.sessionProjections.checkpoint(reclaimed.session).plan?.val).toMatchObject({ active: true })
await expect(scaffold.ctx.sessionPersistence.open(HELD, 'write'))
.rejects.toMatchObject({ name: 'SessionAlreadyOwnedError' })
}
+8 -6
View File
@@ -593,7 +593,7 @@ describe('web e2e: long Chat scroll contract', () => {
const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url))
await mkdir(artifactRoot, { recursive: true })
const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`))
const controlledClock = scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold'
const controlledClock = !scenario.reduced
await withScrollWorld({
failureShot: `visible-fold-${scenario.name}`,
controlledClock,
@@ -760,17 +760,19 @@ describe('web e2e: long Chat scroll contract', () => {
try {
settled = world.scaffold.whenTurnSettled(30_000)
if (controlledClock) {
// Advance every animation frame so the race cannot miss the intermediate fold height.
// Sample each animation frame, including when the CI browser misses real-time frames.
await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000))
try {
await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => {
if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button')
button.click()
})
await expect.poll(async () => {
await page.clock.runFor(16)
return page.locator('[data-fold-race-applied]').count()
}, { timeout: 10_000 }).toBe(1)
if (scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold') {
await expect.poll(async () => {
await page.clock.runFor(16)
return page.locator('[data-fold-race-applied]').count()
}, { timeout: 10_000 }).toBe(1)
}
await expect.poll(async () => {
await page.clock.runFor(16)
return page.locator('[data-chat-motion="collapse"]').count()
+13 -4
View File
@@ -12,7 +12,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, parseSeedFixture, readPersistedEvents, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, openSettings, saveFailureShot, writeComposerDraft } from './support.ts'
import { newEnglishPage, openSettings, pinBrowserClock, pinHostClock, saveFailureShot, WEB_FIXTURE_TIME, writeComposerDraft } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/collapse-timing', import.meta.url))
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
@@ -38,8 +38,12 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => {
let fixture: string
let prompt: string
let replayDir: string
let hostClock: ReturnType<typeof pinHostClock> | undefined
beforeAll(async () => {
// Seeded and live prompt timestamps share the renderer's fixture day, so a run
// that spans Asia/Shanghai midnight renders no date prefix in the aria.
if (MODE !== 'record') hostClock = pinHostClock()
fixture = await readFile(SEED, 'utf8')
const prompts = fixtureUserPrompts(fixture)
expect(prompts).toHaveLength(1)
@@ -56,9 +60,10 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => {
// This rendering borrows a completed turn and replays only its final response as a continuation.
compareReplaySession: false,
})
await seedSession(scaffold, fixture, SEED_ID)
await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME })
browser = await chromium.launch({ headless: true })
page = await newEnglishPage(browser)
if (MODE !== 'record') await pinBrowserClock(page)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor()
@@ -66,8 +71,12 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => {
afterAll(async () => {
const failures: unknown[] = []
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
try {
await browser?.close().catch((error: unknown) => failures.push(error))
await scaffold?.close().catch((error: unknown) => failures.push(error))
} finally {
hostClock?.mockRestore()
}
if (replayDir !== undefined) {
await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
}
@@ -195,6 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr
expect(calls).toEqual([])
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
+2
View File
@@ -212,6 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen
await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor()
await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor()
await panel.getByText(bundleVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await panel.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'),
await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']],
@@ -6,6 +6,65 @@
- paragraph:
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求
- text: 翻译服务 已覆盖
@@ -6,6 +6,65 @@
- paragraph:
- code: "@deepseek-ai/dsh-experimental-cot-translation-bundle"
- paragraph: 机器翻译展开的思考内容;原文会发送给所选服务
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input.
- heading "Table of Contents" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language.
- code: auto
- text: follows the interface language; an explicit language code such as
- code: zh
- text: ","
- code: en
- text: ", or"
- code: ja
- text: overrides it. Save applies the preferences.
- paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts.
- paragraph:
- text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native
- code: deepseek-flash
- text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- text: The bundle patch inserts
- code: translator
- text: and
- code: cot-translation
- text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory."
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Text translation subsystem · Translator service · GUI consumer
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context.
- heading "KV Cache effect" [level=4]
- paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- list:
- listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible.
- listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。
- paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求
- text: 翻译服务
@@ -42,11 +42,11 @@
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 256K
- /placeholder: 1M
- text: 272K
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 32K
- /placeholder: 128K
- text: 128K
- group "输入类型 1":
- text: 输入类型
@@ -49,10 +49,10 @@
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 256K
- /placeholder: 1M
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 32K
- /placeholder: 128K
- group "输入类型 1":
- text: 输入类型
- checkbox "文本" [checked]
@@ -49,10 +49,10 @@
- button "删除模型 1"
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 256K
- /placeholder: 1M
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 32K
- /placeholder: 128K
- group "输入类型 1":
- text: 输入类型
- checkbox "文本"
@@ -6,6 +6,53 @@
- code: "@deepseek-ai/dsh-experimental-badge-skill-bundle"
- paragraph: 让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。
- img "DSH 徽章技能功能预览"
- region "详细介绍":
- heading "@deepseek-ai/dsh-experimental-badge-skill-bundle" [level=1]
- paragraph: English | 中文
- heading "Summary" [level=2]
- paragraph: Let Agents whose presets load skills add a “powered by dsh” badge to documents, pull requests, and other outputs. The shared provider adds no model tool. It ships switched off in Web and Desktop. Select it in Plugins to enable it.
- heading "Table of Contents" [level=2]
- list:
- listitem: Use this package
- listitem: Understand the implementation
- listitem: Further Exploration
- listitem: Model Experience
- listitem: Known Limitations and Deferred Work
- listitem: Dev Note
- separator
- paragraph
- heading "Use this package" [level=2]
- paragraph:
- text: In Web or Desktop, open Plugins and enable
- strong: DSH badge skill
- text: in Official. Switch it off to remove its profile layer.
- paragraph: The switch adds or removes a Host-wide skill provider for existing and new Agents. Presets that expose skill loading can use the badge; minimal keeps its tool catalog unchanged.
- separator
- paragraph
- heading "Understand the implementation" [level=2]
- paragraph:
- code: cordis.patch.yml
- text: contributes the
- code: skill-badge
- text: provider row on the Host. Later root patches can disable that row; an override alone does not select the bundle. The profile composer owns patch ordering and errors.
- separator
- paragraph
- heading "Further Exploration" [level=2]
- paragraph: Capability implementation
- separator
- paragraph
- heading "Model Experience" [level=2]
- paragraph: Indirectly, through the capability implementation, which owns model context and results.
- heading "KV Cache effect" [level=4]
- paragraph: The layer adds no request content directly; the capability implementation owns cache effects from tools, prompts, and results.
- heading "Known Limitations and Deferred Work" [level=2]
- paragraph
- list:
- listitem: A preset must include skill discovery and loading to use the badge. The minimal preset does not gain skill tools from this bundle.
- separator
- paragraph
- heading "Dev Note" [level=3]
- paragraph: None.
- heading "包含的组件" [level=4]
- text: 共 1 个 · 1 已停用
- list:
@@ -3,7 +3,7 @@
- button "Close"
- paragraph: “ARCHIVE_ACTIVE run the long command” still has work in progress. Archiving stops it first; you can restore the session later from the “All conversations (show archived)” filter in the sidebar, and the stopped work will not resume on its own.
- list "Work that will be stopped":
- listitem: The turn in progress
- listitem: "1 background job: node -e \"const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, 200); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, 60000)\""
- listitem: The turn in progress
- button "Cancel"
- button "Stop and archive"
+3
View File
@@ -308,6 +308,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
await custom.getByRole('button', { name: '添加模型', exact: true }).click()
await custom.getByLabel('模型 ID 1').fill('acme-large')
await custom.getByRole('button', { name: '模型选项 1' }).click()
expect(await custom.getByLabel('上下文窗口 1').getAttribute('placeholder')).toBe('1M')
expect(await custom.getByLabel('最大输出 token 数 1').getAttribute('placeholder')).toBe('128K')
expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false)
await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check()
// The switch seeds off, low, medium, and high rows; this route serves no medium.
@@ -322,6 +324,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('acme-gateway:')
expect(document).toContain('reasoningEfforts:')
await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({
context: { contextWindow: 1_000_000 },
inputModalities: ['text', 'image'],
reasoning: { efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] },
})
+7
View File
@@ -87,6 +87,13 @@ describe('web e2e: plan review takeover round trip', () => {
// waitFor is race-free.
const card = page.locator('[data-plan-review-key]')
await card.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
const firstHeader = sessionEvents.find(event => event.type === 'request/header')
const firstPrompt = sessionEvents.find(event => event.type === 'system/message')
expect(firstHeader?.data.header.tools?.map(tool => tool.name)).toContain('exit_plan_mode')
expect(firstPrompt?.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('\n'))
.toContain('You are in plan mode.')
const clock = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'time-context')
expect(clock?.seq).toBeLessThan(firstHeader!.seq)
// The plan-review request must NOT land on the generic question flow.
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
+2
View File
@@ -709,6 +709,8 @@ describe('web e2e: plugin manager', () => {
expect(fits).toBe(true)
await detail.getByText(`v${badgeVersion}`, { exact: true }).waitFor()
await detail.getByText(badgeVersion, { exact: true }).waitFor()
// The bundle's README is its detail document, shown between its components and its source.
await detail.locator('[data-detail-document] h1').waitFor()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'preview.expected.md'),
await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, {
replacements: [[badgeVersion, '{{version}}']],
+1 -1
View File
@@ -162,7 +162,7 @@ describe.skipIf(MODE === 'record')('web e2e: archiving a running Session stops i
// A foreground Bash command is a registered job from its start, so the
// Host reports it beside the turn; stopping the turn kills it.
await expect.poll(() => dialog.getByRole('list', { name: 'Work that will be stopped' }).locator('li').allTextContents())
.toEqual(['The turn in progress', `1 background job: ${WAIT_COMMAND}`])
.toEqual([`1 background job: ${WAIT_COMMAND}`, 'The turn in progress'])
// Asking is not archiving: the Host set is untouched, the turn and its
// Bash child keep running, and the row stays in the sidebar.
expect([...scaffold.ctx.workspaceRegistry.archivedSessionIds]).toEqual([])
+2 -2
View File
@@ -43,8 +43,8 @@ async function remote<T>(
}
/** POST one generated Session Remote unary through the API Gateway carrier. */
function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
return remote<T>(scaffold, `session/${method}`, { request })
async function sessionRemote<T>(scaffold: WebScaffold, method: string, request: unknown): Promise<RpcResult<T>> {
return await remote<T>(scaffold, `session/${method}`, { request })
}
/** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */
@@ -12,8 +12,8 @@
en: 66a769ce60d49ff3
zh: 7e0bfa7c598157fa
/backend-continuation-benchmarks/run:
en: 900cdb7cdb11cf21
zh: 0dafdd9a3be1b04e
en: a9a943044795029c
zh: 45c8257f51ec658a
/backend-continuation-benchmarks/measurements:
en: 84301730c56f0614
zh: 6e90a8d6574fa896
+1 -1
View File
@@ -18,7 +18,7 @@ Measure long-history request processing, cold tool-heavy continuation, and repea
From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks.
The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([archived evidence](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
The test reports all five fresh-process samples, CPU models, available parallelism, platform/architecture, and Node/V8 versions, and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a separately reviewed 297 ms hosted limit ([archived evidence](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)), and checkpoint-enabled SDK-minimal continuation uses a 5,500 ms hosted expectation with 1.25× headroom rounded up to a whole second (7,000 ms). This hosted expectation does not use the 2× reference-machine scale. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
<a id="measurements"></a>
+1 -1
View File
@@ -18,7 +18,7 @@
在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。
测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([已归档的证据](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
测试报告全部五个新进程样本、CPU 型号、可用并行度、平台/架构和 Node/V8 版本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用单独审查的 297 ms 托管上限([已归档的证据](../../.agents/notes/archived/simplification/2026-09-06-agent-request-freeze-evidence.md)),启用 checkpoint 的 SDK-minimal 续聊使用 5,500 ms 托管期望值,乘 1.25× 余量后向上取整到整秒(7,000 ms)。该托管期望值不使用参考机器的 2× 比例。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
<a id="measurements"></a>
@@ -14,8 +14,12 @@ import { WORKLOAD } from './workload.ts'
const ATTEMPTS = 5
const WORKER_TIMEOUT_MS = 60_000
/** M4 Pro / Node 24.19 baseline expectations, before shared CI scaling and variance headroom. */
const EXPECTED_MS = { 'profile-continuation': 1_700 } as const
/** Checkpoint-enabled two-CPU hosted runs have an upper observed median of 5,432.567 ms. */
const EXPECTED_PROFILE_CONTINUATION_CI_MS = 5_500
/** Apply variance headroom and round up to a whole second; the baseline is already a CI measurement. */
const PROFILE_CONTINUATION_BUDGET_MS = Math.ceil(
EXPECTED_PROFILE_CONTINUATION_CI_MS * PERFORMANCE_BUDGET_HEADROOM / 1_000,
) * 1_000
/** Standard two-CPU hosted CI baseline request-history median is 582.304 ms. */
const EXPECTED_BASELINE_REQUEST_CI_MS = 600
const BASELINE_REQUEST_BUDGET_MS = Math.ceil(EXPECTED_BASELINE_REQUEST_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
@@ -54,7 +58,7 @@ const CASES: Readonly<Record<Scenario, BenchmarkCase>> = {
},
}
type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | keyof typeof EXPECTED_MS
type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | 'profile-continuation'
type Report = ContinuationReport | CatalogReport | ProfileReport
function workerName(scenario: Scenario): string {
@@ -81,6 +85,24 @@ function expectTotalWithinBudget(value: number, budget: number): void {
expect(value).toBeLessThanOrEqual(budget)
}
describe('checkpoint-enabled SDK-minimal hosted calibration', () => {
it('accepts the recorded checkpoint-enabled workload above the buffered-write budget', () => {
const recordedMedian = median([4_182.691, 5_946.326875, 5_581.127338, 5_432.567004, 4_362.941759])
expect(recordedMedian).toBe(5_432.567004)
expect(() => expectTotalWithinBudget(recordedMedian, 4_250)).toThrow()
expectTotalWithinBudget(recordedMedian, PROFILE_CONTINUATION_BUDGET_MS)
expect(PROFILE_CONTINUATION_BUDGET_MS).toBe(7_000)
})
it('rejects a material SDK-minimal profile slowdown', () => {
const regressionMedian = median([7_600, 7_900, 8_000, 7_700, 7_800])
expect(regressionMedian).toBe(7_800)
expect(() => expectTotalWithinBudget(regressionMedian, PROFILE_CONTINUATION_BUDGET_MS)).toThrow()
})
})
describe('standard hosted catalog calibration', () => {
it('accepts the recorded two-CPU samples that exceed the historical budget', () => {
const recordedMedian = median([797.373945, 883.157358, 858.363927, 790.568538, 904.5785669999999])
@@ -187,7 +209,7 @@ describe('continuing tool-heavy Sessions with large histories', () => {
const totalMs = samples.map(sample => sample.totalMs)
const budgetMs = scenario === 'request-history' ? REQUEST_HISTORY_BUDGET_MS
: scenario === 'catalog' ? CATALOG_BUDGET_MS
: scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : ciTimeBudget(EXPECTED_MS[scenario])
: scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : PROFILE_CONTINUATION_BUDGET_MS
const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM
console.log(JSON.stringify({
benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD,
@@ -8,7 +8,7 @@ import { scheduler, setTimeout as delay } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
import SessionController from '@deepseek-ai/dsh-api-session-controller'
import type SessionController from '@deepseek-ai/dsh-api-session-controller'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -21,6 +21,7 @@ import { z } from 'zod'
import { generationLogPath, sessionDir, toHeaderLine } from '../../packages/session/session-persistence-jsonl/src/format.ts'
import { compressZstdFrame } from '../../packages/session/session-persistence-jsonl/src/zstd.ts'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { createBenchmarkSessionController } from './session-controller.ts'
const KEY = 'syntheticProjectionList'
const CWD = '/synthetic-projection-benchmark'
@@ -53,7 +54,6 @@ const viewSchema = z.object({ sessionOrdinal: z.number().int(), rows: z.number()
type ProjectionState = z.infer<typeof stateSchema>
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap { syntheticProjectionList: ProjectionState }
interface SessionProjectionMap { syntheticProjectionList: z.infer<typeof viewSchema> }
}
@@ -170,9 +170,9 @@ async function seed(root: string, workload: typeof WORKLOADS[keyof typeof WORKLO
async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[keyof typeof WORKLOADS], observation: ViewObservation, listWorkSliceMs?: number): Promise<SessionController> {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
ctx.sessionProjections.register({
await ctx.plugin(AgentRegistry)
await ctx.sessionProjections.register({
key: KEY, stateVersion: 1, stateSchema,
init: header => makeState(Number(header.id.slice('projection-bench-'.length)), workload.baseRows),
apply: state => state,
@@ -206,7 +206,7 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never)
ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never)
new RemoteAccessService(ctx)
return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false })
return createBenchmarkSessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) })
}
async function memory(): Promise<Memory> {
@@ -0,0 +1,32 @@
/** Session-controller initialization for synthetic corpus workers without attachment I/O. */
import { Service, type Context } from '@deepseek-ai/cordis'
import SessionController, { type Config } from '@deepseek-ai/dsh-api-session-controller'
class BenchmarkSessionController extends SessionController {
initialize(): Promise<void> {
return this[Service.init]()
}
}
/**
* Initialize the controller against the worker's partial Host services.
* @param ctx - worker context owning service disposal.
* @param config - native-opener and list-scheduling settings.
* @returns controller ready for list, search, and fork calls.
*/
export async function createBenchmarkSessionController(ctx: Context, config: Config): Promise<SessionController> {
ctx.provide('attachments', {
imageLimits: {
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
maxImagePixels: 40_000_000,
maxImageDimension: 2000,
mediaTypes: ['image/png'],
},
} as never)
const controller = new BenchmarkSessionController(ctx, config, { canOpenPath: () => false })
await controller.initialize()
return controller
}
@@ -9,7 +9,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
import SessionController from '@deepseek-ai/dsh-api-session-controller'
import type SessionController from '@deepseek-ai/dsh-api-session-controller'
import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections'
import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -28,6 +28,7 @@ import TokenMeter from '@deepseek-ai/dsh-token-meter'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { serializeRecord } from '../../packages/storage/storage-json/src/format.ts'
import { ANCHOR_COUNT } from './corpus-shape.ts'
import { createBenchmarkSessionController } from './session-controller.ts'
import {
anchorHeader,
corpusSessionId,
@@ -141,7 +142,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 200, writeIntervalMs: 5_000 })
// Content search is opt-in; enabled deployments defer the in-memory index to the first search.
await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search' })
ctx.sessionProjections.register(agentPresetProjectionDefinition)
await ctx.sessionProjections.register(agentPresetProjectionDefinition)
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 })
await ctx.plugin(SessionStatsPlugin)
await ctx.plugin(SessionTurnOutlinePlugin)
@@ -158,7 +159,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl
ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never)
ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never)
new RemoteAccessService(ctx)
const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false })
const controller = await createBenchmarkSessionController(ctx, { nativeOpen: false })
return { ctx, controller }
}
@@ -3,7 +3,7 @@
import { performance } from 'node:perf_hooks'
import { scheduler } from 'node:timers/promises'
import { Context } from '@deepseek-ai/cordis'
import AgentLoop, { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
import SessionStore, {
@@ -12,7 +12,7 @@ import SessionStore, {
SessionLogOffset,
SessionPreparation,
} from '@deepseek-ai/dsh-session'
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type AgentHandle } from '@deepseek-ai/dsh-agent'
import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
@@ -30,7 +30,7 @@ import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline
import TokenMeter from '@deepseek-ai/dsh-token-meter'
// These Host-only adapters have no public Node export and are compiled into the benchmark worker.
import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts'
import { installModelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts'
import { modelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts'
import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts'
@@ -128,11 +128,10 @@ function memoryDelta(
}
}
async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean): Promise<void> {
async function installProjectionSet(ctx: Context): Promise<void> {
// Mirrors projection owners mounted by the base and web-app bundles without timing profile boot.
if (!agentLoopOwnsBoundary) ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
ctx.sessionProjections.register(agentPresetProjectionDefinition)
installModelSelectionProjection(ctx)
await ctx.sessionProjections.register(agentPresetProjectionDefinition)
await ctx.sessionProjections.register(modelSelectionProjection)
await ctx.plugin(SessionTitleService, {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
@@ -163,9 +162,10 @@ class SessionBenchmarkHost {
if (agentScenario) await mountAgentLoopTestDependencies(ctx)
else {
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
}
await installProjectionSet(ctx, agentScenario)
await installProjectionSet(ctx)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
let history: SessionHistoryController | undefined
if (scenario === 'first-history') {
+19 -10
View File
@@ -82,7 +82,7 @@ const FACTS: Readonly<Partial<UserTextFacts>> = { sessionLabels: ['会话一'],
const KIB = 1024
/** Exercise each workload's code paths before measuring sub-millisecond baseline evaluations. */
const WARMUP_PAIRS = 10
/** Batch every single-document workload to limit noise in its shortest evaluation intervals. */
/** Base batch size; half-sized scaling inputs use twice as many evaluations. */
const SINGLE_DOCUMENT_EVALUATIONS = 20
const PROSE = '帮我看看这个函数为什么慢,顺便把 README 也更新一下。The quick brown fox jumps over the lazy dog, 1234 times. '
const CODE = 'const value = await fetch(`https://api.example.com/v1/items?id=${id}`); if (!value.ok) throw new Error("bad: " + value.status);\n'
@@ -160,22 +160,32 @@ interface Workload {
readonly id: string
readonly group: UserTextWorkloadGroup
readonly texts: readonly string[]
/** Scaling pairs process the same total keys or characters per sample, including their GC cost. */
readonly evaluationsPerSample: number
}
/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */
function workloads(): readonly Workload[] {
return [
{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000) },
{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 },
...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL })
.map(([name, unit]): Workload => ({ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)] })),
.map(([name, unit]): Workload => ({
id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS,
})),
...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({
id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential),
id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), evaluationsPerSample: 1,
})),
{ id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)] },
{ id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)] },
{ id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS },
{ id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS },
...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [
{ id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)] },
{ id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)] },
{
id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)],
evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS,
},
{
id: `adversarial-${name}-128k`, group: 'adversarial', texts: [fill(unit, 128 * KIB)],
evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS,
},
]),
]
}
@@ -364,8 +374,7 @@ async function allocations(): Promise<UserTextAllocationReport> {
async function timings(attempts: number): Promise<UserTextTimeReport> {
const results: UserTextWorkloadTiming[] = []
for (const { id, group, texts } of workloads()) {
const evaluationsPerSample = texts.length === 1 ? SINGLE_DOCUMENT_EVALUATIONS : 1
for (const { id, group, texts, evaluationsPerSample } of workloads()) {
const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample)
results.push({
id,
+2 -2
View File
@@ -27,8 +27,8 @@
en: e6402b28c0ed15fa
zh: 6c07be9047b42056
/deepseek-harness-architecture/session-log:
en: a2e35c43438fec6d
zh: e27bdac8e9feff32
en: f1729d805c20df36
zh: 6b71b6039663e2e7
/deepseek-harness-architecture/capability-seams:
en: a5bc20c3f2a1b370
zh: 91773a281410b2f4
+1 -1
View File
@@ -123,7 +123,7 @@ Session consumers know only the current logical format. Header-only `stat` and `
**Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations.
**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)).
**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Domain services await registration during activation, retain private handles, and expose business methods for other plugins. Releasing registration removes global participation; retained handles continue folding Session events. `AgentRegistry` owns turn and step state and exposes `turnBoundaryOf(session)` ([registration lifecycle](../packages/session/session-projection/README.md)).
[Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories.
+1 -1
View File
@@ -127,7 +127,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list`
**模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。领域服务在激活时等待注册完成,在内部保留句柄,并向其他插件提供业务方法。释放注册会移除全局参与关系;已有句柄仍可继续折叠 Session 事件。`AgentRegistry` 持有轮次和步骤状态,并提供 `turnBoundaryOf(session)`([注册生命周期](../packages/session/session-projection/README.zh.md))。
[工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。
+58 -28
View File
@@ -59,7 +59,7 @@ export interface Config {
## `@deepseek-ai/dsh-agent-instructions`
- `inject`: `sessionProjections` · `workingDirectory`
- `inject`: `agents` · `workingDirectory`
- `source`: [`packages/context/agent-instructions/src/config.ts:18`](../packages/context/agent-instructions/src/config.ts)
```ts config-catalog
@@ -96,7 +96,7 @@ export interface Config {
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`)
- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts)
- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts)
```ts config-catalog
/** Agent-loop plugin configuration. */
@@ -141,7 +141,7 @@ export type Config = PresetDefinition
## `@deepseek-ai/dsh-agent-preset-registry`
- `inject`: `loader` · `sessionProjections`
- `inject`: `loader` · `sessionProjections` · `agents`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts)
@@ -226,7 +226,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -960,7 +960,7 @@ export interface StagehandModelConfig {
## `@deepseek-ai/dsh-experimental-claude-code-mods`
- `inject`: `workingDirectory`
- `inject`: `workingDirectory` · `agents`
- `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts)
```ts config-catalog
@@ -1472,7 +1472,7 @@ export type Config = LocalConfig
## `@deepseek-ai/dsh-goal`
- `inject`: `agents` · `sessionProjections`
- `source`: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts)
- `source`: [`packages/goal/goal/src/index.ts:168`](../packages/goal/goal/src/index.ts)
```ts config-catalog
/** Deployment defaults for goal creation. */
@@ -1537,8 +1537,8 @@ export interface HmrConfig extends ChokidarOptions {
## `@deepseek-ai/dsh-hooks-claude-code`
- `inject`: `shell` · `sessionProjections` · `workingDirectory`
- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts)
- `inject`: `shell` · `agents` · `workingDirectory`
- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:50`](../packages/experimental/hooks-claude-code/src/index.ts)
```ts config-catalog
/** Plugin config: where the CC hook config lives + substitution roots. */
@@ -1576,8 +1576,8 @@ export interface Config {
## `@deepseek-ai/dsh-hooks-codex`
- `inject`: `shell` · `sessionProjections` · `workingDirectory`
- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts)
- `inject`: `shell` · `agents` · `workingDirectory`
- `source`: [`packages/experimental/hooks-codex/src/index.ts:49`](../packages/experimental/hooks-codex/src/index.ts)
```ts config-catalog
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
@@ -1888,13 +1888,13 @@ export interface PiAiProviderProfile {
compat?: PiAiCompatProfile
/**
* Context capacity for a model this route lists that neither the entry nor
* the installed catalog sizes (default 262,144). A guess by construction, so
* the installed catalog sizes (default 1,000,000). A guess by construction, so
* a deployment whose gateway serves smaller models corrects it here.
*/
defaultContextWindow?: number
/**
* Output capability for a model this route lists that neither the entry nor
* the installed catalog sizes (default 32,768). This sizes the model; it
* the installed catalog sizes (default 128,000). This sizes the model; it
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
@@ -2396,7 +2396,7 @@ export interface Config {
- `inject`: `shell` · `approval` · `sessions` · `sessionProjections`
- `refs`: [`ApprovalPolicy`](subsystems/approval.md) · [`SandboxMode`](subsystems/sandbox.md) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts)
- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
```ts config-catalog
/** The {@link PermissionPresetService} config: preset table and composition default. */
@@ -2464,7 +2464,7 @@ export interface Config {
## `@deepseek-ai/dsh-plan-mode`
- `inject`: `tools` · `systemPrompt` · `sessionProjections`
- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents`
- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts)
```ts config-catalog
@@ -2482,7 +2482,8 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.md)
- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2507,6 +2508,18 @@ export interface Config {
* unless that is npm's own registry or one of these.
*/
fallbackRegistries?: string[]
/** How many of the newest versions above the installed one a version listing reads and returns. */
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
/** Largest bundle detail document, and largest locale file read for one, in bytes. */
detailMaxBytes?: number
/** Largest image served beside a bundle detail document, in bytes. */
detailImageMaxBytes?: number
/** Deadline of one npm file CDN request for an offered bundle's detail document. */
detailFetchTimeoutMs?: number
/** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources?: NpmFileSource[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-manager -->
@@ -2517,8 +2530,8 @@ export interface Config {
## `@deepseek-ai/dsh-plugin-market`
- `inject`: `pluginManager`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.md) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts)
```ts config-catalog
/** Index sources and fetch limits. */
@@ -2529,8 +2542,24 @@ export interface Config {
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
/** Lifetime of a successfully read index document. */
/** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
/** Largest detail document in bytes. */
detailMaxBytes: number
/** Largest image beside a detail document in bytes. */
detailImageMaxBytes: number
/** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
detailRepositoryMaxBytes: number
/** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
detailCacheMaxBytes: number
/** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
detailCacheDir?: string
/** Deadline of one Git command of the clone fallback. */
detailGitTimeoutMs: number
/** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources: NpmFileSource[]
/** Hosts serving the GitLab repository files API. */
detailGitlabHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-market -->
@@ -3056,7 +3085,7 @@ export enum SessionTelemetryMode {
## `@deepseek-ai/dsh-session-title`
- `inject`: `sessions` · `sessionProjections`
- `inject`: `sessions` · `sessionProjections` · `agents`
- `source`: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts)
```ts config-catalog
@@ -3391,7 +3420,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
## `@deepseek-ai/dsh-subagent`
- `inject`: `workingDirectory`
- `inject`: `workingDirectory` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts)
@@ -3643,7 +3672,7 @@ export interface Config {
## `@deepseek-ai/dsh-terminal-bash`
- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory`
- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory`
- `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts)
```ts config-catalog
@@ -3704,7 +3733,7 @@ export type ShellDialect = 'bash' | 'pwsh'
## `@deepseek-ai/dsh-time-context`
- `inject`: `agents` · `sessionProjections`
- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts)
- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts)
```ts config-catalog
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
@@ -3892,7 +3921,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-goal`
- `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections`
- `inject`: `agents` · `goals` · `tools` · `systemPrompt`
- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts)
```ts config-catalog
@@ -3967,8 +3996,8 @@ export interface Config {
## `@deepseek-ai/dsh-tool-present`
- `inject`: `tools` · `fs` · `sessionProjections` · `workingDirectory`
- `source`: [`packages/deliverables/tool-present/src/index.ts:16`](../packages/deliverables/tool-present/src/index.ts)
- `inject`: `tools` · `fs` · `agents` · `workingDirectory`
- `source`: [`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts)
```ts config-catalog
/** Per-call delivery limit. */
@@ -4060,7 +4089,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-session-query`
- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections`
- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `agents`
- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts)
```ts config-catalog
@@ -4619,7 +4648,7 @@ export interface Config {
## `@deepseek-ai/dsh-working-directory`
- `inject`: `fs` · `sessionProjections` · `systemPrompt`
- `source`: [`packages/session/working-directory/src/index.ts:56`](../packages/session/working-directory/src/index.ts)
- `source`: [`packages/session/working-directory/src/index.ts:51`](../packages/session/working-directory/src/index.ts)
```ts config-catalog
/** Deployment default for Sessions without an original project directory. */
@@ -4666,7 +4695,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| `package` | `inject` | `source` |
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
@@ -4847,6 +4876,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) |
| `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) |
| `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) |
+58 -28
View File
@@ -61,7 +61,7 @@ export interface Config {
## `@deepseek-ai/dsh-agent-instructions`
- `inject`: `sessionProjections` · `workingDirectory`
- `inject`: `agents` · `workingDirectory`
- `source`: [`packages/context/agent-instructions/src/config.ts:18`](../packages/context/agent-instructions/src/config.ts)
```ts config-catalog
@@ -98,7 +98,7 @@ export interface Config {
- `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections`
- `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`)
- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts)
- `source`: [`packages/core/agent-loop/src/index.ts:250`](../packages/core/agent-loop/src/index.ts)
```ts config-catalog
/** Agent-loop plugin configuration. */
@@ -143,7 +143,7 @@ export type Config = PresetDefinition
## `@deepseek-ai/dsh-agent-preset-registry`
- `inject`: `loader` · `sessionProjections`
- `inject`: `loader` · `sessionProjections` · `agents`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/preset/agent-preset-registry/src/preset.ts:13`](../packages/preset/agent-preset-registry/src/preset.ts)
@@ -228,7 +228,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
@@ -962,7 +962,7 @@ export interface StagehandModelConfig {
## `@deepseek-ai/dsh-experimental-claude-code-mods`
- `inject`: `workingDirectory`
- `inject`: `workingDirectory` · `agents`
- `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts)
```ts config-catalog
@@ -1474,7 +1474,7 @@ export type Config = LocalConfig
## `@deepseek-ai/dsh-goal`
- `inject`: `agents` · `sessionProjections`
- `source`: [`packages/goal/goal/src/index.ts:172`](../packages/goal/goal/src/index.ts)
- `source`: [`packages/goal/goal/src/index.ts:168`](../packages/goal/goal/src/index.ts)
```ts config-catalog
/** Deployment defaults for goal creation. */
@@ -1539,8 +1539,8 @@ export interface HmrConfig extends ChokidarOptions {
## `@deepseek-ai/dsh-hooks-claude-code`
- `inject`: `shell` · `sessionProjections` · `workingDirectory`
- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts)
- `inject`: `shell` · `agents` · `workingDirectory`
- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:50`](../packages/experimental/hooks-claude-code/src/index.ts)
```ts config-catalog
/** Plugin config: where the CC hook config lives + substitution roots. */
@@ -1578,8 +1578,8 @@ export interface Config {
## `@deepseek-ai/dsh-hooks-codex`
- `inject`: `shell` · `sessionProjections` · `workingDirectory`
- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts)
- `inject`: `shell` · `agents` · `workingDirectory`
- `source`: [`packages/experimental/hooks-codex/src/index.ts:49`](../packages/experimental/hooks-codex/src/index.ts)
```ts config-catalog
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
@@ -1890,13 +1890,13 @@ export interface PiAiProviderProfile {
compat?: PiAiCompatProfile
/**
* Context capacity for a model this route lists that neither the entry nor
* the installed catalog sizes (default 262,144). A guess by construction, so
* the installed catalog sizes (default 1,000,000). A guess by construction, so
* a deployment whose gateway serves smaller models corrects it here.
*/
defaultContextWindow?: number
/**
* Output capability for a model this route lists that neither the entry nor
* the installed catalog sizes (default 32,768). This sizes the model; it
* the installed catalog sizes (default 128,000). This sizes the model; it
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
@@ -2398,7 +2398,7 @@ export interface Config {
- `inject`: `shell` · `approval` · `sessions` · `sessionProjections`
- `refs`: [`ApprovalPolicy`](subsystems/approval.zh.md) · [`SandboxMode`](subsystems/sandbox.zh.md) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/interaction/permission-presets/src/index.ts:159`](../packages/interaction/permission-presets/src/index.ts)
- `source`: [`packages/interaction/permission-presets/src/index.ts:152`](../packages/interaction/permission-presets/src/index.ts)
```ts config-catalog
/** The {@link PermissionPresetService} config: preset table and composition default. */
@@ -2466,7 +2466,7 @@ export interface Config {
## `@deepseek-ai/dsh-plan-mode`
- `inject`: `tools` · `systemPrompt` · `sessionProjections`
- `inject`: `tools` · `systemPrompt` · `sessionProjections` · `agents`
- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts)
```ts config-catalog
@@ -2484,7 +2484,8 @@ export interface PlanModeConfig {
## `@deepseek-ai/dsh-plugin-manager`
- `inject`: `loader` · `profileContext`
- `source`: [`packages/boot/plugin-manager/src/index.ts:42`](../packages/boot/plugin-manager/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.zh.md)
- `source`: [`packages/boot/plugin-manager/src/index.ts:50`](../packages/boot/plugin-manager/src/index.ts)
```ts config-catalog
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
@@ -2509,6 +2510,18 @@ export interface Config {
* unless that is npm's own registry or one of these.
*/
fallbackRegistries?: string[]
/** How many of the newest versions above the installed one a version listing reads and returns. */
versionListLimit?: number
/** How many single-version registry lookups a version listing runs at once. */
versionLookupConcurrency?: number
/** Largest bundle detail document, and largest locale file read for one, in bytes. */
detailMaxBytes?: number
/** Largest image served beside a bundle detail document, in bytes. */
detailImageMaxBytes?: number
/** Deadline of one npm file CDN request for an offered bundle's detail document. */
detailFetchTimeoutMs?: number
/** npm file CDNs that offered bundles are read from, in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources?: NpmFileSource[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-manager -->
@@ -2519,8 +2532,8 @@ export interface Config {
## `@deepseek-ai/dsh-plugin-market`
- `inject`: `pluginManager`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:32`](../packages/boot/plugin-market/src/index.ts)
- `refs`: [`NpmFileSource`](subsystems/boot.zh.md) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/boot/plugin-market/src/index.ts:43`](../packages/boot/plugin-market/src/index.ts)
```ts config-catalog
/** Index sources and fetch limits. */
@@ -2531,8 +2544,24 @@ export interface Config {
fetchTimeoutMs: number
/** Largest accepted index body in bytes. */
maxBytes: number
/** Lifetime of a successfully read index document. */
/** Lifetime of a successfully read index document, a detail read result, and a repository's default-branch commit. */
cacheTtlMs: number
/** Largest detail document in bytes. */
detailMaxBytes: number
/** Largest image beside a detail document in bytes. */
detailImageMaxBytes: number
/** Largest amount in bytes one Git command adds to a partial clone; a repository whose fetch adds more is not read. */
detailRepositoryMaxBytes: number
/** Largest total size of the partial clones; least recently used ones are deleted beyond it. */
detailCacheMaxBytes: number
/** Directory of the partial clones, relative to the profile directory; absent means `.plugin-market/repositories`. */
detailCacheDir?: string
/** Deadline of one Git command of the clone fallback. */
detailGitTimeoutMs: number
/** npm file CDNs read in order; a read's `npmFileSources` moves the CDNs it names first. */
detailNpmFileSources: NpmFileSource[]
/** Hosts serving the GitLab repository files API. */
detailGitlabHosts: string[]
}
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-plugin-market -->
@@ -3058,7 +3087,7 @@ export enum SessionTelemetryMode {
## `@deepseek-ai/dsh-session-title`
- `inject`: `sessions` · `sessionProjections`
- `inject`: `sessions` · `sessionProjections` · `agents`
- `source`: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts)
```ts config-catalog
@@ -3393,7 +3422,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
## `@deepseek-ai/dsh-subagent`
- `inject`: `workingDirectory`
- `inject`: `workingDirectory` · `sessionProjections`
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts)
@@ -3645,7 +3674,7 @@ export interface Config {
## `@deepseek-ai/dsh-terminal-bash`
- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory`
- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory`
- `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts)
```ts config-catalog
@@ -3706,7 +3735,7 @@ export type ShellDialect = 'bash' | 'pwsh'
## `@deepseek-ai/dsh-time-context`
- `inject`: `agents` · `sessionProjections`
- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts)
- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts)
```ts config-catalog
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
@@ -3894,7 +3923,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-goal`
- `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections`
- `inject`: `agents` · `goals` · `tools` · `systemPrompt`
- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts)
```ts config-catalog
@@ -3969,8 +3998,8 @@ export interface Config {
## `@deepseek-ai/dsh-tool-present`
- `inject`: `tools` · `fs` · `sessionProjections` · `workingDirectory`
- `source`: [`packages/deliverables/tool-present/src/index.ts:16`](../packages/deliverables/tool-present/src/index.ts)
- `inject`: `tools` · `fs` · `agents` · `workingDirectory`
- `source`: [`packages/deliverables/tool-present/src/index.ts:15`](../packages/deliverables/tool-present/src/index.ts)
```ts config-catalog
/** Per-call delivery limit. */
@@ -4062,7 +4091,7 @@ export interface Config {
## `@deepseek-ai/dsh-tool-session-query`
- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections`
- `inject`: `tools` · `systemPrompt` · `sessionQuery` · `agents`
- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts)
```ts config-catalog
@@ -4621,7 +4650,7 @@ export interface Config {
## `@deepseek-ai/dsh-working-directory`
- `inject`: `fs` · `sessionProjections` · `systemPrompt`
- `source`: [`packages/session/working-directory/src/index.ts:56`](../packages/session/working-directory/src/index.ts)
- `source`: [`packages/session/working-directory/src/index.ts:51`](../packages/session/working-directory/src/index.ts)
```ts config-catalog
/** Deployment default for Sessions without an original project directory. */
@@ -4668,7 +4697,7 @@ export interface Config {
| `package` | `inject` | `source` |
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) |
| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) |
| `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) |
@@ -4849,6 +4878,7 @@ export interface Config {
| `@deepseek-ai/dsh-native-command` | — | [`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts) |
| `@deepseek-ai/dsh-output-retention` | — | [`packages/util/output-retention/src/index.ts`](../packages/util/output-retention/src/index.ts) |
| `@deepseek-ai/dsh-package-manifest` | — | [`packages/util/package-manifest/src/index.ts`](../packages/util/package-manifest/src/index.ts) |
| `@deepseek-ai/dsh-plugin-detail` | — | [`packages/util/plugin-detail/src/index.ts`](../packages/util/plugin-detail/src/index.ts) |
| `@deepseek-ai/dsh-plugin-market-index` | — | [`packages/util/plugin-market-index/src/index.ts`](../packages/util/plugin-market-index/src/index.ts) |
| `@deepseek-ai/dsh-remote-mock` | — | [`packages/test-support/remote-mock/src/index.ts`](../packages/test-support/remote-mock/src/index.ts) |
| `@deepseek-ai/dsh-sandbox-windows-acl` | — | [`packages/sandbox/sandbox-windows-acl/src/index.ts`](../packages/sandbox/sandbox-windows-acl/src/index.ts) |
+2 -2
View File
@@ -21,8 +21,8 @@
en: 0894bd9f35430753
zh: 2d28090e71134edd
/cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata:
en: 2382017c7f84ace6
zh: 03af99592baaab9f
en: 68b463cd3d2e5fb0
zh: 80ea797c5770c573
/cookbook-adding-a-workspace-package/6-verify:
en: 80c7fb90b1175aae
zh: 2ba37962dbc57299
+3 -1
View File
@@ -14,7 +14,7 @@ packages/<group>/<pkg>/
# ../../../vendor/cordis (+ ../../../vendor/schemastery if
# you use Config, + ../../<group>/<dep> for each dsh dep)
src/index.ts # service default export or plugin (name/inject/apply/Config)
locale/en.json # optional display metadata: meta.title and meta.description
locale/en.json # optional display metadata: meta.title, meta.description, and meta.detail
locale/zh.json # translations using the same fields
README.md # service API, events, extension points, design notes,
# + gated Model Experience context blocks or short form
@@ -147,6 +147,8 @@ Export `<package name>/locale/en.json` for locale lookup; expose `<package name>
For an image on bundle cards, details, and component rows, a package-root plugin sets top-level `"icon": "./icon.svg"` in its exported manifest, or exports `./icon` when that field is omitted. A subpath plugin exports its own icon, such as `"./search/icon": "./search.svg"`. Include each image in `files`. A manifest `icon` is relative to the manifest's directory; absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. An exported icon resolves through Node exports like locale resources and must remain inside its package. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Images must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text, without trying the export; missing or undecodable images use the panel's default artwork.
A bundle can also set `meta.detail` to a Markdown file path relative to the package root, such as `"docs/DETAIL.md"` in `locale/en.json` and `"docs/DETAIL.zh.md"` in `locale/zh.json`; it is read from these files at the package root. The path uses `/`, stays inside the package, ends in `.md` or `.markdown`, and has no package-field fallback; include the file and its images in `files`. A bundle's page in **Plugins**, and its market entry page when the [custom plugin markets bundle](../../packages/experimental/plugin-market-bundle/README.md) is on, show the file for the active UI language as the plugin's detail document. Without `meta.detail` they show the package README, and a market entry page then falls back to the declared repository's README; the [document order](../../packages/util/plugin-detail/README.md#document-order) defines the rules. Write it for users, and reference images by paths relative to the file.
Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins.
Only Settings shortens literal package-name and module-name fallbacks by removing npm scope and Cordis/DSH prefixes; Plugin Manager keeps complete names. Locale titles and descriptions remain unchanged. A row configuration page can use its registered summary when the plugin has no display description.
Loaded 100 of 585 files, more files were not shown because too many files have changed in this diff. Show more