Merge remote-tracking branch 'origin/master' into feat/plugin-recommendations

# Conflicts:
#	docs/capability-seams.i18n.yaml
This commit is contained in:
ZiyaZhang committed 2026-10-19 21:00:44 -07:00
commit 59fb9da656
161 files changed
+5905 -510

No files matched your search

@@ -21,8 +21,8 @@
en: 0a4240d88cca240a
zh: 8f5f1527caf571cd
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/the-loading-flow-end-to-end:
en: 7dac236d6add3c3c
zh: 2a43a923a0646bb0
en: 15fb56befd328339
zh: 0ffb10ada7e52805
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/decision/live-graph-reconciliation:
en: 37e2fa1fe5cb2b95
zh: 80e513db87709cb4
@@ -33,8 +33,8 @@
en: 94ca0d09e6fd1e3a
zh: d5949a2bdec795ba
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/consequences:
en: 6a96d9ac06c222e5
zh: b2814ab58348e831
en: 16e1f2467c559315
zh: a714ec6ff2722cf1
/agent-note-client-plugin-loading-lazy-factories-cordis-lifecycle-and-hot-reload/alternatives-considered:
en: b8f036f50cff7cd8
zh: 6d623c4a1f4314b1
@@ -56,7 +56,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. `auditStartupEntries` reports failed imports, activation errors with their original stacks, and pending dependencies; optional entries warn and required entries reject startup ([startup policy](2026-09-09-consumer-owned-startup-strictness.md)).
2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) resolves each live Loader entry through the same `name` and owning-tree `baseUrl` inputs that imported its Host face, then reads the nearest owning package.json `dsh.client` declaration and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. The manifest package name is the browser module identity even when an overlay names a relative source or built entry file. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. The row's three optional fields come from manifests, never hand-copied. Composition orders requested dynamic rows before their consumers, rejects synchronous request cycles, and assigns every row to exactly one initial batch. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the Host audit reports either error from the FAILED fiber.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per entry name and owning-tree base URL for the process lifetime and changed bundle publication reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, rereading the package manifest and bundle metadata on each reconciliation, so an entry restart publishes a changed declaration or bundle and an unchanged one publishes nothing; a bundle rebuilt without an entry restart is published through `rebuilt(id)` ([package asset decision](2026-10-19-client-plugin-package-assets.md)). The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Initial rows hash their pre-read filesystem metadata; startup combo revisions hash the ordered row identities and revisions, and the rows plus batch descriptors hash into `graph.rev`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph, while modules registers the combo route and contributes structured index-injection rows.
Why is the roster yml rows and not a scan? Because which plugins compose into a deployment is a composition decision, not a package property — a package declaring `dsh.client` in the repo does not mean this deployment mounts it, so discovery-by-scan cannot make that call; the node half scans only what the tree actually mounted.
@@ -102,7 +102,7 @@ The current package inventory and build forms live in the [client shell layering
## Consequences
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Dynamic packages have one artifact form, so the purity check covers them all. Cordis dependencies, module requests, and the boot tier live with their owners — the manifests — while the composing app holds only the roster. Host graph validation and recursive request arrival keep synchronous factory dependencies explicit. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps two replaceable carrier hooks: `loadBundle` for bundle bytes and `resolveAsset` for package-asset references.
Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains bundle snapshots and lazy response plans for the current graph and one prior startup generation. Script and map bodies are cached after their first `GET`, so memory grows with bundle snapshots and requested response bodies. Each materialized response stays fixed at its URL; a prior-generation map first requested after a rebuild reads the current map file.
@@ -56,7 +56,7 @@ Host 会快照每个已构建插件入口 bundle,并把每个调度阶段的
1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。`auditStartupEntries` 报告 import 失败、带原始 stack 的激活错误,以及待满足的依赖;optional entry 输出 warning,required entry 则使启动失败([启动策略](2026-09-09-consumer-owned-startup-strictness.zh.md))。
2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)使用 Host face import 时相同的 `name` 与所属 tree `baseUrl` 解析每个 live Loader entry,再读取最近归属 package.json 的 `dsh.client` 声明并组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。即使 overlay 指向相对的 source 或 built entry 文件,manifest 包名仍是浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。Row 的三个可选字段都来自 manifest,永不人肉抄写。组合会把被请求的动态图 row 排到消费者之前、拒绝同步请求环,并把每个 row 恰好分配给一个初始批次。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,Host 检查会从 FAILED fiber 报告这两类错误。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按 entry 名与所属 tree base URL 缓存至进程结束,变化后 bundle 的发布只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,每次对账都重新读取包 manifest 与 bundle 元数据,因此条目重启会发布变化后的声明或 bundle,未变化时不发布;没有伴随条目重启的 bundle 重建经 `rebuilt(id)` 发布([包资源决策](2026-10-19-client-plugin-package-assets.zh.md))。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 对读取前的文件系统元数据求哈希;启动 combo revision 对有序 row 的身份与 revision 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 combo 路由并贡献结构化 index 注入行。
为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
@@ -102,7 +102,7 @@ Bootstrap 替换会在失效或卸载前被拒绝:模块系统保留其初始
## Consequences
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。动态包只有一种产物形态,因此纯度检查覆盖全部动态包。Cordis 依赖、模块请求与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。Host graph 校验与递归请求到达使同步 factory 依赖保持显式。浏览器原生 script 装载保留插件网络资源、生成 bundle 与 TypeScript/TSX 源码之间的标准映射,模块系统只保留两个可替换的载体钩子:负责 bundle 字节的 `loadBundle` 和负责包资源引用的 `resolveAsset`。
接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 为当前图及上一代启动图保留 bundle 快照与惰性响应计划。脚本和 map body 在首次 `GET` 后缓存,因此内存随 bundle 快照和已请求的响应 body 增长。每份已物化响应在其 URL 下保持固定;若上一代 map 在重建后才首次被请求,则会读取当前 map 文件。
@@ -12,8 +12,8 @@
en: 972bbe45fe8ac28e
zh: 593b851c142894e1
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches:
en: f44b784545c6d9a2
zh: 3eb6fcb9f348dec4
en: 913d1dceab36d4a1
zh: 049521c5127f3478
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings:
en: b8278678170f1507
zh: 113a51ba5cf27de1
@@ -38,7 +38,7 @@ Subagent children showed no local echo because, when echoes were introduced, `su
- `finish`: install the final room.
Content added during the batch updates the remaining movement by starting a new segment.
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation.
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation, and so does a row whose ancestor finished closing, because `hidden="until-found"` keeps that row's own size while removing it from the flow.
- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately.
- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change).
- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain.
@@ -38,7 +38,7 @@ Status: implemented
- `finish`:安装最终补白。
批次中途新增的内容按分段规则更新剩余运动。
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画,祖先已收起完成的行同样如此,因为 `hidden="until-found"` 让这类行保留自身尺寸,却已不占文档流。
- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。
- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。
- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.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-17-agent-controller-message-delivery.md
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller:
en: 9105f8148e107f43
zh: a160e87bb0605da4
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem:
en: 5071232d9743096c
zh: b553a626538b3466
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision:
en: 1b61d351c4d2bdf1
zh: d6f8180b30d97b34
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered:
en: acc465cfe02b624c
zh: db1d06574f647130
/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences:
en: 9d8486586d044709
zh: 24c7249421fd2541
@@ -0,0 +1,51 @@
# Agent Note: Delivering messages from outside an Agent through the Agent controller
Status: implemented
English | [中文](2026-10-17-agent-controller-message-delivery.zh.md)
## Problem
Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client.
## Decision
The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged.
| Part | Contract |
|---|---|
| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox |
| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake |
| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails |
| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it |
| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled |
| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox |
Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message.
`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice.
The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check.
Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids.
The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile.
## Alternatives considered
- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract.
- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it.
- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts.
- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing.
- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it.
- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`.
The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it.
## Consequences
- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed.
- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft.
- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced.
- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt.
- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point.
@@ -0,0 +1,51 @@
# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息
Status: implemented
[English](2026-10-17-agent-controller-message-delivery.md) | 中文
## 问题
插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。
## 决策
core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。
| 部分 | 约定 |
|---|---|
| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id |
| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 |
| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 |
| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 |
| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 |
| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 |
只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。
`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。
Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。
请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。
Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。
## 备选方案
- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。
- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。
- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。
- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。
- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。
- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。
PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。
## 影响
- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。
- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。
- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。
- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。
- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。
@@ -0,0 +1,40 @@
# Bilingual-pair consistency record for 2026-10-19-client-plugin-package-assets.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-19-client-plugin-package-assets.md
/agent-note-client-plugin-package-assets-served-by-url:
en: 5505b948511a2d52
zh: dae985d67e5c5935
/agent-note-client-plugin-package-assets-served-by-url/problem:
en: 159de908b09780eb
zh: 8d15ca2f15b8846a
/agent-note-client-plugin-package-assets-served-by-url/decision:
en: 8ffcd355ec5e8271
zh: 2932530bf783e3f7
/agent-note-client-plugin-package-assets-served-by-url/decision/references-and-file-names:
en: 704267953c123629
zh: 860997024ad7825e
/agent-note-client-plugin-package-assets-served-by-url/decision/build:
en: 485013b2b6778fa2
zh: 7e5cb238bb2b8585
/agent-note-client-plugin-package-assets-served-by-url/decision/host-route:
en: 151b7300f5b27a9f
zh: fae9adff942c2ce8
/agent-note-client-plugin-package-assets-served-by-url/decision/webworker-preview:
en: db1c7b2a0b916804
zh: 0edf4123667d8e2e
/agent-note-client-plugin-package-assets-served-by-url/decision/changes-and-package-manifests:
en: eedff52d04763c50
zh: 203bfcf81242b8fe
/agent-note-client-plugin-package-assets-served-by-url/decision/gates-and-migration:
en: 1e4d83d0de2d47c9
zh: dc2c2876c73d3b1c
/agent-note-client-plugin-package-assets-served-by-url/alternatives-considered:
en: 98960e10634ee536
zh: f0fdb0b0cdf9511c
/agent-note-client-plugin-package-assets-served-by-url/consequences:
en: 898262f39c0243ae
zh: e30f2161f5b669fe
/agent-note-client-plugin-package-assets-served-by-url/testing:
en: 8b6ce91df7c4c3a5
zh: b149b71cef5be761
@@ -0,0 +1,92 @@
# Agent Note: Client plugin package assets served by URL
Status: implemented
English | [中文](2026-10-19-client-plugin-package-assets.zh.md)
## Problem
The browser half of a dynamic Client plugin is a lazy-CJS factory built by the [client build preset](../../../../packages/client/tsdown.client.ts) and loaded through the `/plugins` route of the [Client module system](../../../../packages/client/modules/README.md). That route served only `client.js` and package-local chunks, so an image that plugin code referenced had to be embedded in the bundle as a base64 data URL, and an image used by a stylesheet needed a package-level build plugin that rewrote it into the inlined style.
The embedded bytes were downloaded and parsed with the JavaScript whether or not the image was shown, and base64 adds a third to their size: the onboarding illustrations made the ui-settings-account `client.js` about 3.4 MB. ui-chat carried its own [tsdown plugin](../../../../packages/client/ui-chat/tsdown.config.ts) for one mask image.
## Decision
A Client plugin package keeps raw images under `src/client/assets/`. The shared preset emits each referenced image under `lib/assets/` and turns the reference into a synchronous `require.asset(<document-relative URL>)` call on the bundle's module loader; the package publishes the emitted files with `"exports": { "./assets/*": "./lib/assets/*" }` and `lib/assets` in `files`; the client-modules Host serves them at `/plugins/<package>/assets/<emitted path>`.
### References and file names
Client code imports an image (`import art from './assets/a.webp'`), and the package's own stylesheets reference one with a relative `url()`. Both compile to a `require.asset(reference)` call whose argument is the same reference; a stylesheet becomes string literals joined with those calls, which run when its injected text is built. The module system answers each call through its `resolveAsset` option, which returns the reference unchanged unless a carrier supplies one. The emitted file name inserts the content revision, the first 12 hexadecimal digits of the SHA-1 digest of the bytes, before the extension, so one URL always names one content:
```text
src/client/assets/[email protected]
lib/assets/[email protected]
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
```
Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`, and the package name is emitted verbatim. The URL is relative to the shell document, like every app-owned route, so it resolves under any mount and from injected `<style>` text. Components need no hook, injected value, or service: the URL is a module constant computed when the module first runs.
The supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) in client-modules owns the media-type table, the export key and target, the revision, the emitted name, and the URL, and the preset, the route, and the gates read them from there. The shared Client type root [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts), registered in [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json), declares each extension as a module with a string default export; a test keeps it equal to the table.
### Build
The `dsh-package-asset` plugin of the dynamic browser face resolves an import against its source location and fails the build when the file's real path is outside the package's `src/client/assets/` (which also rejects another package's or a dependency's image), when `exports["./assets/*"]` is not exactly `./lib/assets/*`, when the extension is unsupported, when a path segment is `node_modules` or contains `%`, `#`, `?`, `\`, or NUL, which the Host route cannot serve, or when the extension is not lowercase, as the import types are. It emits each file once per build and registers it as a watch input. The three CSS pipelines (CSS Modules, global, and `?inline`) rewrite relative `url()` values through a lightningcss visitor in stylesheets inside the package's own `src/`; `data:`, fragment, scheme, `/`-prefixed, and empty URLs stay unchanged, a relative file reference with a fragment or query fails the build, and dependency stylesheets are not rewritten. lightningcss does not call its `Url` visitor inside `image-set()`, so an `Image` visitor rewrites those options with the same rule. The rewrite writes an identifier placeholder that lightningcss never escapes, so a file name with parentheses or quotes still becomes its resolver call. The Node Client face of a dual-face package rejects asset imports. The browser face is the only writer of `lib/assets/`: a one-shot build deletes files it did not emit, and a watch rebuild keeps them, because the Host still serves the previous bundle generation and pages running it request those names.
### Host route
`bundleResource` matches `/plugins/<package>/assets/<path>` before the bundle tables, and such a path is answered by the asset route alone. For an active client row, the path is percent-decoded once and `createRequire(<package manifest>).resolve('<package>/assets/<path>')` applies the package's own exports, so unexported paths, `.` and `..` segments, missing files, and directories do not resolve. The media type comes from the resolved file's extension, because Node reads `#` and `?` in the subpath as URL syntax. Any resolution failure, an inactive package, and an unsupported type answer 404; the query selects nothing.
A served asset streams from disk on every request; the Host keeps neither its bytes nor a hash, and a request the browser cancels mid-stream ends without a warning. It carries the media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag` equal to the requested path, `x-content-type-options: nosniff`, and, for SVG, `content-security-policy: script-src 'none'`. An `If-None-Match` equal to the tag answers 304 without opening the file; `HEAD` answers without a body. `fetchBundle()` returns the same responses with a streamed body that closes the file when cancelled.
### WebWorker preview
The preview page runs its Host in a Worker, and a page request for a reference never reaches it. Before any Client bundle runs, the page asks the Worker for `GET /__assets__`, answered by tunnel glue like `GET /__boot__`: an uncompressed tar of every file under `lib/assets/` of each image package that declares `"./assets/*": "./lib/assets/*"`, named by its unencoded `plugins/<package>/assets/<path>`, which fits the ustar name limits the base image already met. The page encodes each entry name into its reference and turns the entry into a Blob URL with the media type of its extension, and its `resolveAsset` returns that URL, or the reference when the image holds no such file. The base image already carries these files, so no network request is added; each boot still walks the image's packages, archives every asset, and copies each entry into a Blob, enabled or not.
### Changes and package manifests
A rebuilt image produces a new file name in a rebuilt `client.js`, which reaches the browser through the existing bundle HMR; assets have no channel of their own. Client-modules reads a package manifest and the bundle metadata on every reconciliation, that is, on each Loader entry construction and disposal, instead of caching the manifest until restart, so an entry that restarts after a reinstall, upgrade, or move publishes its current declaration, client export, and manifest path; a reconciliation that finds the same manifest and bundle metadata leaves the published graph unchanged and reads no bundle bytes.
### Gates and migration
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) requires `lib/assets` in `files` when the export is declared. [`verify-client-packages`](../../../../scripts/verify-client-packages.ts) requires the exact export target, allows the export only on dynamic Web Client packages, and keeps the export and the package's references (imports, and `url()` in the stylesheets its Client sources import) consistent in both directions. [`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) treats `./assets/*` as a non-code export. ui-settings-account and ui-chat moved to package assets, and ui-chat's tsdown plugin is gone.
Images that third-party libraries load themselves (PDF.js fonts, cmaps, wasm, and workers), plugin metadata icons, detail-document images, and static-linked packages keep their own delivery.
## Alternatives considered
**Keep data URLs.** No route is needed, but the bytes travel and parse with the JavaScript, and every package that styles with an image writes its own build plugin.
**A plain URL string with no runtime helper.** The served Web app needs nothing more, but the WebWorker preview page cannot request the URL, and changing it afterwards means copying the files to a static deployment or rewriting strings found in bundle text. A static copy is a build-time snapshot that bypasses the Host's rules and misses packages installed at runtime; finding strings in bundle text guesses at code. A synchronous call keeps the build output explicit and lets each carrier decide the URL.
**A Client Cordis service such as `ctx.assets.url(path)`.** Current consumers reference fixed files; a runtime lookup also cannot know the content revision without publishing a per-package asset list.
**Raw files in a package-root `assets/` without the build.** Authors could not preprocess them, so naming and format conversion would stay manual.
**The revision in a `?rev=` query, verified by the Host.** The Host must read a whole file to decide between 200 and 404, so a first request reads it twice and the Host must cache hashes by file metadata; a revision in the file name lets the Host only resolve and stream.
**The `client.js` revision as the asset revision.** That revision comes from the written bundle's metadata after the build, too late to appear in the bundle.
**Images in CSS only through JavaScript style objects.** Masks, backgrounds, and `@supports` fallbacks would move into components, while the URL is known at build time and the same rules rewrite `url()`.
**One plugin file protocol shared with detail documents.** Detail images read any package version from local, npm, or Git sources, need authentication because they trigger Host network access, and cache by source. Joining them would move authentication, source providers, and cache policy into client-modules; the Plugin Manager and Plugin Market detail routes stay separate until another consumer needs files of other packages.
**Manifests from Plugin Manager, or a manifest cache with change checks.** Plugin Manager lists bundles, not every client row, and resolves from the profile installation rather than the Loader resolution of the running entry. The failure was timing: a cache that outlived entry restarts. Rereading on each reconciliation and skipping unchanged results removes the cache instead of adding validation to it.
## Consequences
- The ui-settings-account `client.js` is 214 KB, and the account and ui-chat bundles contain no image data URLs; an image costs one same-origin request when shown and may appear after layout.
- `require.asset` belongs to the bundle factory contract beside `require.async`; a package built outside this repository must emit the call. The WebWorker preview creates a Blob URL for every package asset of its image at boot, enabled or not, and keeps them for the page's lifetime.
- The Desktop forwarder rewrites `/plugins/` cache headers to `no-store`, so Desktop reads an asset again on each display.
- The Host does not compare file contents with the revision in the name: a file under `lib/assets/` edited in place keeps its immutable cache entries. Only the build writes that directory.
- `require.resolve` runs synchronously on each request; a local measurement took about 43 µs for an existing file and 170 µs for a missing one. It reads exports through Node's package configuration cache, so an `./assets/*` export edited in place takes effect after boot HMR expires that package or the Host restarts. The route does not check the resolved file's real path, so a symbolic link under `lib/assets/` that leads outside the package is served.
- A package outside this repository must reproduce the emitted-name rule and the `require.asset` call; the Client module system README records them.
- A development session leaves earlier asset files under `lib/assets/` until the next one-shot build removes them; that pruning assumes the browser face is the directory's only writer.
- An entry restart reads its manifest and stats its bundle; only a changed result reads the bundle and broadcasts a graph frame (about 30 KB for 69 rows). Editing only `package.json`, without an entry restart, still takes effect at the next entry or Host restart.
## Testing
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) composes the real Web server and compares every answer with `fetchBundle()`: headers including the SVG policy, 304 without opening the file, `HEAD`, 405, multi-chunk streaming, stream closing on cancel, a browser cancellation that logs nothing, and 404 for excluded subpaths, missing exports and files, `..`, `%2e%2e`, encoded `..`, an empty path, malformed escapes, decoded `%` and `#`, unsupported types, directories, and inactive packages.
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) covers media types, the revision, emitted names, and references; [client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) keeps the type root equal to the table.
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) covers emitted names, resolver calls in scripts and stylesheet text, their CommonJS rendering in a complete build, preserved `url()` forms, each build rejection, the Node face guard, one-shot pruning, and watch rebuilds that keep earlier files; [loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) covers the default and carrier resolvers.
- The gate specs reject each invalid manifest or reference form, and [node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) covers declarations, moved exports, and added or removed client declarations after an entry restart, an unchanged restart that publishes nothing and reads no bundle, and a chunk of a package named `@scope/assets`.
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) and [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) assert the package-asset URLs and decode the images in a browser; [preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) decodes the ui-chat mask the WebWorker preview resolves to a Blob URL, and the runtime's [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) covers the archive and resolver.
@@ -0,0 +1,92 @@
# Agent Note: Client 插件包资源经 URL 提供
Status: implemented
[English](2026-10-19-client-plugin-package-assets.md) | 中文
## Problem
动态 Client 插件的浏览器半侧由 [client 打包预设](../../../../packages/client/tsdown.client.ts) 打成 lazy-CJS factory,经 [Client 模块系统](../../../../packages/client/modules/README.zh.md) 的 `/plugins` 路由加载。这条路由过去只提供 `client.js` 和包内分片,所以插件代码引用的图片只能以 base64 data URL 打进 bundle,样式里用到的图片则要靠包级构建插件替换成内联样式。
不管图片显不显示,这些字节都随 JavaScript 一起下载和解析,base64 还会让体积大三分之一:引导插图使 ui-settings-account 的 `client.js` 达到约 3.4 MB。ui-chat 还为一张遮罩图维护了自己的 [tsdown 插件](../../../../packages/client/ui-chat/tsdown.config.ts)。
## Decision
Client 插件包把原始图片放在 `src/client/assets/`。共享预设把每个被引用的图片产出到 `lib/assets/`,并把引用改成对 bundle 模块加载器的同步调用 `require.asset(<文档相对 URL>)`;包通过 `"exports": { "./assets/*": "./lib/assets/*" }` 和 `files` 里的 `lib/assets` 发布这些产物;client-modules 的 Host 在 `/plugins/<包名>/assets/<产出路径>` 提供它们。
### 引用与文件名
Client 代码用 import 引用图片(`import art from './assets/a.webp'`),包自己的样式表用相对 `url()` 引用,两者都编译成参数为同一个引用的 `require.asset(reference)` 调用;样式表变成字符串字面量与这些调用的拼接,在构建注入文本时执行。模块系统通过 `resolveAsset` 选项回答每次调用;除非载体提供了自己的实现,否则原样返回引用。产出文件名在扩展名前插入内容 revision,即文件字节 SHA-1 摘要的前 12 位十六进制,因此一个 URL 永远只对应一份内容:
```text
src/client/assets/[email protected]
lib/assets/[email protected]
plugins/@deepseek-ai/dsh-client-ui-chat/assets/running-whale%402x-06738fa29b2f.png
```
路径逐段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;包名原样输出。URL 和其他应用自有路由一样相对外壳文档,在任意挂载下、以及在注入的 `<style>` 文本中都能正确解析。组件不需要 hook、注入值或 service:URL 就是模块首次运行时算出的常量。
支持的扩展名是 `.svg`、`.png`、`.webp`、`.jpg` 和 `.jpeg`。client-modules 的 [`src/assets.ts`](../../../../packages/client/modules/src/assets.ts) 统一定义类型表、导出键与目标、revision、产出文件名和 URL,预设、路由和门禁都从这里读取。共享的 Client 类型根 [`scripts/types/client-assets`](../../../../scripts/types/client-assets/index.d.ts) 在 [`tsconfig.base.client.json`](../../../../tsconfig.base.client.json) 中登记,把每个扩展名声明为默认导出字符串的模块;有测试保证它与类型表一致。
### 构建
动态浏览器 face 的 `dsh-package-asset` 插件按源码位置解析 import。以下情况构建失败:文件真实路径不在本包 `src/client/assets/` 内(他包或依赖里的图片也因此被拒绝);`exports["./assets/*"]` 不是恰好 `./lib/assets/*`;扩展名不受支持;路径段为 `node_modules` 或含 Host 路由无法提供的 `%`、`#`、`?`、`\` 或 NUL;扩展名不是小写(导入类型只声明小写扩展名)。每个文件每次构建只产出一次,并登记为监听输入。三条 CSS 管线(CSS Modules、全局样式、`?inline`)对本包 `src/` 内的样式表,经 lightningcss visitor 改写相对 `url()`;`data:`、片段、带协议、以 `/` 开头和空的 URL 保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包的样式表不改写。lightningcss 不会在 `image-set()` 内部调用 `Url` visitor,因此由 `Image` visitor 按同样的规则改写这些选项。改写时先写入 lightningcss 从不转义的标识符占位,因此含括号或引号的文件名仍会变成对应的解析调用。双 face 包的 Node Client face 拒绝资源 import。浏览器 face 是 `lib/assets/` 的唯一写入方:一次性构建会删除本次没有产出的文件,watch 重建则保留它们,因为 Host 仍在提供上一代 bundle,运行它的页面会请求那些文件名。
### Host 路由
`bundleResource` 先于 bundle 表匹配 `/plugins/<包名>/assets/<路径>`,这类路径只由资源路由回答。对启用中的 client 行,路径解码一次后,`createRequire(<包的 manifest>).resolve('<包名>/assets/<路径>')` 套用包自己的 exports,未导出路径、`.` 和 `..` 段、不存在的文件和目录都解析不出来。媒体类型按解析出的文件扩展名判断,因为 Node 会把子路径中的 `#` 和 `?` 当作 URL 语法。任何解析失败、未启用的包和不支持的类型都返回 404;查询串不参与选择。
被提供的资源每次请求都从磁盘流式读取,Host 既不保存字节也不保存 hash;浏览器中途取消的请求直接结束,不记 warning。响应带媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、等于请求路径的 `etag`、`x-content-type-options: nosniff`;SVG 另带 `content-security-policy: script-src 'none'`。`If-None-Match` 等于该 tag 时返回 304,且不打开文件;`HEAD` 不带 body。`fetchBundle()` 返回同样的响应,body 为流,取消时关闭文件。
### WebWorker 预览
预览页的 Host 运行在 Worker 中,页面对引用发出的请求到达不了它。在任何 Client bundle 运行之前,页面向 Worker 请求 `GET /__assets__`,它和 `GET /__boot__` 一样由隧道胶水层回答:镜像中每个声明了 `"./assets/*": "./lib/assets/*"` 的包,其 `lib/assets/` 下的所有文件打成一个未压缩的 tar,条目以未编码的 `plugins/<包名>/assets/<路径>` 命名,基础镜像已满足同样的 ustar 名称限制。页面把每个条目名编码为引用,并按扩展名的媒体类型转成 Blob URL,它的 `resolveAsset` 返回这个 URL;镜像里没有对应文件时返回引用本身。基础镜像本来就带着这些文件,所以不增加网络请求;但每次启动仍会遍历镜像中的包、打包全部资源,并把每个条目复制成 Blob,不论包是否启用。
### 变化与包 manifest
图片重建后,重建的 `client.js` 带着新文件名,经现有的 bundle HMR 到达浏览器;资源没有单独的通道。client-modules 每次对账(即每次 Loader 条目构造和销毁)都读取包 manifest 和 bundle 元数据,不再把 manifest 缓存到重启,所以重装、升级或移动后重启的条目会发布当前的声明、client 导出和 manifest 路径;读到相同 manifest 和 bundle 元数据的对账不改动已发布的启动图,也不读取 bundle 字节。
### 门禁与迁移
[`check-workspace-constraints`](../../../../scripts/check-workspace-constraints.ts) 要求声明导出时 `files` 含 `lib/assets`。[`verify-client-packages`](../../../../scripts/verify-client-packages.ts) 要求导出目标精确匹配,只允许动态 Web Client 包声明该导出,并要求导出与包内引用(import,以及其 Client 源码所 import 的样式表中的 `url()`)双向一致。[`verify-package-dependencies`](../../../../scripts/verify-package-dependencies.ts) 把 `./assets/*` 视为非代码导出。ui-settings-account 和 ui-chat 已迁移到包资源,ui-chat 的 tsdown 插件已删除。
三方库自己加载的资源(PDF.js 的字体、cmap、wasm 和 worker)、插件元数据图标、详情文档图片,以及 static-linked 包,保留各自的交付方式。
## Alternatives considered
**保留 data URL。** 不需要路由,但字节随 JavaScript 一起传输和解析,每个在样式里用图片的包都要自写构建插件。
**不带运行时 helper 的纯 URL 字符串。** 普通 Web 应用不需要更多,但 WebWorker 预览页无法请求这个 URL,事后改写只能把文件复制到静态部署,或在 bundle 文本里查找字符串替换。静态复制是构建时的快照,绕过了 Host 的规则,也覆盖不到运行时安装的包;在 bundle 文本里找字符串是在猜代码。同步调用让构建产物保持显式,并由各个载体决定 URL。
**Client Cordis service,例如 `ctx.assets.url(path)`。** 当前使用方都引用固定文件;运行时查找也拿不到内容 revision,除非另外发布每个包的资源清单。
**原始文件直接放在包根 `assets/`,不经构建。** 作者无法做预处理,命名和格式转换只能手工维护。
**revision 放在 `?rev=` 查询串,由 Host 校验。** Host 必须读完整个文件才能决定回 200 还是 404,首次请求要读两遍,还要按文件元数据缓存 hash;revision 写进文件名后,Host 只需解析路径并流式发送。
**用 `client.js` 的 revision 作为资源 revision。** 这个 revision 在构建写出 bundle 后才由文件元数据得出,来不及写进 bundle。
**CSS 只能经 JavaScript style 对象引用图片。** 遮罩、背景和 `@supports` 降级会被挪进组件,而 URL 在构建时就能确定,同一套规则也能改写 `url()`。
**与详情文档共用一套插件文件协议。** 详情图片要从本地、npm 或 Git 读取任意包版本,会触发 Host 网络访问因而需要鉴权,缓存方式也随来源而定。合并会把鉴权、来源提供方和缓存策略搬进 client-modules;在出现需要读取其他包文件的新使用方之前,Plugin Manager 和 Plugin Market 的详情路由保持独立。
**从 Plugin Manager 获取 manifest,或给 manifest 缓存加变化检查。** Plugin Manager 只列出 bundle,不覆盖所有 client 行,而且从 profile 安装目录解析,而不是用运行中条目的 Loader 解析。问题出在时机:缓存活得比条目重启更久。每次对账重新读取并跳过未变化的结果,是删掉缓存,而不是给缓存加校验。
## Consequences
- ui-settings-account 的 `client.js` 为 214 KB,账户和 ui-chat 的 bundle 都不再含图片 data URL;图片显示时多一次同源请求,可能晚于布局出现。
- `require.asset` 和 `require.async` 一样属于 bundle factory 契约;仓库外构建的包必须输出这个调用。WebWorker 预览在启动时为镜像里所有包资源创建 Blob URL,不论包是否启用,并在页面生命周期内保留。
- Desktop 转发层把 `/plugins/` 的缓存头改写为 `no-store`,Desktop 每次显示都会重新读取资源。
- Host 不比对文件内容与文件名中的 revision:原地修改 `lib/assets/` 下的文件会保留它的 immutable 缓存。这个目录只由构建写入。
- `require.resolve` 在每次请求时同步执行;本机实测文件存在约 43 µs,不存在约 170 µs。它经 Node 的包配置缓存读取 exports,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包失效或 Host 重启后才生效。路由不检查解析结果的真实路径,所以 `lib/assets/` 下指向包外的符号链接会被提供。
- 仓库外的包必须复刻产出文件名规则和 `require.asset` 调用;Client 模块系统 README 记录了它们。
- 开发期间 `lib/assets/` 会留下旧的资源文件,直到下一次一次性构建把它们清掉;这种清理的前提是浏览器 face 是该目录的唯一写入方。
- 条目重启时会读取它的 manifest 并 stat 它的 bundle;只有结果变化时才读取 bundle 并广播启动图帧(69 行时约 30 KB)。只改 `package.json` 而条目没有重启时,仍要到下次条目重启或 Host 重启才生效。
## Testing
- [package-assets.client.spec.ts](../../../../packages/client/modules/tests/package-assets.client.spec.ts) 组合真实 Web server,并将每个响应与 `fetchBundle()` 比对:包括 SVG 策略在内的响应头、不打开文件的 304、`HEAD`、405、多分片流式发送、取消时关闭流、浏览器取消请求时不记日志,以及以下情况的 404:被排除的子路径、缺少导出或文件、`..`、`%2e%2e`、编码后的 `..`、空路径、错误的转义、解码后的 `%` 和 `#`、不支持的类型、目录和未启用的包。
- [assets.client.spec.ts](../../../../packages/client/modules/tests/assets.client.spec.ts) 覆盖媒体类型、revision、产出文件名和引用;[client-asset-types.spec.ts](../../../../scripts/client-asset-types.spec.ts) 保证类型根与类型表一致。
- [client-bundle-css.spec.ts](../../../../scripts/client-bundle-css.spec.ts) 覆盖产出文件名、脚本和样式表文本中的解析调用、完整构建中它们的 CommonJS 渲染、保留的 `url()` 形式、每种构建拒绝、Node face 守卫、一次性构建的清理,以及 watch 重建保留旧文件;[loader.client.spec.ts](../../../../packages/client/modules/tests/loader.client.spec.ts) 覆盖默认解析和载体解析。
- 各门禁的 spec 拒绝每种无效的 manifest 或引用形式;[node-half.client.spec.ts](../../../../packages/client/modules/tests/node-half.client.spec.ts) 覆盖条目重启后的声明更新、导出移动、client 声明的增减、未变化的重启既不发布也不读取 bundle,以及名为 `@scope/assets` 的包的分片。
- [desktop-onboarding.e2e.ts](../../../../apps/web/tests/desktop-onboarding.e2e.ts) 和 [reasoning-preview.e2e.ts](../../../../apps/web/tests/reasoning-preview.e2e.ts) 断言包资源 URL,并在浏览器中解码这些图片;[preview-boot.e2e.ts](../../../../apps/web/tests/preview-boot.e2e.ts) 解码 WebWorker 预览解析为 Blob URL 的 ui-chat 遮罩图,运行时的 [package-assets.spec.ts](../../../../packages/experimental/webworker-runtime/tests/package-assets.spec.ts) 覆盖归档和解析。
+3
View File
@@ -40,6 +40,8 @@ flowchart LR
cfg --> plugin_dsh_base_user_questions
plugin_dsh_base_agent["agent<br/>@deepseek-ai/dsh-agent"]
cfg --> plugin_dsh_base_agent
plugin_dsh_base_agent_controller["agent-controller<br/>@deepseek-ai/dsh-agent-controller"]
cfg --> plugin_dsh_base_agent_controller
plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek<br/>@deepseek-ai/dsh-plugin-package-inventory-deepseek"]
cfg --> plugin_dsh_base_plugin_package_inventory_deepseek
plugin_dsh_base_agent_default_model["agent-default-model<br/>@deepseek-ai/dsh-agent-default-model"]
@@ -218,6 +220,7 @@ flowchart LR
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` |
| `user-questions` | `@deepseek-ai/dsh-user-questions` |
| `agent` | `@deepseek-ai/dsh-agent` |
| `agent-controller` | `@deepseek-ai/dsh-agent-controller` |
| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` |
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
| `jobs` | `@deepseek-ai/dsh-jobs-local` |
@@ -0,0 +1,367 @@
/**
* Scroll continuity when "On next message" folds a process group the reader opened. The latest Turn has
* one Step; the reader opened its group and expanded the reasoning inside, so the group body is capped and
* scrollable, then sends the next message from the floor. A held prompt keeps the new Turn from starting
* until the fold settles, so only the fold moves the view while the scrollport is sampled on every
* animation frame.
*
* - A long answer leaves the folded group above the viewport. The scroll position changes in one direction
* only: the fold never scrolls the view away and then snaps it back.
* - A short answer leaves fold room below the tail. Growth fills that room without scrolling, so the running
* status keeps its entrance slide when the new Step's first row appears instead of moving in one frame.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Browser, Page, Route } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { launchWebScaffold, parseSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { newEnglishPage, openSettings } from './support.ts'
declare global {
interface Window {
openFoldTrace?: { readonly frames: TraceFrame[]; readonly slides: number[]; stop(): void }
}
}
const MODE = webSnapshotMode()
const ROOT = '[data-conversation-scroll]:not([data-sidebar-chat] [data-conversation-scroll])'
/** Settled Turns before the latest one, so the transcript overflows the scrollport. */
const TURNS = 7
const NEXT_INPUT = 'OPENFOLD_NEXT_INPUT'
const REPLY = 'OPENFOLD_REPLY'
/** One painted frame of the main scrollport. */
interface TraceFrame {
readonly top: number
readonly floor: number
/** Height of the fold room below the transcript. */
readonly spacer: number
/** The column marks the tail as pinned, which runs the status slide at zero duration. */
readonly pinned: boolean
/** The running status's top relative to the scrollport, when it is mounted. */
readonly status: number | null
readonly folding: boolean
}
function paragraphs(marker: string, count: number, text: string): string {
return `${marker}\n\n${Array.from({ length: count }, (_, line) => `${text} ${line}.`).join('\n\n')}`
}
/** Settled history whose latest Turn is one Step: long reasoning, then an answer of `answerParagraphs`. */
function seededLog(id: string, answerParagraphs: number): { log: string; search: string } {
const base = createChatScrollFixture({ markerPrefix: `OPENFOLD${id.toUpperCase()}`, title: `OPENFOLD ${id}`, turns: TURNS })
const parsed = parseSeedFixture(base.log)
const boundary = parsed.events.findIndex(event => event.type === 'turn/start' && event.data.turn === TURNS)
const drafts: (SessionEvent | { type: string; data: object; surfaceOp?: 'append' })[] = parsed.events.slice(0, boundary)
drafts.push(
{ type: 'turn/start', data: { turn: TURNS } },
{ type: 'step/start', data: { turn: TURNS, step: 1 } },
{ type: 'user/message', data: createUserMessage({
content: [{ type: 'text', text: 'OPENFOLD_LAST_USER' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'assistant/message', surfaceOp: 'append', data: {
turn: TURNS, step: 1, stream: [], message: createAssistantMessage({
content: [
{ type: 'reasoning', text: paragraphs('OPENFOLD_REASONING', 30, 'Reasoning weighs one more consideration, paragraph') },
{ type: 'text', text: paragraphs('OPENFOLD_ANSWER', answerParagraphs, 'Final answer paragraph of the single Step') },
],
source: { provider: 'fixture', model: 'fixture' },
}),
} },
{ type: 'step/end', data: { turn: TURNS, step: 1 } },
{ type: 'turn/end', data: { turn: TURNS, reason: { kind: 'completed' } } },
)
return {
log: [parsed.headerLine, ...drafts.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 1 })), ''].join('\n'),
search: base.markers.user(1),
}
}
function reply(): ReplayEntry {
const text = `${REPLY} short answer.`
const finish: StreamChunk[] = [
{ type: 'usage', usage: { inputTokens: 512, outputTokens: 64 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
return { kind: 'chunks', chunks: [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
...finish,
] }
}
async function stableScroll(page: Page): Promise<void> {
await page.evaluate(async (selector) => {
const host = document.querySelector<HTMLElement>(selector)!
let previous = host.scrollTop
const started = performance.now()
let stableSince = started
await new Promise<void>((resolveStable, reject) => {
const check = (): void => {
const now = performance.now()
const moving = host.querySelector('[data-chat-motion="collapse"], [data-chat-motion="reveal"]') !== null
if (Math.abs(host.scrollTop - previous) > 0.25 || moving) stableSince = now
previous = host.scrollTop
if (now - stableSince >= 300) resolveStable()
else if (now - started >= 20_000) reject(new Error(`scroll did not settle: top ${String(host.scrollTop)}`))
else requestAnimationFrame(check)
}
requestAnimationFrame(check)
})
}, ROOT)
}
async function toBottom(page: Page): Promise<void> {
const button = page.locator(ROOT).getByRole('button', { name: 'Back to bottom', exact: true })
if (await button.count() > 0) await button.click()
await stableScroll(page)
await expect.poll(() => page.locator(`${ROOT} [data-chat-following-tail]`).count()).toBe(1)
}
async function openSessionBySearch(page: Page, query: string): Promise<void> {
await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(query)
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
await results.click()
}
async function choosePreferences(page: Page): Promise<void> {
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
const timing = dialog.getByText('When to Collapse Work Details', { exact: true }).locator('../..')
await timing.getByRole('button').click()
await page.getByRole('menuitem', { name: 'On next message', exact: true }).click()
const details = dialog.getByText('Work details', { exact: true }).locator('../..')
await details.getByRole('button').click()
await page.getByRole('menuitem', { name: 'Standard', exact: true }).click()
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
}
/** Open the latest Turn's process group and expand its reasoning until the group body caps its height. */
async function openLatestGroup(page: Page): Promise<void> {
const group = page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS)}"]`).last()
const header = group.locator('button[aria-expanded]').first()
if (await header.getAttribute('aria-expanded') !== 'true') await header.click()
const thought = group.locator('[data-variant="think"]').getByRole('button').first()
if (await thought.getAttribute('aria-expanded') !== 'true') await thought.click()
const body = group.locator('[data-step-process-body]')
await expect.poll(() => body.evaluate(element => element.scrollHeight > element.clientHeight + 100)).toBe(true)
// Focus left inside a folding row keeps that row open; a reader who moved on has focused nothing there.
await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) document.activeElement.blur() })
await page.mouse.move(0, 0)
}
/**
* Sample the scrollport on every animation frame, and record the duration of every status entrance slide
* that a flow change starts.
*/
async function startTrace(page: Page): Promise<void> {
await page.evaluate((selector) => {
const host = document.querySelector<HTMLElement>(selector)!
const column = host.querySelector<HTMLElement>('[data-chat-flow]')!
const spacer = host.querySelector<HTMLElement>('[data-chat-turn-spacer]')!
const frames: TraceFrame[] = []
const slides: number[] = []
let frame = 0
const tick = (): void => {
const status = host.querySelector<HTMLElement>('[data-chat-running]')
frames.push({
top: host.scrollTop,
floor: Math.max(0, host.scrollHeight - host.clientHeight),
spacer: spacer.getBoundingClientRect().height,
pinned: column.hasAttribute('data-chat-tail-pinned'),
status: status === null ? null : status.getBoundingClientRect().top - host.getBoundingClientRect().top,
folding: column.hasAttribute('data-input-fold') || host.querySelector('[data-chat-motion="collapse"]') !== null,
})
frame = requestAnimationFrame(tick)
}
// The slide starts at the style update after the flow change; reading animations there sees its duration.
const observer = new MutationObserver(() => {
const status = host.querySelector<HTMLElement>('[data-chat-running]')
for (const animation of status?.getAnimations() ?? []) {
if (animation instanceof CSSAnimation && animation.animationName.includes('running-follows-output')) {
slides.push(Number(animation.effect?.getComputedTiming().duration ?? 0))
}
}
})
observer.observe(column, { childList: true, subtree: true })
window.openFoldTrace = { frames, slides, stop: () => { cancelAnimationFrame(frame); observer.disconnect() } }
frame = requestAnimationFrame(tick)
}, ROOT)
}
async function readTrace(page: Page): Promise<{ frames: TraceFrame[]; slides: number[] }> {
return page.evaluate(() => {
const trace = window.openFoldTrace!
return { frames: [...trace.frames], slides: [...trace.slides] }
})
}
/** Per-frame scroll movements larger than subpixel rounding. */
function movements(frames: readonly TraceFrame[]): number[] {
const steps: number[] = []
for (let index = 1; index < frames.length; index++) {
const step = frames[index]!.top - frames[index - 1]!.top
if (Math.abs(step) > 1) steps.push(Math.round(step))
}
return steps
}
interface World {
readonly scaffold: WebScaffold
readonly page: Page
readonly session: SessionId
readonly tripwire: ReturnType<typeof watchConsole>
/** Releases the held prompt; the new Turn starts after this. */
readonly releasePrompt: () => void
/** Releases the held model stream; the new Step's output arrives after this. */
readonly releaseModel: () => void
readonly ended: Promise<void>
readonly close: () => Promise<void>
}
/**
* Seed and open the case, open the latest group, and send the next message from the floor with the prompt
* and the model stream held.
*/
async function sendFromOpenedGroup(browser: Browser, id: string, answerParagraphs: number): Promise<World> {
const replayDir = await mkdtemp(join(tmpdir(), 'dsh-open-fold-'))
await writeFile(join(replayDir, 'replay.override.json'), JSON.stringify([reply()]))
const scaffold = await launchWebScaffold({
replayFixture: join(replayDir, 'override-only.jsonl'),
replayOverride: join(replayDir, 'replay.override.json'),
paceMs: 16,
replayContextWindow: 10_000_000,
})
const model = Promise.withResolvers<undefined>()
const disposeHold = scaffold.ctx.on('llm/stream', async function* (_options, next) {
await model.promise
for await (const chunk of next()) yield chunk
})
const prompt = Promise.withResolvers<undefined>()
const holdPrompt = async (route: Route): Promise<void> => {
await prompt.promise
await route.continue()
}
const page = await newEnglishPage(browser, 826)
const close = async (): Promise<void> => {
prompt.resolve(undefined)
model.resolve(undefined)
disposeHold()
const failures: unknown[] = []
await page.context().close().catch((error: unknown) => failures.push(error))
await scaffold.close().catch((error: unknown) => failures.push(error))
await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, `${id}: cleanup failed`)
}
try {
const tripwire = watchConsole(page)
await page.setViewportSize({ width: 1170, height: 826 })
await page.emulateMedia({ reducedMotion: 'no-preference' })
const session = SessionId(`open-fold-${id}`)
const { log, search } = seededLog(id, answerParagraphs)
await seedSession(scaffold, log, session)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await openSessionBySearch(page, search)
await page.locator(ROOT).getByText('OPENFOLD_ANSWER', { exact: false }).last().waitFor({ timeout: 30_000 })
await choosePreferences(page)
await toBottom(page)
await openLatestGroup(page)
await toBottom(page)
await page.route('**/api/session/prompt', holdPrompt)
const ended = new Promise<void>((resolveEnded, reject) => {
const timer = setTimeout(() => { off(); reject(new Error(`${id}: no turn/end within 60000ms`)) }, 60_000)
const off = scaffold.ctx.on('session/event', (owner: { id: SessionId }, event: SessionEvent) => {
if (owner.id !== session || event.type !== 'turn/end') return
clearTimeout(timer)
off()
resolveEnded()
})
})
// A failure before the release awaits nothing; the success path awaits and rethrows this rejection.
ended.catch(() => undefined)
await startTrace(page)
await page.locator(`${ROOT} [data-composer-input][contenteditable="true"]`).last().fill(NEXT_INPUT)
await page.locator(ROOT).getByRole('button', { name: 'Send message', exact: true }).click()
await page.locator(`${ROOT} [data-submission-echo]`).waitFor()
await stableScroll(page)
return {
scaffold, page, session, tripwire, ended, close,
releasePrompt: () => { prompt.resolve(undefined) },
releaseModel: () => { model.resolve(undefined) },
}
} catch (error) {
await close().catch(() => undefined)
throw error
}
}
describe.skipIf(MODE === 'record')('web e2e: next-message fold of an opened process group', () => {
let browser: Browser
beforeAll(async () => {
browser = await chromium.launch()
})
afterAll(async () => {
await browser?.close()
})
it('folds the opened group above a long answer without scrolling away and back', async () => {
const world = await sendFromOpenedGroup(browser, 'long', 30)
try {
const { frames } = await readTrace(world.page)
const steps = movements(frames)
const reversals = steps.filter((step, index) => index > 0 && Math.sign(step) !== Math.sign(steps[index - 1]!)).length
// Soft assertions let the held Turn finish, so a failure reports the curve instead of replay leftovers.
expect.soft(frames.some(frame => frame.folding), 'the message folds the opened group').toBe(true)
expect.soft(reversals, `scroll direction changes; per-frame movements ${JSON.stringify(steps)}`).toBe(0)
const settled = frames.at(-1)!
expect.soft(settled.floor - settled.top, 'distance from the floor after the fold').toBeLessThanOrEqual(1)
world.releasePrompt()
world.releaseModel()
await world.ended
expect(world.tripwire.pageErrors).toEqual([])
} finally {
await world.close()
}
})
it('keeps the running status slide while fold room below a short answer takes the new Step', async () => {
const world = await sendFromOpenedGroup(browser, 'short', 2)
try {
const folded = (await readTrace(world.page)).frames.at(-1)!
expect.soft(folded.spacer, 'fold room below the short answer').toBeGreaterThan(0)
expect.soft(folded.pinned, 'tail pin while fold room remains').toBe(false)
world.releasePrompt()
// The running Step's group row is the first row after the user message; output stays held until then.
await world.page.locator(`${ROOT} [data-chat-group-key][data-chat-turn="${String(TURNS + 1)}"]`).waitFor({ timeout: 30_000 })
await world.page.locator(`${ROOT} [data-chat-running]`).waitFor()
await world.page.waitForTimeout(300)
const { frames, slides } = await readTrace(world.page)
const statusSteps = movements(frames.map(frame => ({ ...frame, top: frame.status ?? Number.NaN })))
.filter(step => Number.isFinite(step))
// The fold runs status moves at zero duration on its own, so only settled frames show the pin's effect.
const pinnedOverRoom = frames.filter(frame => !frame.folding && frame.pinned && frame.spacer > 0)
expect.soft(pinnedOverRoom, 'settled frames pinned over fold room').toEqual([])
expect.soft(slides.some(duration => duration > 0), `status slide durations ${JSON.stringify(slides)}; status movements ${JSON.stringify(statusSteps)}`).toBe(true)
world.releaseModel()
await world.ended
expect(world.tripwire.pageErrors).toEqual([])
} finally {
await world.close()
}
})
})
+125 -3
View File
@@ -44,9 +44,54 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await row.waitFor({ timeout: 20_000 })
const trigger = row.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const trailing = row.locator('[data-session-trailing]')
const observations: { stage: string; icons: string[]; time: boolean; pinned: boolean; actions: boolean }[] = []
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row): Promise<void> => {
const icons = target.locator('[data-session-trailing]')
const actions = target.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const time = target.locator('[class*="time"]')
await expect.poll(() => icons.first().isVisible()).toBe(iconsVisible)
await expect.poll(() => time.isVisible()).toBe(iconsVisible)
await expect.poll(() => actions.isVisible()).toBe(actionsVisible)
observations.push({
stage,
icons: await icons.filter({ visible: true }).evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')!)),
time: await time.isVisible(),
pinned: await target.getByRole('img', { name: 'Pinned', exact: true }).isVisible(),
actions: await actions.isVisible(),
})
}
await expect.poll(() => trailing.count()).toBe(2)
expect(await trailing.evaluateAll(elements => elements.map(element => element.getAttribute('data-session-trailing'))))
.toEqual([sessionId, sessionId])
await page.mouse.move(0, 0)
await observe('resting', true, false)
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(true)
await observe('dark', true, false)
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(false)
const titleWidth = await row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width)
await row.hover()
await trigger.focus()
await observe('hover', false, true)
await page.mouse.move(0, 0)
await observe('pointer-left', true, false)
await row.click()
await expect.poll(() => page.locator('[data-composer-input][contenteditable="true"]')
.evaluate(element => element === document.activeElement)).toBe(true)
await row.click()
await page.mouse.move(0, 0)
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-current-row', true, false)
expect(await row.locator('[data-slot="sidebar.session.row.leading"]').evaluate(element => element.tagName)).toBe('SPAN')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('focus', false, true)
await page.keyboard.press('Tab')
expect(await row.evaluate(element => element.querySelector('button') === document.activeElement)).toBe(true)
await trigger.click()
await observe('menu-open', false, true)
const menu = page.getByRole('menu')
await menu.waitFor()
expect(await menu.getByRole('separator').count()).toBe(1)
@@ -56,7 +101,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
webSnapshotMode(),
)
await menu.getByRole('menuitem', { name: 'Export session' }).click()
await menu.getByRole('menuitem', { name: 'Export session' }).press('Enter')
await expect.poll(() => menu.count()).toBe(0)
expect(await page.evaluate(() => ({
action: document.documentElement.dataset.sessionAction,
@@ -64,10 +109,87 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
title: document.documentElement.dataset.sessionActionTitle,
}))).toEqual({ action: 'fixture.export-session', id: sessionId, title: SESSION_TITLE })
await page.getByRole('button', { name: 'Account menu', exact: true }).focus()
await page.mouse.move(0, 0)
await observe('blur', true, false)
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await page.mouse.move(0, 0)
await observe('narrow', true, false)
const bounds = await row.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const icons = [...element.querySelectorAll('[data-session-trailing]')].map(icon => icon.getBoundingClientRect())
return {
titleRight: title.right, firstIconLeft: icons[0]!.left, lastIconRight: icons.at(-1)!.right,
rowRight: element.getBoundingClientRect().right,
}
})
expect(bounds.titleRight).toBeLessThanOrEqual(bounds.firstIconLeft)
expect(bounds.lastIconRight).toBeLessThanOrEqual(bounds.rowRight)
await page.setViewportSize({ width: 1680, height: 1000 })
const touchPage = await browser.newPage({
viewport: { width: 390, height: 844 }, locale: 'en-US', isMobile: true, hasTouch: true,
})
try {
const touchConsole = watchConsole(touchPage)
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
const touchRow = touchPage.getByRole('treeitem').filter({ has: touchPage.getByText(SESSION_TITLE, { exact: true }) })
const touchTrigger = touchRow.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
await touchTrigger.waitFor()
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
await observe('touch', true, true, touchRow)
expect(await touchRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false)
expect(await touchRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false)
await touchTrigger.tap()
await touchPage.getByRole('menu').waitFor()
await observe('touch-menu-open', true, true, touchRow)
await touchPage.keyboard.press('Escape')
await observe('touch-menu-closed', true, true, touchRow)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
await touchRow.getByRole('img', { name: 'Pinned', exact: true }).waitFor()
await observe('touch-pinned', true, true, touchRow)
const touchBounds = await touchRow.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const resting = element.querySelector('[class*="rowResting"]')!.getBoundingClientRect()
const actions = element.querySelector('[class*="rowActions"]')!.getBoundingClientRect()
return { titleRight: title.right, restingLeft: resting.left, restingRight: resting.right,
actionsLeft: actions.left, actionsRight: actions.right, rowRight: element.getBoundingClientRect().right }
})
expect(touchBounds.titleRight).toBeLessThanOrEqual(touchBounds.restingLeft)
expect(touchBounds.restingRight).toBeLessThanOrEqual(touchBounds.actionsLeft)
expect(touchBounds.actionsRight).toBeLessThanOrEqual(touchBounds.rowRight)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Archive session', exact: true }).tap()
await expect.poll(() => touchRow.count()).toBe(0)
expect(scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)).toBe(true)
await page.getByRole('button', { name: 'View options', exact: true }).click()
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
await row.click()
await page.getByRole('alert').filter({ hasText: 'Archived sessions cannot be opened' }).waitFor()
await page.mouse.move(0, 0)
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-archived-row', true, false)
await scaffold.ctx.workspaceRegistry.unarchiveSession(sessionId)
await observe('touch-unarchived', true, true, touchRow)
expect(touchConsole.pageErrors).toEqual([])
} finally {
await touchPage.close()
}
scaffold.ctx.loader.remove(entryId)
await expect.poll(() => page.evaluate(
() => document.documentElement.dataset.liveDisposals,
)).toBe('1')
await expect.poll(() => trailing.count()).toBe(0)
await expect.poll(() => row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width))
.toBeGreaterThan(titleWidth)
await compareOrRefreshGolden(
join(EXPECTED, 'session-trailing.expected.json'),
JSON.stringify(observations, null, 2),
webSnapshotMode(),
)
await row.hover()
await trigger.click()
await menu.waitFor()
@@ -86,7 +208,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
expect(console.pageErrors).toEqual([])
await assertFixtureInventory(EXPECTED, [
'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md',
'session-actions.expected.md',
'session-actions.expected.md', 'session-trailing.expected.json',
])
} finally {
await browser.close()
+15 -10
View File
@@ -19,6 +19,7 @@ const MODE = webSnapshotMode()
const EXPECTED = fileURLToPath(new URL('./expected/desktop-onboarding', import.meta.url))
const NS = 'ui-settings-account'
const INITIAL = { version: 1, step: 'welcome', purpose: null, process: null, completion: null, usage: 'compact', developerTools: false }
const ACCOUNT_ASSET = /\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-[a-z-]+-[0-9a-f]{12}\.(?:svg|webp)$/
describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () => {
let root: string
@@ -124,15 +125,18 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
async function snapshot(name: string): Promise<void> {
await expect.poll(() => page.locator('[data-desktop-onboarding]').getAttribute('aria-busy')).toBe('false')
const hasIllustration = name.startsWith('welcome') || name.startsWith('credit')
if (hasIllustration) {
const decoded = await page.locator('[class*="illustration"] > img').evaluateAll(async elements =>
Promise.all(elements.map(async (element) => {
const image = element as HTMLImageElement
await image.decode()
return image.naturalWidth > 0 && image.naturalHeight > 0
})))
expect(decoded).toEqual([true, true])
}
// Illustrations and card icons are package assets fetched after the step renders.
const images = await page.locator('[data-desktop-onboarding] img').evaluateAll(async elements =>
Promise.all(elements.map(async (element) => {
const image = element as HTMLImageElement
await image.decode()
return {
source: image.currentSrc, illustration: image.matches('[class*="illustration"] > img'),
loaded: image.naturalWidth > 0 && image.naturalHeight > 0,
}
})))
expect(images.filter(image => !image.loaded || !ACCOUNT_ASSET.test(image.source)), `${name}: images outside loaded package assets`).toEqual([])
expect(images.filter(image => image.illustration), `${name}: illustration images`).toHaveLength(hasIllustration ? 2 : 0)
const aria = await captureStableAria(page, '[data-desktop-onboarding]', scaffold.workspaceCwd)
await mkdir(EXPECTED, { recursive: true })
await compareOrRefreshGolden(join(EXPECTED, `${name}.expected.md`), aria, MODE)
@@ -424,7 +428,8 @@ describe.skipIf(MODE === 'record')('web e2e: App-only desktop onboarding', () =>
expect(await page.getByRole('button', { name: 'Next', exact: true })
.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(72, 73, 76)')
await snapshot('credit-en-dark')
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage)).toContain('data:image/svg+xml')
expect(await page.locator('[class*="backIcon"]').evaluate(element => getComputedStyle(element).maskImage))
.toMatch(/^url\("[^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-settings-account\/assets\/onboarding-back-[0-9a-f]{12}\.svg"\)$/)
await page.getByRole('button', { name: 'Next', exact: true }).click()
await page.getByRole('button', { name: 'Got it', exact: true }).click()
await page.getByRole('checkbox', { name: 'Coding & development', exact: true }).locator('..').click()
@@ -0,0 +1,143 @@
[
{
"stage": "resting",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "dark",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "hover",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "pointer-left",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "clicked-current-row",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "focus",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "menu-open",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "blur",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "narrow",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "touch",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-menu-open",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-menu-closed",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-pinned",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": true,
"actions": true
},
{
"stage": "clicked-archived-row",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "touch-unarchived",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
}
]
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
id: '@fixture/live-client',
factory(require) {
const React = require('react')
const { MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
const { IconFolderCloseRegular, IconNewChatOutlineRegular, MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
const style = document.createElement('style')
style.dataset.plugin = '@fixture/live-client'
style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }'
@@ -88,6 +88,18 @@ window.__ModuleLoader__.load({
t(label),
)
})
ctx.slots.inject('sidebar.session.row.trailing', function* () {
for (const [id, order, Icon, label] of [
['fixture.trailing-new-chat', 200, IconNewChatOutlineRegular, 'section'],
['fixture.trailing-folder', 100, IconFolderCloseRegular, 'badge'],
]) {
yield ctx.slots.register({
name: 'sidebar.session.row.trailing', id, order, locale: 'fixtureLive',
}, ({ sessionId, t }) => React.createElement('span', {
role: 'img', 'aria-label': t(label), 'data-session-trailing': sessionId,
}, React.createElement(Icon)))
}
})
ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () {
// The shipped rows end at Session ID (450); these follow as one group,
// opened by the export row's hairline.
+15
View File
@@ -611,6 +611,21 @@ async function toggleOptionalBundles(page: Page): Promise<void> {
await cards.first().waitFor({ timeout: 30_000 })
const listed = await cards.evaluateAll(nodes => nodes.map(node => node.getAttribute('data-plugin-package')))
expect(listed.filter(name => name !== null && OPTIONAL_BUNDLES.includes(name)).length).toBeGreaterThan(0)
// Package assets resolve from the Worker image: ui-chat's injected mask is a Blob URL that decodes.
const mask = await page.evaluate(async () => {
const styles = [...document.querySelectorAll('style[data-plugin="@deepseek-ai/dsh-client-ui-chat"]')]
.map(style => style.textContent).join('\n')
const url = /url\((blob:[^)]+)\)/u.exec(styles)?.[1]
const unresolved = styles.includes('plugins/@deepseek-ai/dsh-client-ui-chat/assets/')
if (url === undefined) return { url: null, width: 0, unresolved }
const image = new Image()
image.src = url
await image.decode()
return { url, width: image.naturalWidth, unresolved }
})
expect(mask.url).toMatch(/^blob:/u)
expect(mask.unresolved).toBe(false)
expect(mask.width).toBeGreaterThan(0)
const result = await page.evaluate(async () => {
type Result<T> = { result: { ok: true; value: T } | { ok: false; error: { message: string } } }
const transport = (globalThis as typeof globalThis & {
+6 -4
View File
@@ -12,6 +12,8 @@ import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeCo
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
const UI_EXPECTED = fileURLToPath(new URL('./expected/reasoning-preview/running.expected.md', import.meta.url))
/** Computed mask of the animated whale: the ui-chat package asset URL, whose `@` is percent-encoded. */
const WHALE_MASK = /^url\("([^"]*\/plugins\/@deepseek-ai\/dsh-client-ui-chat\/assets\/running-whale%402x-[0-9a-f]{12}\.png)"\)$/
class PausedReasoningAdapter extends LlmAdapter {
override async listModels(provider: string) { return [{ provider, id: 'paused', name: `${provider}/paused` }] }
@@ -96,14 +98,14 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' })
await expect.poll(() => animatedWhale.isVisible()).toBe(true)
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha')
const imageSize = await animatedWhale.evaluate(async (element) => {
const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1]
if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL')
const imageSize = await animatedWhale.evaluate(async (element, mask) => {
const source = getComputedStyle(element).maskImage.match(new RegExp(mask))?.[1]
if (source === undefined) throw new Error('Running whale mask must use the ui-chat package asset URL')
const image = new Image()
image.src = source
await image.decode()
return [image.naturalWidth, image.naturalHeight]
})
}, WHALE_MASK.source)
expect(imageSize).toEqual([28, 28])
expect(await restingWhale.isVisible()).toBe(false)
const firstWhaleFrame = await whale.screenshot({ animations: 'allow' })
+1
View File
@@ -26,6 +26,7 @@
"tests/collapse-timing.e2e.ts",
"tests/notice-wake-tail.e2e.ts",
"tests/fold-blank-matrix.e2e.ts",
"tests/chat-fold-opened-group.e2e.ts",
"tests/chat-flow-layout.e2e.ts",
"tests/layout-bottom.e2e.ts",
"tests/shortcuts-desktop.e2e.ts",
+4 -4
View File
@@ -18,11 +18,11 @@
en: b9eeb18645121a8b
zh: 70d87a12c43e042a
/deepseek-harness-architecture/core-packages:
en: 20ef882e44b06a10
zh: c10c3934ed5570e9
en: f4dde8809a713bad
zh: ccbee814b4e8b4b7
/deepseek-harness-architecture/events:
en: e89802f066e4955d
zh: ea2fb4bef94e086d
en: c00467ff3a210ef5
zh: 7619d7cca723fa6b
/deepseek-harness-architecture/turn-flow:
en: e6402b28c0ed15fa
zh: 6c07be9047b42056
+2
View File
@@ -61,6 +61,7 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes
| [`core/tools`](subsystems/tools.md) | The scoped tool registry and guarded execution pipeline | `ctx.tools` |
| [`core/agent`](subsystems/core.md) | The `Agent` interface, live registry, and `agent/*` events | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.md) | The default driver implementing that interface | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.md#messages-from-outside-an-agent) | Admission and delivery of outside messages | `ctx.agentController` |
| [`core/scope`](subsystems/scope.md) | The per-agent scoped-registration primitive | library, no key |
| [`llm/llm`](subsystems/llm-streaming.md) | Message and stream vocabulary plus the adapter seam | `ctx.llm` |
@@ -70,6 +71,7 @@ Events are the extension points, and picking the right domain is the first decis
- **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload.
- **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight.
- **Delivery admission** (`agent-controller/admission`) holds or refuses outside input before it enters the inbox.
- **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop.
AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering.
+2
View File
@@ -61,6 +61,7 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
| [`core/tools`](subsystems/tools.zh.md) | 作用域化的工具注册表和带把关的执行流水线 | `ctx.tools` |
| [`core/agent`](subsystems/core.zh.md) | `Agent` 接口、活跃 agent 注册表和 `agent/*` 事件 | `ctx.agents` |
| [`core/agent-loop`](subsystems/core.zh.md) | 实现该接口的默认驱动器 | `ctx.agentLoop` |
| [`core/agent-controller`](subsystems/core.zh.md#messages-from-outside-an-agent) | 外部消息的准入与投递 | `ctx.agentController` |
| [`core/scope`](subsystems/scope.zh.md) | 按 agent 划分作用域的注册原语 | 库,无 ctx 键 |
| [`llm/llm`](subsystems/llm-streaming.zh.md) | 消息与流式词汇表,以及适配器 seam | `ctx.llm` |
@@ -72,6 +73,7 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共
- **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。
- **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
- **投递准入**(`agent-controller/admission`)在外部输入进入收件箱之前暂留或拒绝它。
- **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。
AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: 6b8ab0b2ec113ca9
zh: 62c4fc0f3e3a6ed4
en: 848af277c4d4552d
zh: 1feb3248baa7b686
+5
View File
@@ -194,6 +194,8 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -301,6 +303,7 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -460,6 +463,7 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -692,6 +696,7 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone's behalf, one at a time per Agent. |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. |
+5
View File
@@ -196,6 +196,8 @@ flowchart LR
pkg_skill_office["skill-office"]
svc_agents["ctx.agents<br/>Agent service"]
pkg_acp["acp"]
pkg_agent_controller["agent-controller"]
svc_agentController["ctx.agentController<br/>Outside-message delivery"]
svc_agentDefaultModel["ctx.agentDefaultModel<br/>Default Agent model selection"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_base["base"]
@@ -303,6 +305,7 @@ flowchart LR
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_agent --> svc_agents
pkg_agent_controller --> svc_agentController
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_preset_registry --> svc_agentPresets
@@ -462,6 +465,7 @@ flowchart LR
pkg_working_directory --> svc_workingDirectory
pkg_workspace --> svc_workspaceRegistry
pkg_workspace_changes --> svc_workspaceChanges
svc_agentController --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
@@ -694,6 +698,7 @@ flowchart LR
| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),提供缓存投影视图,并加速 prepared Session 的投影恢复。 |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | 准备发送方代表他人送来的消息,经 agent-controller/admission 阶段接纳后放入收件箱,每个 Agent 逐条处理。 |
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 |
+2 -1
View File
@@ -225,7 +225,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4696,6 +4696,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/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) |
+2 -1
View File
@@ -227,7 +227,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
@@ -4698,6 +4698,7 @@ export interface Config {
| --- | --- | --- |
| `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) |
| `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) |
| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/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) |
+6 -5
View File
@@ -8,6 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -22,11 +23,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+6 -5
View File
@@ -10,6 +10,7 @@
<!-- BEGIN GENERATED event-producer-consumer:events -->
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` |
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
@@ -24,11 +25,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
+7 -1
View File
@@ -41,6 +41,7 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -642,6 +643,9 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1277,6 +1281,7 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1657,6 +1662,7 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1776,7 +1782,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+7 -1
View File
@@ -43,6 +43,7 @@ flowchart TD
end
subgraph group_core["packages/core"]
pkg_agent["agent"]
pkg_agent_controller["agent-controller"]
pkg_agent_default_model["agent-default-model"]
pkg_agent_loop["agent-loop"]
pkg_agent_tool_presentation["agent-tool-presentation"]
@@ -644,6 +645,9 @@ flowchart TD
pkg_llm_retry --> pkg_session
pkg_llm_retry --> pkg_session_projection
pkg_llm_retry --> pkg_timeout
pkg_agent_controller --> pkg_agent
pkg_agent_controller --> pkg_llm
pkg_agent_controller --> pkg_scope
pkg_agent_default_model --> pkg_agent
pkg_agent_default_model --> pkg_llm
pkg_goal --> pkg_agent
@@ -1279,6 +1283,7 @@ flowchart TD
pkg_tool_subagent_control --> pkg_subagent
pkg_tool_subagent_control --> pkg_tools
pkg_api_session_controller --> pkg_agent
pkg_api_session_controller --> pkg_agent_controller
pkg_api_session_controller --> pkg_agent_default_model
pkg_api_session_controller --> pkg_agent_preset_registry
pkg_api_session_controller --> pkg_api_gateway
@@ -1659,6 +1664,7 @@ flowchart TD
| [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) |
| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) |
@@ -1778,7 +1784,7 @@ flowchart TD
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) |
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) |
| [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+4 -4
View File
@@ -1350,8 +1350,8 @@
en: 2786fef560851bb4
zh: 5d787475356b2606
/session-persistence-event-catalog/resolved-persistence-types/string:
en: 41c03b03d1bd5a44
zh: 1b0e430af6c140b3
en: 324d7ebffee10aad
zh: 31f7b8a18571665a
/session-persistence-event-catalog/resolved-persistence-types/string~2:
en: 0089620bf4638263
zh: 1922bef1713094bb
@@ -1839,8 +1839,8 @@
en: 809a23c25ded8497
zh: e9cd23e281f8d31f
/session-persistence-event-catalog/resolved-persistence-types/kind-user:
en: 53b7abac679ee252
zh: 63eb6a015986ef6d
en: db30e659a1cc4c06
zh: 8585ea4c8178ab37
/session-persistence-event-catalog/resolved-persistence-types/kind-user-approval:
en: a52d2dfbaf844127
zh: 4c5306ca40f20873
+2 -2
View File
@@ -5592,7 +5592,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7578,7 +7578,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5594,7 +5594,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7580,7 +7580,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def`
来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -23676,7 +23676,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:406",
"packages/api/session-controller/src/types.ts:412",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:21",
@@ -24198,7 +24198,7 @@
"digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def",
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:415"
"packages/api/session-controller/src/types.ts:421"
]
},
{
+6 -6
View File
@@ -9,11 +9,11 @@
en: 96b95cdb015c1af6
zh: 01e4144d91551c13
/client-modules/the-scan:
en: 91ce66fab388a8a2
zh: 77428943582ae3f6
en: 811c0e8a3a370399
zh: 4a49a3999d35503a
/client-modules/the-bundle-route-and-index-injection:
en: d653be500d3a0d67
zh: fffb1901ce8ba7d9
en: 82c089fbd9ffd535
zh: 30ac52d01b4fc074
/client-modules/the-service:
en: 000882ed7f09ad3f
zh: c17c05f087d9574d
en: b1df1bf2b8ee3ad1
zh: 6373d1b8cae22d02
+14 -10
View File
@@ -95,11 +95,11 @@ A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional
Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others.
Package metadata — including the negative "not a client package" verdict — is cached per Loader specifier and owning-tree base URL until restart. A fiber restart from the same source reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`.
Each reconciliation reads the package manifest and the bundle metadata again. A fiber restart from the same source that finds the same declaration and bundle metadata keeps its row and rev and reads no bundle bytes; a changed declaration, client export, or bundle is published by that reconciliation. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()`.
## The bundle route and index injection
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` addresses one generated combo script; a one-resource request uses the same form and is the HMR path. `/plugins/<package>/assets/<path>` serves a package's published images; the [package README](../../packages/client/modules/README.md#package-assets) owns that route. The script is concatenated once on its first `GET` and ends with a `sourceMappingURL` that carries the combo query alone — `??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`, resolved against the script's own directory rather than the document. The map files are not read by startup, index rendering, script `GET`, or `HEAD`; the first map `GET` reads and validates them, composes one Indexed Source Map v3, and caches that body. An authored component map supplies its section; a component without one receives an identity section whose `sourcesContent` is the captured bundle and whose source name is its packaged `sourceURL` or plugin route. Every startup request URL is at most 3 KiB measured as UTF-8 bytes; partitioning uses the longer map form. All application URLs are preloaded, and all bootstrap URLs execute before the graph global and Vite entry. Materialized responses use long-lived immutable caching. Unknown or altered resource lists, missing revisions, and stale revisions answer 404 rather than serving different bytes or letting the SPA fallback return HTML as JavaScript; other methods are 405. The injection rows carry the current graph on every index render, so a reload always boots against the live composition.
## The service
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
}
```
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` resolves the same lazy response used by the HTTP route. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
`ClientModuleRegistry` (`ctx.clientModules`, defined in [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts)) exposes reads and the rebuild face; signatures are in the generated [service catalog](#ctxclientmodules--clientmoduleregistry). `graph()` returns the current composed graph (a stable object between changes), `clientPath(id)` returns the bundle's absolute path, and `artifactBaseline(id)` returns the bundle stat values captured before the current snapshot was read. `fetchBundle()` returns the same responses as the HTTP route: the lazy bundle responses, and package assets read on each request. `rebuilt(id)` publishes a bundle rebuilt without an entry restart: it derives the revision from filesystem metadata, and only a revision change reads the new bytes, recomposes the graph, and notifies; an entry restart publishes through reconciliation instead. `onRebuilt` fires per changed bundle with the new revision; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt revision change) and is pull-model — listeners re-read `graph()`. Both notification paths contain listener exceptions so one throwing subscriber cannot skip later subscribers or kill whatever triggered the flush.
[`dsh-client-hmr`](../../packages/client/hmr/README.md) delivers live graph snapshots in the shipped Web composition. The Host forwards existing graph-change notifications immediately, and reconnect sends the current full graph. A graph describes desired browser entries without asserting that Host cleanup has completed. Its artifact poll separately reports rebuilt revisions. Source-map changes alone do not trigger a reload; a new combo-map URL appears only after a bundle revision changes, and each map body is fixed by its first `GET`. Client Modules validates snapshots and serializes reconciliation with those rebuilds; it owns the boot-created entry map, single-resource arrivals, asynchronous removal, unused-module/style cleanup and page-local retry status. Static platform modules and the bootstrap retain their page lifetime; Electron installation is a separate flow.
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModules` — `ClientModuleRegistry`
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -150,10 +150,13 @@ graph(): WebBootGraph
clientPath(id: string): string | undefined
/**
* Serve an advertised revisioned bundle or source map without a Web server.
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
* returns the same immutable headers without materializing a body. Each body
* is built once on its first `GET`; script construction never reads maps.
* Serve an advertised revisioned bundle, source map, chunk, or package asset
* without a Web server. Unknown URLs return 404, unsupported methods return
* 405, and `HEAD` returns the same immutable headers without materializing a
* body. Each bundle body is built once on its first `GET`; script
* construction never reads maps. A package asset is read on every request
* and answers 304 without a body when `If-None-Match` equals its entity tag;
* its body holds the file open until the caller consumes or cancels it.
* @param request - shell-carrier request for a `/plugins` resource.
* @returns the exact response also exposed by the optional Web route.
*/
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
/**
* Publish one completed bundle generation (the HMR watch's registration
* hook — the only entry point through which build changes reach the graph).
* hook; an entry restart publishes through reconciliation instead).
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
* @param id - entry id (package name).
* @returns the current artifact rev, or undefined for an unknown id.
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
rebuilt(id: string): string | undefined
/**
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
* entry restart publishes reaches only {@link onGraphChanged} listeners.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
+14 -10
View File
@@ -95,11 +95,11 @@ interface WebBootGraph {
扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。
包元数据——包括「非 client 包」这一否定结论——按 Loader specifier 与所属 tree base URL 缓存至重启。同一来源的 fiber 重启会原样复用其 row 与 rev;bundle 内容变更只经 `rebuilt()` 到达图。
每次对账都会重新读取包 manifest 与 bundle 元数据。同一来源的 fiber 重启若读到相同的声明与 bundle 元数据,就保留其 row 与 rev,且不读取 bundle 字节;声明、client 导出或 bundle 发生变化时,由这次对账发布。没有伴随条目重启的 bundle 重建经 `rebuilt()` 到达图。
## bundle 路由与 index 注入
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
`GET`/`HEAD /plugins/??<package-a>/client.js,<package-b>/client.js&rev=<rev>` 寻址一份生成的 combo 脚本;单资源请求采用同一形式,也是 HMR 路径。`/plugins/<package>/assets/<path>` 提供包发布的图片,该路由由[包 README](../../packages/client/modules/README.zh.md#package-assets) 说明。脚本在首次 `GET` 时只拼接一次,并以 `sourceMappingURL` 结尾,该引用只携带 combo 查询串——`??<package-a>/client.js.map,<package-b>/client.js.map&rev=<rev>`,按脚本自身目录而非文档解析。启动、index 渲染、脚本 `GET` 和 `HEAD` 都不会读取 map 文件;首次 map `GET` 才会读取并校验这些文件、组合一份 Indexed Source Map v3,并缓存该 body。组件有自带 map 时直接用于对应 section;没有时则获得 identity section,其 `sourcesContent` 是捕获的 bundle,source 名取打包后的 `sourceURL` 或插件路由。每条启动请求 URL 按 UTF-8 字节计算都不超过 3 KiB;切分按更长的 map 形式计算。所有 application URL 都会预加载,所有 bootstrap URL 都会在图全局量与 Vite entry 之前执行。已物化响应使用长期 immutable 缓存。未知或被修改的资源列表、缺少 revision 及陈旧 revision 都返回 404,绝不提供其他字节,也不会让 SPA fallback 把 HTML 当作 JavaScript 返回;其他方法返回 405。注入行在每次 index 渲染时携带当前图,因此重新加载总是基于实时组合启动。
## 服务
@@ -117,7 +117,7 @@ interface ClientArtifactBaseline {
}
```
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 解析 HTTP 路由所使用的同一份惰性响应。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle stat 值。`fetchBundle()` 返回与 HTTP 路由相同的响应:惰性的 bundle 响应,以及每次请求都读取的包资源。`rebuilt(id)` 发布没有伴随条目重启的 bundle 重建:它从文件系统元数据派生 revision,只有 revision 变化才会读取新字节、重新组合图并发出通知;条目重启则经对账发布。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 revision;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 revision 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
随包提供的 Web 组合通过 [`dsh-client-hmr`](../../packages/client/hmr/README.zh.md) 交付动态图快照。Host 立即转发现有图变化通知,重连会发送当前完整图。图描述浏览器的目标条目,不声明 Host 清理已经完成。产物轮询另外报告重建 revision。仅 source map 变化不会触发重载;新 combo-map URL 只会在 bundle revision 变化后出现,每份 map body 由其首次 `GET` 固定。Client Modules 校验快照,并将对账与重建串行协调;它持有启动创建的条目映射,负责单资源到达、异步移除、未使用模块与样式清理,以及页面本地重试状态。静态平台模块与 bootstrap 保持页面生命周期;Electron 安装属于独立流程。
@@ -133,7 +133,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
### `ctx.clientModules` — `ClientModuleRegistry`
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
The web plugin table service: incremental `dsh.client` scan + wire composition + bundle and package-asset route + index injection rows. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -150,10 +150,13 @@ graph(): WebBootGraph
clientPath(id: string): string | undefined
/**
* Serve an advertised revisioned bundle or source map without a Web server.
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
* returns the same immutable headers without materializing a body. Each body
* is built once on its first `GET`; script construction never reads maps.
* Serve an advertised revisioned bundle, source map, chunk, or package asset
* without a Web server. Unknown URLs return 404, unsupported methods return
* 405, and `HEAD` returns the same immutable headers without materializing a
* body. Each bundle body is built once on its first `GET`; script
* construction never reads maps. A package asset is read on every request
* and answers 304 without a body when `If-None-Match` equals its entity tag;
* its body holds the file open until the caller consumes or cancels it.
* @param request - shell-carrier request for a `/plugins` resource.
* @returns the exact response also exposed by the optional Web route.
*/
@@ -171,7 +174,7 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
/**
* Publish one completed bundle generation (the HMR watch's registration
* hook — the only entry point through which build changes reach the graph).
* hook; an entry restart publishes through reconciliation instead).
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
* @param id - entry id (package name).
* @returns the current artifact rev, or undefined for an unknown id.
@@ -179,7 +182,8 @@ artifactBaseline(id: string): ClientArtifactBaseline | undefined
rebuilt(id: string): string | undefined
/**
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
* entry restart publishes reaches only {@link onGraphChanged} listeners.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
+6 -3
View File
@@ -6,8 +6,8 @@
en: f6ae07d8902e8edf
zh: c92fef531c6da331
/core/the-spine-package-by-package:
en: c90cb23528ddb6c8
zh: 62724b7255b586b6
en: 60926dc99f41024d
zh: f40370b034f91463
/core/creation-and-ownership:
en: ab09a38b4e1f0883
zh: 81e2d14c4036ab6c
@@ -16,7 +16,10 @@
zh: 25bc9cb60ee1520a
/core/initiating-agent:
en: 6c8c47ac78de2dde
zh: c9d527a25a85d1d9
zh: acde99c8808ea891
/core/messages-from-outside-an-agent:
en: cf087fd9ff5edb87
zh: c3bb56b17cd76c52
/core/interception-decisions:
en: 6fccceb0a5f1cad9
zh: f43c30c2386d8cc8
+125 -1
View File
@@ -15,9 +15,10 @@ A turn flows through the six packages in one loop: the driver in [`agent-loop`](
| `tools/` | The scoped tool registry and guarded execution pipeline (`ctx.tools`) | [tools.md](tools.md) |
| `agent/` | The `Agent` interface, live registry, initiator scope, and `agent/*` event vocabulary (`ctx.agents`) | this page |
| `agent-loop/` | The concrete driver implementing the public `Agent` contract (`ctx.agentLoop`) | this page |
| `agent-controller/` | Delivery of messages from outside an Agent through the `agent-controller/admission` stage (`ctx.agentController`) | this page |
| `scope/` | The scoped-registration primitive the registries and loop build per-agent scoping on | [scope.md](scope.md) |
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. `agent-controller` sits in front of the loop: a sender that brings a message on someone's behalf delivers it through `ctx.agentController` instead of appending to the inbox itself. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree.
## Creation and ownership
@@ -305,6 +306,65 @@ The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, ch
The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules.
## Messages from outside an Agent
A sender that brings a message on someone's behalf, such as the Web Session Remote, calls `ctx.agentController.deliver` from [`agent-controller`](../../packages/core/agent-controller/README.md) instead of `followup()` or `steer()`. The controller prepares the content in the Agent's delivery order, asks the `agent-controller/admission` waterfall for a decision, and appends an admitted message; messages the Agent's own machinery produces enter without the stage. The [delivery decision](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md) explains why the stage lives outside `agent`.
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
A listener admits by returning the decision of `next()`, holds the message by awaiting before `next()`, and refuses it by returning a `reject` decision:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
## Interception decisions
Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result.
@@ -437,6 +497,33 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1099,6 +1186,43 @@ Types: [Scoped](scope.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.md) · [UserMessage](session.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+127 -1
View File
@@ -15,9 +15,10 @@
| `tools/` | 带作用域的工具注册表与受保护的执行流水线(`ctx.tools`) | [tools.md](tools.zh.md) |
| `agent/` | `Agent` 接口、实时注册表、发起者作用域与 `agent/*` 事件词汇(`ctx.agents`) | 本页 |
| `agent-loop/` | 实现公开 `Agent` 约定的具体 driver(`ctx.agentLoop`) | 本页 |
| `agent-controller/` | 外部消息经 `agent-controller/admission` 阶段投递进 Agent(`ctx.agentController`) | 本页 |
| `scope/` | 注册表与循环用于构建按 agent 作用域的注册原语 | [scope.md](scope.zh.md) |
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。`agent-controller` 位于循环之前:代表他人送来消息的发送方经 `ctx.agentController` 投递,而不是自己写入收件箱。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。
<a id="creation-and-ownership"></a>
@@ -311,6 +312,67 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有
`ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md)定义其生命周期和作用域规则。
<a id="messages-from-outside-an-agent"></a>
## 来自 Agent 外部的消息
代表他人送来消息的发送方(例如 Web Session Remote)调用 [`agent-controller`](../../packages/core/agent-controller/README.zh.md) 的 `ctx.agentController.deliver`,而不是 `followup()` 或 `steer()`。控制器按该 Agent 的投递顺序准备内容,向 `agent-controller/admission` waterfall 请求决定,再放入被接纳的消息;Agent 自身机制产生的消息不经过这个阶段。[投递决策](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md)说明了这个阶段为什么不放在 `agent` 里。
源码:[`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
```ts type-equiv
/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */
type DeliveryMode = 'followup' | 'steer'
```
```ts type-equiv
/** One message a sender delivers on someone's behalf. */
interface DeliveryRequest {
/** Where the message enters once it is admitted. */
readonly mode: DeliveryMode
/** Durable attribution recorded on the message. */
readonly source: MessageSource
/** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */
readonly signal: AbortSignal
/**
* Turn the sender's content into durable message content, such as by
* storing attachments. It runs in the Agent's delivery order, after the
* earlier messages to the same Agent settled; its failure reaches the
* sender unchanged.
* @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle.
* @returns the content of the message.
*/
readonly prepare: (signal: AbortSignal) => Promise<UserMessage['content']>
/**
* Record what the sender needs before the Agent wakes. It runs
* synchronously after admission, immediately before the message enters the
* inbox. A throw keeps the message out of the inbox and reaches the sender
* unchanged; the sender reverts its own effects when `deliver` rejects.
* @param message - the admitted message.
*/
readonly beforeInsert?: (message: UserMessage) => void
}
```
监听器返回 `next()` 的决定即放行,在 `next()` 之前 await 即暂留消息,返回 `reject` 决定即拒绝:
```ts type-equiv
/** The outcome of the admission stage for one message. */
type AdmissionDecision =
/** The message enters the inbox. */
| { readonly kind: 'admit' }
/** The message stays out of the inbox. */
| {
readonly kind: 'reject'
/** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */
readonly reason: string
/** Explanation for a sender that shows nothing of the refusing plugin. */
readonly message: string
/** Whether the refusing plugin explains the refusal in its own interface. */
readonly presented: boolean
}
```
<a id="interception-decisions"></a>
## 拦截决策
@@ -449,6 +511,33 @@ interface TurnBoundaryProjection {
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxagentcontroller--agentcontroller"></a>
### `ctx.agentController` — `AgentController`
Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.
```ts cordis-catalog
/**
* Deliver one message from outside the Agent: prepare its content, ask the
* `agent-controller/admission` stage for a decision, then append an
* admitted message with `followup` or `steer`.
* @param agent - the receiving Agent.
* @param request - delivery mode, source, cancellation, and the sender's content preparation.
* @returns the id of the message once it is in the inbox.
* @throws {@link AgentAdmissionRejected} when a listener rejects the message
* or throws, or when the Agent left the registry first; the error of
* `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's
* cancellation reason as soon as it cancels, also while the message waits
* behind another one; the stop reason once the controller stops.
*/
async deliver(agent: Agent, request: DeliveryRequest): Promise<MessageId>
```
Types: [MessageId](llm-streaming.zh.md)
Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts)
<a id="ctxagentdefaultmodel--agentdefaultmodelconfig"></a>
### `ctx.agentDefaultModel` — `AgentDefaultModelConfig`
@@ -1111,6 +1200,43 @@ Types: [Scoped](scope.zh.md)
Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts)
<a id="agent-controller-events"></a>
### `agent-controller/*` events
<a id="agent-controlleradmission--waterfall"></a>
#### `agent-controller/admission` — waterfall
Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.
```ts cordis-catalog
/**
* Decide whether one message from outside an Agent enters its inbox.
* `ctx.agentController.deliver` runs this stage and appends the message
* only after the whole chain admitted it; messages the Agent's own
* machinery produces enter through `followup`, `steer`, or `inject`
* without it. Listeners run in registration order, and messages to one
* Agent are decided one at a time in arrival order. A listener admits by
* returning the decision of `next()`, whose last step admits; awaiting
* before `next()` holds the message, and returning a `reject` decision
* refuses it without asking later listeners. A thrown error means the
* listener's check itself failed. A listener must not wait for a person
* when the message source is unattended, such as a schedule or a webhook.
* @param payload.agent - the receiving Agent.
* @param payload.message - the message as it will enter the inbox.
* @param payload.signal - aborted when the sender cancels or the controller stops; a holding
* listener must settle once it aborts, because controller disposal waits for it.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.
* @mode waterfall
*/
'agent-controller/admission'(this: Scoped<Agent>, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise<AdmissionDecision>): Promise<AdmissionDecision>
```
Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md)
Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts)
<a id="agent-loop-events"></a>
### `agent-loop/*` events
+6 -2
View File
@@ -930,10 +930,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session.
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
+6 -2
View File
@@ -934,10 +934,14 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* Admit one prompt after explicitly resuming its Session.
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue>
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
/web-client-slots:
en: 8d33b164230344fa
zh: 13a930ab96280744
en: 5f160332e4980284
zh: b1ae1da451a6209e
/web-client-slots/declaration-and-lifecycle:
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
+2
View File
@@ -10,6 +10,8 @@ This page documents slot ownership, component inputs, extension APIs, and the sh
The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime.
[ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior.
## Declaration and lifecycle
`SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location.
+2
View File
@@ -10,6 +10,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。
[ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。
## 声明与生命周期
`SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。
+2 -2
View File
@@ -9,8 +9,8 @@
en: dc8c41fbf9869179
zh: e45976a585cb9c86
/web-ui-style-reference/component-rules:
en: 3a35065f2be9de06
zh: 41291bba391fb515
en: 6715806a6f84d141
zh: 94b7a6ede4919512
/web-ui-style-reference/corner-radii-and-settings-cards:
en: 118972f6c2bd6ce4
zh: 85f8416b03e4dd8b
+1
View File
@@ -20,6 +20,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
- Pair font sizes with line heights and use the theme typography variables when an existing role matches.
- Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors.
- Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches.
- A relative `url()` in a plugin stylesheet points to a file in the package's `src/client/assets/`, and the build rewrites it to the [package asset](../packages/client/AGENTS.md#dependency-declaration) URL. `data:`, `#fragment`, `/`-rooted, scheme, and empty `url('')` references stay unchanged, a relative file reference with a fragment or query fails the build, and stylesheets from dependencies are not rewritten.
- Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls.
- Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
- Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
+1
View File
@@ -20,6 +20,7 @@
- 字体大小必须与行高配对;已有角色匹配时使用主题排版变量。
- 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。
- 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。
- 插件样式表中的相对 `url()` 指向本包 `src/client/assets/` 中的文件,构建会将其改写为[包资源](../packages/client/AGENTS.md#dependency-declaration) URL。`data:`、`#fragment`、以 `/` 开头、带 scheme 的引用以及空 `url('')` 引用保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包中的样式表不会被改写。
- 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。
- 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
- 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: 8fcfa14545bd9d1c
zh: c10c2bba792699ef
en: fe242dda6579245e
zh: cbd8c012135623de
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
+1 -1
View File
@@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. A prompt enters the Agent through [`ctx.agentController.deliver`](../../core/agent-controller/README.md#use-this-package), which runs the receipt resolution and attachment storage in the Agent's delivery order and binds the receipts immediately before the message enters the inbox, so the call stays open while an `agent-controller/admission` listener holds it. An image prompt takes the Session's image admission turn when its preparation starts and keeps it until the prompt settles, so a model selection lands either before its image check or after it entered the inbox, and waits while a listener holds that image prompt. A refusal returns `session/prompt-rejected` with the listener's `reason` and `presented`, a listener failure returns the same code with reason `agent-controller/admission-failed`, and an Agent disposed meanwhile returns `session/not-found`. A client that predates `session/prompt-rejected` shows it as an ordinary failure with its message and code. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values.
Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner.
+1 -1
View File
@@ -33,7 +33,7 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。提示词经 [`ctx.agentController.deliver`](../../core/agent-controller/README.zh.md#use-this-package) 进入 Agent:控制器按该 Agent 的投递顺序完成凭证解析与附件存储,并在消息进入收件箱前一刻绑定凭证,因此 `agent-controller/admission` 监听器暂留它期间调用保持未完成。图片提示词在开始准备时取得该 Session 的图片准入顺位,并持有到提示词结束,因此模型切换要么发生在它的图片检查之前,要么发生在它进入收件箱之后;监听器暂留这条图片提示词期间,模型切换会等待。被拒绝时返回 `session/prompt-rejected`,并带上监听器给出的 `reason` 与 `presented`;监听器失败时返回同一错误码,reason 为 `agent-controller/admission-failed`;期间 Agent 被销毁则返回 `session/not-found`。早于 `session/prompt-rejected` 的旧客户端把它显示为普通失败,带消息与错误码。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。
每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。
@@ -80,6 +80,7 @@
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-preset-registry": "workspace:*",
"@deepseek-ai/dsh-api-gateway": "workspace:*",
@@ -125,6 +126,7 @@
"@deepseek-ai/dsh-api-remote-access": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:*",
@@ -387,6 +387,27 @@ export class ApiSessionAgentController {
return result
}
/**
* Take the image admission turn for one Agent: wait until earlier image
* admissions and model selections settled, then hold the turn until the
* returned release runs.
* @param agent - live Agent that owns the serialization chain.
* @param signal - the waiting caller's cancellation; a caller cancelled by its turn passes the turn on.
* @returns the release, which the caller runs once its image prompt settled.
* @throws the cancellation reason when the caller was cancelled by its turn.
*/
acquireImageAdmission(agent: Agent, signal: AbortSignal): Promise<() => void> {
const previous = this.imageAdmissionChains.get(agent) ?? Promise.resolve()
const held = Promise.withResolvers<undefined>()
this.imageAdmissionChains.set(agent, previous.then(() => held.promise))
const release = (): void => { held.resolve(undefined) }
return previous.then(() => {
if (signal.aborted) release()
signal.throwIfAborted()
return release
})
}
/**
* Resolve the preset id and pre-publication Agent setup for a create or resume.
* @param presetId - requested preset or the configured default when omitted.
+89 -42
View File
@@ -5,14 +5,15 @@ import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import type { ADMISSION_AGENT_GONE, ADMISSION_REJECTED_CODE, AgentAdmissionRejected } from '@deepseek-ai/dsh-agent-controller'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type {
AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,
} from '@deepseek-ai/dsh-attachment'
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
import type {} from '@deepseek-ai/dsh-client-file-upload'
import type { PromptFileBinding } from '@deepseek-ai/dsh-client-file-upload'
import {
ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage,
ReasoningEffortId, assistantStreamChunks, freezeMessage,
} from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import { buildForkSeed } from '@deepseek-ai/dsh-session/fork'
@@ -310,56 +311,72 @@ export class SessionCommandController {
}
/**
* Admit one prompt after Agent and attachment validation; the Remote method
* has already validated the content and built the durable message source.
* Deliver one prompt through the Agent controller, whose admission stage
* may hold or refuse it; the Remote method has already validated the
* content and built the durable message source. The controller runs this
* Session's content preparation (image model check, upload receipts,
* attachment storage) in the Agent's delivery order and binds the upload
* receipts immediately before the message enters the inbox. A repeated
* request identity is acknowledged without delivering it again. An image
* prompt takes the Session's image admission turn when its preparation
* starts and keeps it until the prompt settles, so a model selection lands
* either before its image check or after it entered the inbox; a selection
* waits while a listener holds that image prompt.
* @param request - Session identity, prompt content, and delivery mode.
* @param source - the `user-rpc` source recorded on the accepted message.
* @param signal - the requesting client's cancellation; it stops a prompt that waits for its turn or is held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
async prompt(request: SessionPromptRequest, source: MessageSource): Promise<SessionPromptValue> {
async prompt(request: SessionPromptRequest, source: MessageSource, signal: AbortSignal): Promise<SessionPromptValue> {
const agent = await this.resolveAgent(request.sessionId)
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
const hasImage = request.content.some(part => part.type === 'image')
const admit = async (): Promise<SessionPromptValue> => {
try {
if (hasImage) {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
throw new RemoteError(
'session/attachment-invalid',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
const uploads: { receiptIds: readonly FileUploadReceiptId[]; binding?: PromptFileBinding } = { receiptIds: [] }
const imageTurn: { release?: () => void } = {}
try {
await this.ctx.agentController.deliver(agent, {
mode: request.mode === 'steer' ? 'steer' : 'followup',
source,
signal,
prepare: async (preparing) => {
if (hasImage) {
// Taken in the Agent's delivery order, so a waiting image prompt keeps its place among the others.
imageTurn.release = await this.agents.acquireImageAdmission(agent, preparing)
await this.requireImageInput(agent)
}
}
const admission = resolvePromptFileReceipts(
request.content,
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
)
const content = await this.ctx.attachments.admitPromptContent(admission.content)
const message: UserMessage = createUserMessage({ content, source })
if (this.ctx.agents.get(agent.id) !== agent) {
throw new RemoteError(
'session/not-found',
`session "${agent.id}" was disposed during prompt admission`,
{ sessionId: agent.id },
const admission = resolvePromptFileReceipts(
request.content,
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
)
}
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
if (request.mode === 'steer') agent.steer(message)
else agent.followup(message)
binding.commit()
} catch (error) {
if (remoteErrorOf(error) !== undefined) throw error
if (error instanceof AttachmentError) {
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
}
return { accepted: true }
uploads.receiptIds = admission.receiptIds
return await this.ctx.attachments.admitPromptContent(admission.content)
},
beforeInsert: () => {
uploads.binding = this.ctx.fileUploads.bindPrompt(agent, uploads.receiptIds, request.requestId)
},
})
uploads.binding?.commit()
} catch (error) {
throw promptFailure(agent, error, signal)
} finally {
uploads.binding?.[Symbol.dispose]()
imageTurn.release?.()
}
return { accepted: true }
}
/** Refuse an image prompt when the Session's current model does not declare image input. */
private async requireImageInput(agent: Agent): Promise<void> {
const current = this.agents.selectionFor(agent).current
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
throw new RemoteError(
'session/attachment-invalid',
`Model "${current.model}" does not support image input.`,
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
)
}
return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
}
private async requireModel(selection: Pick<AgentModelSelection, 'provider' | 'model'>): Promise<void> {
@@ -560,6 +577,36 @@ export class SessionCommandController {
}
}
// Typed by the agent-controller constants, so a changed value fails this module's build while it imports no agent-controller value.
const ADMISSION_REJECTED: typeof ADMISSION_REJECTED_CODE = 'AGENT_ADMISSION_REJECTED'
const AGENT_GONE: typeof ADMISSION_AGENT_GONE = 'agent-controller/agent-gone'
/** Recognize an admission refusal by the code that the error of every copy of the agent-controller module carries. */
function isAdmissionRejected(error: unknown): error is AgentAdmissionRejected {
return error instanceof Error && 'code' in error && error.code === ADMISSION_REJECTED
}
/**
* Map a failed prompt admission onto the Session Remote's failures: Remote
* failures and the client's own cancellation pass unchanged, and an admission
* refusal keeps its reason and whether its plugin presents it.
*/
function promptFailure(agent: Agent, error: unknown, signal: AbortSignal): unknown {
if (remoteErrorOf(error) !== undefined || signal.aborted) return error
if (isAdmissionRejected(error)) {
if (error.reason === AGENT_GONE) {
return new RemoteError('session/not-found', `session "${agent.id}" was disposed during prompt admission`, {
sessionId: agent.id,
})
}
return new RemoteError('session/prompt-rejected', error.message, { reason: error.reason, presented: error.presented })
}
if (error instanceof AttachmentError) {
return new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
}
return new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
}
function resolvePromptFileReceipts(
content: SessionPromptRequest['content'],
stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
+8 -3
View File
@@ -117,6 +117,7 @@ export interface SessionControllerInternals {
/** Host service backing the generated `ctx.remote.session` namespace. */
export class SessionController extends TypertRemoteService {
static inject = [
'agentController',
'agentDefaultModel',
'agents',
'attachments',
@@ -458,10 +459,14 @@ export class SessionController extends TypertRemoteService {
}
/**
* Admit one prompt after explicitly resuming its Session.
* Admit one prompt after explicitly resuming its Session. The prompt is
* delivered through `ctx.agentController`, whose `agent-controller/admission`
* stage runs before it enters the inbox, so the call can wait for a listener
* that holds it.
* @param request - Session identity, prompt content, source metadata, and delivery mode.
* @param signal - caller cancellation before prompt admission begins.
* @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage.
* @returns acknowledgement that the Agent accepted the prompt.
* @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails.
*/
@Remote('prompt')
async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise<SessionPromptValue> {
@@ -487,7 +492,7 @@ export class SessionController extends TypertRemoteService {
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
...(principal === undefined ? {} : { principal }),
}
return this.commands.prompt(request, source)
return this.commands.prompt(request, source, signal)
}
/**
@@ -212,6 +212,12 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
'session/projections-unavailable': Record<string, never>
'session/writer-held': { readonly sessionId: SessionId }
'session/agent-busy': { readonly reason: string }
'session/prompt-rejected': {
/** Code of the refusal, owned by the plugin that refused, such as `forgePanel/worktree-missing`. */
readonly reason: string
/** Whether the refusing plugin shows its own explanation, so the client adds no notice. */
readonly presented: boolean
}
'session/invalid-time-zone': { readonly value: string }
'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string }
'agent-preset/conflict': {
@@ -1,5 +1,6 @@
import { Context, FiberState } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentController from '@deepseek-ai/dsh-agent-controller'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
@@ -38,6 +39,7 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentController)
await ctx.plugin(CommandRuntime)
const session = ctx.sessions.create(SESSION, {
meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) },
@@ -99,6 +101,8 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
selectionFor: () => selection,
serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
} as unknown as ApiSessionAgentController
// Image prompts take their admission turn at once; no model selection competes in these tests.
Object.assign(agents, { acquireImageAdmission: () => Promise.resolve(() => {}) })
const uploads = new FileUploads(ctx)
if (uploadRoute === undefined) throw new Error('file upload route was not registered')
return {
@@ -117,6 +121,8 @@ async function uploadHarness(origin?: 'subagent'): Promise<{
/** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */
const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId }
/** A client that stays connected for the whole prompt. */
const SIGNAL = new AbortController().signal
function promptRequest(content: Parameters<SessionCommandController['prompt']>[0]['content']) {
return {
@@ -168,7 +174,7 @@ describe('Session file uploads', () => {
{ type: 'file', receiptId: receipt.receiptId },
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
{ type: 'text', text: 'read it' },
]), USER_SOURCE)
]), USER_SOURCE, SIGNAL)
expect(followup).toHaveBeenCalledTimes(1)
const message = followup.mock.calls[0]?.[0] as UserMessage
expect(message.content).toEqual([
@@ -195,7 +201,7 @@ describe('Session file uploads', () => {
name: 'huge.bin',
}))
expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 })
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([
{ type: 'file', attachment: receipt.file },
])
@@ -222,7 +228,7 @@ describe('Session file uploads', () => {
await expect(controller.prompt(promptRequest([
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
{ type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId },
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } })
expect(followup).not.toHaveBeenCalled()
expect(saveImages).not.toHaveBeenCalled()
})
@@ -320,7 +326,7 @@ describe('Session file uploads', () => {
const { controller, uploads, agent } = await uploadHarness()
uploads.retirePrompt(agent, 'not-staged')
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
uploads.retirePrompt(agent, 'other-request')
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
@@ -335,7 +341,7 @@ describe('Session file uploads', () => {
{ data: 'AAAA' },
new AbortController().signal,
)
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE)
await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE, SIGNAL)
first.agent.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'extension event' }],
source: { kind: 'user', rpcId: 1 } as never,
@@ -360,9 +366,9 @@ describe('Session file uploads', () => {
it('deduplicates a retried rpcId already present in the Agent inbox', async () => {
const { controller, agent, followup } = await uploadHarness()
const request = promptRequest([{ type: 'text', text: 'once' }])
await controller.prompt(request, USER_SOURCE)
await controller.prompt(request, USER_SOURCE, SIGNAL)
agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage)
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true })
expect(followup).toHaveBeenCalledOnce()
})
@@ -379,7 +385,7 @@ describe('Session file uploads', () => {
source: { kind: 'user', rpcId: request.requestId },
}), { surfaceOp: 'append' })
await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true })
await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true })
expect(followup).not.toHaveBeenCalled()
})
@@ -389,7 +395,7 @@ describe('Session file uploads', () => {
saveImages.mockReturnValueOnce(admitted.promise)
const prompting = controller.prompt(promptRequest([
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
]), USER_SOURCE)
]), USER_SOURCE, SIGNAL)
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
await disposeAgent()
admitted.resolve([{
@@ -401,7 +407,7 @@ describe('Session file uploads', () => {
it('rejects when a previously bound receipt retires during image admission', async () => {
const { controller, uploads, agent, saveImages } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
const admitted = Promise.withResolvers<readonly ImageAttachmentRef[]>()
saveImages.mockReturnValueOnce(admitted.promise)
const prompting = controller.prompt({
@@ -410,7 +416,7 @@ describe('Session file uploads', () => {
{ type: 'image', mediaType: 'image/png', data: 'AAAA' },
]),
requestId: 'req-2' as SessionRequestId,
}, USER_SOURCE)
}, USER_SOURCE, SIGNAL)
await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
uploads.retirePrompt(agent, 'req-1')
admitted.resolve([{
@@ -424,12 +430,12 @@ describe('Session file uploads', () => {
it('keeps the prior receipt binding when a later prompt attempt fails', async () => {
const { controller, uploads, agent, followup } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
followup.mockImplementationOnce(() => { throw new Error('busy') })
await expect(controller.prompt({
...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]),
requestId: 'req-2' as SessionRequestId,
}, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
}, USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' })
uploads.retirePrompt(agent, 'req-1')
expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined()
})
@@ -440,7 +446,7 @@ describe('Session file uploads', () => {
followup.mockImplementationOnce(() => { throw new Error('busy') })
await expect(controller.prompt(promptRequest([
{ type: 'file', receiptId: receipt.receiptId },
]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' })
]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' })
uploads.retirePrompt(agent, 'req-1')
expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file)
})
@@ -448,7 +454,7 @@ describe('Session file uploads', () => {
it('retires a prompt-bound receipt when its queued occurrence is removed', async () => {
const { controller, uploads, agent, followup } = await uploadHarness()
const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE)
await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL)
const queued = followup.mock.calls[0]?.[0] as UserMessage
agent.inbox.append('next-turn', queued)
expect(await controller.updateQueue({
@@ -469,7 +475,7 @@ describe('Session file uploads', () => {
await controller.prompt(promptRequest([
{ type: 'file', receiptId: first.receiptId },
{ type: 'file', receiptId: second.receiptId },
]), USER_SOURCE)
]), USER_SOURCE, SIGNAL)
const message = followup.mock.calls[0]?.[0] as UserMessage
expect(message.content).toEqual([
@@ -0,0 +1,217 @@
/**
* Session prompts through the Agent controller's admission stage: how the
* `session/prompt` Remote reports a held, refused, or failed admission to the
* client.
*/
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts'
import { createSessionTestRemote } from './test-remote.ts'
let nextRequestId = 1
async function harness(options: { readonly refusal?: Error } = {}) {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create()
const inbox = createInboxStub()
const deliver = (target: 'next-turn' | 'next-step', message: UserMessage): void => {
if (options.refusal !== undefined) throw options.refusal
inbox.append(target, message)
}
const agent: Agent = {
id: session.id,
options: {},
session,
inbox,
status: 'idle',
ctx,
send: () => {},
followup: (message) => { deliver('next-turn', message) },
steer: (message) => { deliver('next-step', message) },
inject: () => {},
cancel: () => {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
const unregister = await ctx.agents.register(agent)
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
cwd: '/tmp',
})
const prompt = (text: string, mode: SessionPromptRequest['mode'] = 'queue', requestId?: SessionRequestId) => ({
requestId: requestId ?? `admission-${String(nextRequestId++)}` as SessionRequestId,
sessionId: session.id,
mode,
content: [{ type: 'text' as const, text }],
})
return { ctx, agent, inbox, remote, prompt, unregister }
}
function textsOf(messages: readonly UserMessage[]): string[] {
return messages.map(message => message.content.map(block => block.type === 'text' ? block.text : '').join(''))
}
describe('Session prompt admission', () => {
it('admits a queued prompt into the next turn and a steered one into the next step', async () => {
const { ctx, agent, inbox, remote, prompt } = await harness()
const admitted: Agent[] = []
ctx.on('agent-controller/admission', async ({ agent: subject }, next) => {
admitted.push(subject)
return await next()
})
expect(await remote.prompt(prompt('later'))).toEqual({ ok: true, value: { accepted: true } })
expect(await remote.prompt(prompt('now', 'steer'))).toEqual({ ok: true, value: { accepted: true } })
expect(admitted).toEqual([agent, agent])
expect(textsOf(inbox.nextTurn)).toEqual(['later'])
expect(textsOf(inbox.nextStep)).toEqual(['now'])
})
it('keeps the prompt call open while a listener holds it', async () => {
const { ctx, inbox, remote, prompt } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
let settled = false
const pending = remote.prompt(prompt('held')).finally(() => { settled = true })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(settled).toBe(false)
expect(inbox.nextTurn).toEqual([])
release.resolve(undefined)
expect(await pending).toEqual({ ok: true, value: { accepted: true } })
expect(textsOf(inbox.nextTurn)).toEqual(['held'])
})
it('returns a refusal with its reason and whether its plugin presents it, and keeps the inbox untouched', async () => {
const { ctx, inbox, remote, prompt } = await harness()
let presented = true
ctx.on('agent-controller/admission', () => Promise.resolve({
kind: 'reject' as const, reason: 'fixture/worktree-missing', message: 'The worktree was removed.', presented,
}))
expect(await remote.prompt(prompt('refused'))).toMatchObject({
ok: false,
error: {
code: 'session/prompt-rejected',
message: 'The worktree was removed.',
details: { reason: 'fixture/worktree-missing', presented: true },
},
})
presented = false
expect(await remote.prompt(prompt('refused again'))).toMatchObject({
ok: false,
error: { code: 'session/prompt-rejected', details: { reason: 'fixture/worktree-missing', presented: false } },
})
expect(inbox.nextTurn).toEqual([])
})
it('reports a failed listener as a refusal the client explains', async () => {
const { ctx, inbox, remote, prompt } = await harness()
ctx.on('agent-controller/admission', () => Promise.reject(new Error('listener broke')))
const result = await remote.prompt(prompt('broken'))
expect(result).toMatchObject({
ok: false,
error: { code: 'session/prompt-rejected', details: { reason: 'agent-controller/admission-failed', presented: false } },
})
expect(result.ok ? '' : result.error.message).toContain('listener broke')
expect(inbox.nextTurn).toEqual([])
})
it('cancels a held prompt whose client went away', async () => {
const { ctx, inbox, remote, prompt } = await harness()
ctx.on('agent-controller/admission', async ({ signal }, next) => {
await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
return await next()
})
const client = new AbortController()
const pending = remote.prompt(prompt('abandoned'), client.signal)
await new Promise<void>((resolve) => { setImmediate(resolve) })
client.abort(new Error('client left'))
expect(await pending).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
expect(inbox.nextTurn).toEqual([])
})
it('reports a Session whose Agent was disposed while its prompt was held as not found', async () => {
const { ctx, agent, remote, prompt, unregister } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
const pending = remote.prompt(prompt('orphaned'))
await new Promise<void>((resolve) => { setImmediate(resolve) })
await unregister()
release.resolve(undefined)
expect(await pending).toMatchObject({
ok: false,
error: { code: 'session/not-found', details: { sessionId: agent.id } },
})
})
it('maps a refusal from another copy of the agent-controller module by its code', async () => {
const { ctx, agent, remote, prompt } = await harness()
// Another copy of the module raises the same fields from its own class.
const refusal = (reason: string): Error => Object.assign(new Error('The worktree was removed.'), {
name: 'AgentAdmissionRejected', code: 'AGENT_ADMISSION_REJECTED', reason, presented: true,
})
vi.spyOn(ctx.agentController, 'deliver')
.mockRejectedValueOnce(refusal('fixture/worktree-missing'))
.mockRejectedValueOnce(refusal('agent-controller/agent-gone'))
expect(await remote.prompt(prompt('refused'))).toMatchObject({
ok: false,
error: {
code: 'session/prompt-rejected',
message: 'The worktree was removed.',
details: { reason: 'fixture/worktree-missing', presented: true },
},
})
expect(await remote.prompt(prompt('orphaned'))).toMatchObject({
ok: false,
error: { code: 'session/not-found', details: { sessionId: agent.id } },
})
})
it('acknowledges a repeated request without admitting it again', async () => {
const { ctx, inbox, remote, prompt } = await harness()
let admissions = 0
ctx.on('agent-controller/admission', async (_admission, next) => {
admissions += 1
return await next()
})
const first = prompt('once')
expect(await remote.prompt(first)).toEqual({ ok: true, value: { accepted: true } })
expect(await remote.prompt(prompt('once', 'queue', first.requestId))).toEqual({ ok: true, value: { accepted: true } })
expect(admissions).toBe(1)
expect(textsOf(inbox.nextTurn)).toEqual(['once'])
})
it('reports the Agent refusing to queue a prompt as busy', async () => {
const { remote, prompt } = await harness({ refusal: new Error('inbox closed') })
expect(await remote.prompt(prompt('rejected'))).toMatchObject({
ok: false,
error: { code: 'session/agent-busy', details: { reason: 'Error: inbox closed' } },
})
})
})
@@ -137,6 +137,46 @@ async function harness(logged?: {
return { ctx, agent, sessionId: session.id }
}
/** An image store whose saved attachments are one-pixel PNGs. */
function provideImageStore(ctx: Context): void {
const attachments = {
imageLimits: {
maxImageBytes: 4,
maxImagesPerMessage: 2,
maxMessageImageBytes: 4,
maxImagePixels: 4,
maxImageDimension: 2000,
mediaTypes: ['image/png'],
},
validateImage: () => Promise.resolve(),
saveImage: (input: { data: Uint8Array; mediaType: 'image/png' }) => Promise.resolve({
attachmentId: `att-${String(input.data[0])}`, mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1,
}),
}
ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
}
/** A queued prompt with its text, after a one-pixel image when `withImage` is set. */
function imagePrompt(sessionId: SessionId, text: string, withImage: boolean): SessionPromptRequest {
return promptRequest({
sessionId,
mode: 'queue',
content: [
...withImage ? [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' }] : [],
{ type: 'text' as const, text },
],
})
}
function textOf(message: UserMessage): string {
return message.content.map(block => block.type === 'text' ? block.text : '').join('')
}
/** Let pending prompts run as far as they can. */
async function settle(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
function expectValue<T>(result: { ok: true; value: T } | { ok: false }): T {
if (!result.ok) throw new Error('expected successful response')
return result.value
@@ -313,6 +353,110 @@ describe('Web session model selection', () => {
await ctx.fiber.dispose()
})
it('admits image prompts in arrival order behind an image prompt that a listener holds', async () => {
const { ctx, agent, sessionId } = await harness()
provideImageStore(ctx)
const followed: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } })
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async ({ message }, next) => {
if (textOf(message) === 'held') await release.promise
return await next()
})
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const prompts = [
remote.prompt(imagePrompt(sessionId, 'held', true)),
remote.prompt(imagePrompt(sessionId, 'second image', true)),
remote.prompt(imagePrompt(sessionId, 'third text', false)),
]
await settle()
expect(followed).toEqual([])
release.resolve(undefined)
expect((await Promise.all(prompts)).map(result => result.ok)).toEqual([true, true, true])
expect(followed).toEqual(['held', 'second image', 'third text'])
await ctx.fiber.dispose()
})
it('keeps a model selection behind the image prompt being admitted but not behind one still queued', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
provideImageStore(ctx)
const events: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { events.push(`admitted ${textOf(message)}`) } })
const releases = new Map([['held image', Promise.withResolvers<undefined>()], ['held text', Promise.withResolvers<undefined>()]])
ctx.on('agent-controller/admission', async ({ message }, next) => {
await releases.get(textOf(message))?.promise
return await next()
})
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
const select = () => remote.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' }))
.then((result) => { events.push('selected'); return result })
// A held image prompt was checked against the current model, so the selection waits for its append.
const heldImage = remote.prompt(imagePrompt(sessionId, 'held image', true))
await settle()
const first = select()
await settle()
expect(events).toEqual([])
releases.get('held image')?.resolve(undefined)
expect((await heldImage).ok).toBe(true)
expect((await first).ok).toBe(true)
expect(events).toEqual(['admitted held image', 'selected'])
// An image prompt queued behind a held text prompt has not been checked yet, so the selection does not wait for it.
const heldText = remote.prompt(imagePrompt(sessionId, 'held text', false))
const queuedImage = remote.prompt(imagePrompt(sessionId, 'queued image', true))
await settle()
expect((await select()).ok).toBe(true)
expect(events).toEqual(['admitted held image', 'selected', 'selected'])
releases.get('held text')?.resolve(undefined)
expect((await heldText).ok).toBe(true)
expect(await queuedImage).toMatchObject({
ok: false, error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
})
expect(events).toEqual(['admitted held image', 'selected', 'selected', 'admitted held text'])
await ctx.fiber.dispose()
})
it('passes the image admission turn on when the sender of a waiting image prompt leaves', async () => {
const { ctx, agent, sessionId } = await harness()
provideImageStore(ctx)
const followed: string[] = []
Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } })
const listing = Promise.withResolvers<readonly LlmModelInfo[]>()
ctx.llm.registerAdapter(['gated'], new class extends CatalogAdapter {
override listModels(): Promise<readonly LlmModelInfo[]> {
return listing.promise
}
}('Gated', []))
const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
// The selection holds the image admission turn while the provider lists its models.
const selection = remote.selectModel(request({ sessionId, provider: 'gated', model: 'slow' }))
await settle()
const sender = new AbortController()
const leaving = remote.prompt(imagePrompt(sessionId, 'left', true), sender.signal)
await settle()
sender.abort(new Error('client left'))
expect(await leaving).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
listing.resolve([{ provider: 'gated', id: 'slow', name: 'Slow' }])
expect((await selection).ok).toBe(true)
expect((await remote.prompt(imagePrompt(sessionId, 'after', true))).ok).toBe(true)
expect(followed).toEqual(['after'])
await ctx.fiber.dispose()
})
it('authorizes attachment bytes only when the session event stream references the id', async () => {
const { ctx, agent, sessionId } = await harness()
const ref = {
@@ -31,6 +31,7 @@ import {
remoteErrorOf,
type RemoteResult,
} from '@deepseek-ai/dsh-typert-protocol'
import AgentController from '@deepseek-ai/dsh-agent-controller'
import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access'
import SessionController from '../src/index.ts'
import type {
@@ -293,6 +294,7 @@ async function installControllers(
} as never)
}
installSessionReadTestServices(ctx)
if (ctx.get('agentController') === undefined) new AgentController(ctx)
if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx)
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
class TestController extends SessionController {
@@ -32,6 +32,9 @@
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-controller"
},
{
"path": "../../core/agent-default-model"
},
+5
View File
@@ -82,6 +82,11 @@
- id: agent
name: '@deepseek-ai/dsh-agent'
# Delivers messages from outside an Agent into its inbox after the admission
# stage; entry points such as the Web Session Remote deliver through it.
- id: agent-controller
name: '@deepseek-ai/dsh-agent-controller'
- id: plugin-package-inventory-deepseek
name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
+1
View File
@@ -37,6 +37,7 @@
"@deepseek-ai/dsh-hmr": "workspace:*",
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-agent-controller": "workspace:*",
"@deepseek-ai/dsh-agent-default-model": "workspace:*",
"@deepseek-ai/dsh-agent-loop": "workspace:*",
"@deepseek-ai/dsh-attachment-local": "workspace:*",
+2 -1
View File
@@ -70,6 +70,7 @@ Npm sections describe installation and development relationships; each build fac
6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents.
7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view.
8. **Only the root manifest declares React type packages.** `@types/react` and `@types/react-dom` belong only in the root `package.json` `devDependencies`, and `verify-package-dependencies` rejects them in every manifest under `apps/*`, `packages/*/*`, and `vendor/*`; packages still declare the `react` and `react-dom` they import. Each package-local pnpm link is one more location TypeScript scans when it computes module specifiers for every JSX file ([rationale](../../.agents/notes/implemented/process/2026-10-11-lossless-build-speedups.md#1-only-the-root-manifest-declares-react-type-packages)).
9. **Package images live under `src/client/assets/`.** Client code references one by relative `import` and the package's own CSS by relative `url()`; supported extensions are `.svg`, `.png`, `.webp`, `.jpg`, and `.jpeg`. The package declares `"./assets/*": "./lib/assets/*"` in `exports` and `lib/assets` in `files`; the build preset emits each referenced file under `lib/assets/`, and the browser loads it through the [client-modules asset route](modules/README.md).
## Build-time browser environment
@@ -143,7 +144,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore
Bringing up a new `packages/client/<name>` plugin package (ui-workspace is a complete example; ui-sidebar/ui-user-questions are minimal skeletons):
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./client`/`./src/*`/`./package.json`, `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section.
1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-<name>`, exports `.`/`./client`/`./src/*`/`./package.json` plus `./assets/*` for [package images](#dependency-declaration), `dsh.client` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency), `tsdown.config.ts` (`clientBundle(import.meta.dirname, ['lib/types/index.js'])`), `src/index.ts` (empty node-half apply), `src/css-modules.d.ts` when using CSS Modules, and `README.md` with the Model Experience section.
2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dsh.client` row in `packages/bundle/web-app/cordis.patch.yml`; a `packages/bundle/web-app/package.json` dependency (profile boots resolve bare row names through the computed runtime resolution, which is installed as an interception on Node's resolvers — a row whose package no manifest declares fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`.
3. **dsh.client manifest semantics**: `platform: 'web'` always, and the declaration requires a `./client` export (the scan throws without one); `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is Cordis fiber inject waiting on *services*, nothing else. A non-baseline `external` request sequences its dynamic supplier ahead of the consumer — see [shared modules](#shared-modules-and-the-module-graph).
4. **Registering into another package's slot**: apply order is unconstrained, and a business service is not a declaration barrier. Use `ctx.slots.inject(name, () => ctx.slots.register(...))`; it waits on the actual declaration, removes the contribution when that declaration collapses, reruns after redeclaration, and leaves with the caller's plugin fiber. Return a generator yielding each registration when several contributions must install and roll back atomically. A bare `slots.register` into an undeclared slot remains an error; keep service edges only for services the contribution actually reads.
@@ -98,6 +98,12 @@ export interface ClientTransportHooks {
* bundles load over HTTP.
*/
loadBundle?(url: string): Promise<void>
/**
* Package-asset resolver for the module system, present when the page cannot
* request `plugins/<package>/assets/<path>` itself (the worker tunnel): it
* maps each reference to a URL the page can load. Absent in the served web app.
*/
resolveAsset?(reference: string): string
/**
* The transport owner declares the page owns the Host outright: the Host
* runs inside a worker this page spawned, so no other party can reach it and
+11 -8
View File
@@ -18,8 +18,11 @@
en: 25f536f156ec0bcc
zh: a522355b3e0edeae
/deepseek-ai-dsh-client-modules/use-this-package/declaring-a-client-plugin:
en: d4a7f865f4593724
zh: 4313eb8fe9cd5c98
en: cc0eb2c35a7a0998
zh: 0e1df23869410c7a
/deepseek-ai-dsh-client-modules/use-this-package/package-assets:
en: 529af1eb8bf1acf2
zh: 7d891a5dfa90c625
/deepseek-ai-dsh-client-modules/use-this-package/what-the-browser-loads:
en: 1383d68472b3c057
zh: 94dd7d9abbdbccf3
@@ -45,8 +48,8 @@
en: 6d16b74acb333e3b
zh: fcb3a20cc8c39e4a
/deepseek-ai-dsh-client-modules/understand-the-implementation/incremental-composition:
en: 41c38d220c99e49e
zh: 4501a507fb1d97d5
en: d2c42ee89ca84f7b
zh: 70ee9dd5e6aa5327
/deepseek-ai-dsh-client-modules/understand-the-implementation/boot-manifest-injection:
en: 253d9f265cd83ed0
zh: 9d0f8df84d9d3ac9
@@ -54,8 +57,8 @@
en: df2994f9ff8872c9
zh: cd776526ed2b51e6
/deepseek-ai-dsh-client-modules/understand-the-implementation/source-map:
en: fabe9db8c44e2d90
zh: 89ca94a8161f57ed
en: d8b5407a0279a3f9
zh: 27af3437fad78672
/deepseek-ai-dsh-client-modules/further-exploration:
en: 7e5b283efe4f1d16
zh: 79472b64a0d759fe
@@ -66,8 +69,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work:
en: 6dbb155bc65c9d65
zh: 368b553a56e46dfc
en: 97127ddc35daf6d4
zh: 1417b06046b01067
/deepseek-ai-dsh-client-modules/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+9 -1
View File
@@ -33,6 +33,11 @@ Use it when you compose or build a browser client plugin: the package turns a pa
A browser plugin package declares `dsh.client` in its `package.json` with `platform: 'web'`, exports a `./client` bundle, and lists any non-baseline module requests under `dsh.client.external`. The host half turns each declaration into a served bundle under `/plugins`, ordered so dynamic providers load before their consumers.
<a id="package-assets"></a>
### Package assets
The same `/plugins` route serves the images a client package publishes through its `"./assets/*": "./lib/assets/*"` export. The build emits each referenced asset with its content revision, the first 12 hexadecimal digits of the SHA-1 digest of its bytes, inserted before the extension, and compiles the import, or the stylesheet `url()`, into a synchronous `require.asset("plugins/<package>/assets/<emitted path>")` call on the bundle's module loader, so one URL always names one content. Each path segment is percent-encoded with `encodeURIComponent` and then `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`; a package built outside this repository must emit the same form. The call returns the document-relative reference unless the page's carrier supplies `resolveAsset`: the WebWorker preview, whose page cannot request the reference, maps it to a Blob URL of the bytes its Worker image holds. Any `/plugins/<package>/assets/` path is answered by the asset route alone: for an active client row, the Host percent-decodes the path once and resolves `<package>/assets/<path>` with Node's `require.resolve` from the package's own manifest, so the package's exports decide which files exist. A path that does not resolve, a resolved file whose extension is not `.svg`, `.png`, `.webp`, `.jpg`, or `.jpeg`, a malformed escape, and an inactive package answer 404; the query selects nothing. A served asset streams from disk on every request without caching and carries its media type, the bundle `cache-control: public, max-age=31536000, immutable`, `etag: "<requested path>"`, and `x-content-type-options: nosniff`; an SVG additionally carries `content-security-policy: script-src 'none'`, so an SVG opened directly runs no script. An `If-None-Match` equal to that tag answers 304 without opening the file, and `HEAD` answers without a body. `fetchBundle()` returns the same responses, with a streamed body that closes the file when cancelled.
### What the browser loads
The application combo scripts carry only each plugin's `client.js` entry and register those factories once during boot; module bodies remain lazy and run only at first import or materialization. A source `import()` split by tsdown compiles to `require.async("./client.<name>.js")`; its versioned sibling script arrives only when that expression runs. Rows that share a combo URL share one in-flight script task. A combo whose `<script>` fails to load is requested once more; a combo that loads without registering a row is never re-executed, because the batch script registers its packages in sequence and a replay would stop at the first duplicate registration. In both cases each still-missing row then loads its own one-resource combo URL, so one failed batch costs at most three requests per missing row and leaves the rows it did register untouched. The module system records the last import failure per row (transport, registration, dependency cascade, or factory execution); the Web boot audit reports that text per entry. HMR switches one changed row to its revisioned one-resource combo URL. `<id>/client` and the bare id resolve to the same exports, because a plugin bundle is its package's client half.
@@ -73,7 +78,7 @@ Executing a plugin bundle only registers its factory; every module-body side eff
### Incremental composition
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Package metadata is cached per Loader specifier and owning-tree base URL until restart, while the resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. Bundle content changes reach the graph only through `rebuilt()` (the HMR hook).
The Node half scans incrementally per package — no full-rescan path. Every `internal/plugin` emission marks the fiber's entry name dirty; a microtask flush reconciles each dirty name against the live loader entries, and the activation pass seeds the same dirty set and flushes synchronously, so first scan and steady state share one implementation. Each reconciliation reads the package manifest and the bundle metadata again, so an entry that restarts after its package was reinstalled, upgraded, or moved publishes its current `dsh.client` declaration and client export; a reconciliation that finds the same manifest and bundle metadata publishes nothing and reads no bundle bytes, and an unchanged bundle keeps its revision, so the browser keeps the loaded plugin. The resolved manifest package name identifies the browser module. Distinct active Loader sources resolving to one package name are rejected; removing the conflict promotes the remaining source without requiring its fiber to restart. A bundle rebuilt without an entry restart reaches the graph through `rebuilt()` (the HMR hook).
The Node half snapshots each `client.js` entry before publication and creates combo descriptors without building response bodies. It groups resources into combo route keys (`/plugins/??...&rev=...`), with one bootstrap combo for the modules row and one or more application combos for the other rows; each phase is partitioned before a URL exceeds 3 KiB. The graph and batch descriptors carry the app-directory-relative form of the same key (`plugins/??...&rev=...`), which a browser document resolves under its own mount, while the response table stays keyed by the absolute route; the source-map trailer is resolved against the combo script's own directory. A script body is combined once on its first `GET` and ends with its map reference. The corresponding map files are read, validated, and combined separately on the first map `GET`; `HEAD` materializes neither body. The Host does not scan or preload sibling chunks: an exact `/plugins/<package>/client.<name>.js?rev=<rev>` request reads that script through the asynchronous file API, so a filesystem that materializes files on first asynchronous read can supply it, and caches it; its map remains uncomputed until the map URL is requested. The chunk reference the browser derives from its owner row is document-relative as well (`plugins/<package>/client.<name>.js?rev=<rev>`), and a chunk script's map trailer is the bare `client.<name>.js.map?rev=<rev>` name, which resolves against the script's own directory. Every combo or chunk map is Indexed Source Map v3 and uses an authored section when available or an identity section for the packaged bundle. Initial publication and HMR derive per-plugin revisions from the entry's `mtimeMs`, `ctimeMs`, and size, without hashing artifact contents. Status-change time distinguishes rewrites that preserve mtime and size. Unchanged artifacts therefore retain their revisions across Host restarts, so SSE reconnection does not replace their browser plugins. The shared preset stamps `client.js` after every package output is written, so a chunk-only rebuild changes the owner revision without a Host-side chunk scan. Combo revisions derive from the ordered row revisions. Advertised combo responses and requested chunk responses remain immutable across unrelated graph recomposition, and an unknown resource or revision returns 404.
@@ -92,6 +97,7 @@ The host contributes structured index rows that inject, into `<head>`: the `wind
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Node half: `ClientModuleRegistry`, scan, artifact snapshots, optional combo route, structured index rows |
| [`src/assets.ts`](src/assets.ts) | Package-asset media types, export, content revision, revisioned file name, and browser reference shared by the build preset, the asset route, and gates |
| [`src/client/index.ts`](src/client/index.ts) | Browser half: bootstrap export, `ctx.modules` enrollment |
| [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`: load/materialize/invalidate machinery |
| [`src/client/entries.ts`](src/client/entries.ts) | Page entry reconciliation, retries and code replacement |
@@ -138,6 +144,8 @@ These limits define what the module system does not do. They are current package
- **Bootstrap and code replacement limits** — the page retains its modules bootstrap and static platform identities. Removing or replacing the bootstrap requires a page reload; live replacement requests report a page-local error while retaining its fiber and exports; replacing package code and all existing consumers is outside ordinary enable/disable synchronization.
- **Lazy delivery retains requested bodies** — the Host holds each bundle and lazy response plan; a script or map body remains cached after its first `GET`, and HMR additionally retains one prior startup generation. Memory grows only for response bodies that clients request while preserving one-generation race tolerance.
- **An unrequested prior-generation map reads the current map file** — combo revisions track executable bundles, not debug artifacts. If HMR rebuilds a map before the retained prior URL receives its first map `GET`, that response uses the current authored map with the prior bundle offsets; requesting the map before the rebuild fixes that URL's response.
- **A manifest edit alone is not observed** — the Host reads a package manifest when its Loader entry starts or restarts; editing only `package.json` takes effect at the next entry restart or Host restart. The asset route resolves through Node's package configuration cache, so a `./assets/*` export edited in place takes effect only after boot HMR expires that package or the Host restarts.
- **Desktop re-reads package assets on every display** — the Desktop forwarder rewrites every `/plugins/` cache header to `no-store`, so the immutable asset headers do not reach its browser cache.
<a id="dev-note"></a>
### Dev Note
+9 -1
View File
@@ -33,6 +33,11 @@ kind: "package-reference"
浏览器插件包在其 `package.json` 中以 `platform: 'web'` 声明 `dsh.client`,导出 `./client` bundle,并在 `dsh.client.external` 下列出任何基座之外的模块请求。宿主半侧把每份声明变成 `/plugins` 下提供的 bundle,并让动态提供方先于其消费方加载。
<a id="package-assets"></a>
### 包资源
同一个 `/plugins` 路由也提供客户端包通过其 `"./assets/*": "./lib/assets/*"` 导出发布的图片。构建会产出每个被引用的资源,在扩展名之前插入其内容 revision(资源字节 SHA-1 摘要的前 12 位十六进制数字),并把 import 或样式表 `url()` 编译成对 bundle 模块加载器的同步调用 `require.asset("plugins/<package>/assets/<emitted path>")`,因此一个 URL 始终对应一份内容。每个路径段先用 `encodeURIComponent` 编码,再编码 `!`、`'`、`(`、`)` 和 `*`,因此引用本身也是合法的无引号 CSS `url()`;仓库外构建的包必须输出同样的形式。除非页面的载体提供了 `resolveAsset`,这个调用都返回文档相对引用;WebWorker 预览的页面无法请求该引用,它把引用映射为 Worker 镜像中对应字节的 Blob URL。任何 `/plugins/<package>/assets/` 路径都只由资源路由响应:对于活动的客户端 row,Host 对路径做一次百分号解码,并从该包自身的 manifest(元数据清单)出发,用 Node 的 `require.resolve` 解析 `<package>/assets/<path>`,因此由包的导出决定哪些文件存在。无法解析的路径、扩展名不是 `.svg`、`.png`、`.webp`、`.jpg` 或 `.jpeg` 的已解析文件、格式错误的转义以及未激活的包都返回 404;查询串不参与选择。提供的资源每次请求都从磁盘流式读取且不缓存,并携带其媒体类型、与 bundle 相同的 `cache-control: public, max-age=31536000, immutable`、`etag: "<requested path>"` 和 `x-content-type-options: nosniff`;SVG 还额外携带 `content-security-policy: script-src 'none'`,因此直接打开的 SVG 不会运行脚本。`If-None-Match` 等于该 etag 时返回 304 且不打开文件,`HEAD` 的响应不带 body。`fetchBundle()` 返回相同的响应,其流式 body 在取消时关闭文件。
### 浏览器加载什么
application combo 脚本只携带每个插件的 `client.js` 入口,并在启动时仅注册一次这些 factory;模块主体仍保持惰性,只在首次 import 或物化时运行。经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。共享 combo URL 的 row 共用一个进行中的脚本任务。`<script>` 加载失败的 combo 会再请求一次;加载成功但没有注册某条 row 的 combo 绝不会重新执行,因为批量脚本按顺序注册各个包,重放会在第一个重复注册处停止。两种情况下,每条仍缺失的 row 随后加载自己的单资源 combo URL,因此一个失败的 batch 对每条缺失 row 最多花费三次请求,且不影响它已经注册的 row。模块系统按 row 记录最后一次 import 失败(传输、注册、依赖级联或 factory 执行);Web 启动审计按条目报告该文本。HMR(热模块替换)会让一条发生变化的 row 改用带 revision 的单资源 combo URL。`<id>/client` 与裸 id 解析到同一组导出,因为插件 bundle 就是其包的客户端半侧。
@@ -74,7 +79,7 @@ application combo 脚本只携带每个插件的 `client.js` 入口,并在启
### 增量组合
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。包元数据按 Loader specifier 与所属 tree base URL 缓存至重启,解析出的 manifest(元数据清单)包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。bundle 内容变更只能通过 `rebuilt()`(HMR 钩子)进入图。
Node 半侧逐包增量扫描——没有全量重扫路径。每次发出 `internal/plugin` 事件时,系统都会把该 fiber 的 entry 名标脏;微任务 flush 会把每个脏名与当前 loader 条目对账,激活 pass 会初始化同一个脏集合并同步 flush,因此首次扫描与稳态共用同一实现。每次对账都会重新读取包 manifest 与 bundle 元数据,因此包在重新安装、升级或移动之后重启的条目会发布其当前的 `dsh.client` 声明与客户端导出;读到相同 manifest 与 bundle 元数据的对账不发布任何内容,也不读取 bundle 字节;未变化的 bundle 保留其 revision,因此浏览器会保留已加载的插件。解析出的 manifest 包名作为浏览器模块身份。若不同的 active Loader source 解析到同一包名,组合会失败;移除冲突来源后,剩余来源无需重启 fiber 即可接替。没有伴随条目重启的 bundle 重建经 `rebuilt()`(HMR 钩子)进入图。
Node 半侧会在发布前快照每个 `client.js` 入口,并在不构建响应 body 的情况下创建 combo descriptor。它把资源分组为 combo 路由键(`/plugins/??...&rev=...`):modules row 使用一个 bootstrap combo,其余 row 使用一个或多个 application combo;每个阶段都会在 URL 超过 3 KiB 之前分区。启动图与批次描述符携带同一路由键的应用目录相对形式(`plugins/??...&rev=...`),浏览器文档会按其自身挂载解析它,而响应表仍以绝对路由为键;source-map trailer 则相对该 combo 脚本自身目录解析。脚本 body 在首次 `GET` 时只组合一次,并以对应的 map 引用结尾;map 文件则在首次 map `GET` 时单独读取、校验并组合,`HEAD` 不会物化任一 body。Host 不扫描也不预加载同级 chunk:精确的 `/plugins/<package>/client.<name>.js?rev=<rev>` 请求会通过异步文件 API 读取该脚本并缓存,因此首次异步读取时才物化文件的文件系统也能提供它;其 map 仍会等到 map URL 被请求后才计算。浏览器从所属 row 派生出该 chunk 引用,因此它同样是文档相对形式(`plugins/<package>/client.<name>.js?rev=<rev>`);chunk 脚本携带的 map trailer 则是裸文件名 `client.<name>.js.map?rev=<rev>`,由该脚本自身目录解析。每个 combo 或 chunk map 都是 Indexed Source Map v3,并在可用时使用作者提供的 section,否则为已打包 bundle 生成 identity section。首次发布与 HMR 都从入口的 `mtimeMs`、`ctimeMs` 和大小派生逐插件 revision,不对产物内容求哈希。状态变更时间用于区分保留 mtime 和大小的重写。未变化的产物因此会跨 Host 重启保持 revision,SSE 重连不会替换其浏览器插件。共享预设会在该包所有输出写完后标记 `client.js`,因此仅 chunk 发生重建也会更换 owner revision,无需 Host 扫描 chunk。combo revision 从有序 row revision 派生。已公告的 combo 响应与已请求的 chunk 响应会跨无关图重组保持不可变;未知资源或 revision 返回 404。
@@ -93,6 +98,7 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | Node 半侧:`ClientModuleRegistry`、扫描、产物快照、可选 combo 路由、结构化 index 行 |
| [`src/assets.ts`](src/assets.ts) | 包资源的媒体类型、导出、内容 revision、带 revision 的文件名与浏览器引用,由构建预设、资源路由和门禁共用 |
| [`src/client/index.ts`](src/client/index.ts) | 浏览器半侧:bootstrap 导出、`ctx.modules` 登记 |
| [`src/client/system.ts`](src/client/system.ts) | `ClientModuleSystem`:加载/物化/失效机制 |
| [`src/client/entries.ts`](src/client/entries.ts) | 页面条目对账、重试与代码替换 |
@@ -139,6 +145,8 @@ bundle 路由随注入的 `webServer` 生命周期注册:服务就绪时注册
- **Bootstrap 与代码替换限制**——页面保留 modules bootstrap 和静态平台模块的身份。移除或替换 bootstrap 需要刷新页面;动态替换请求会报告页面本地错误,并保留其 fiber 与导出;替换包代码及其所有现有消费者不属于普通启停同步。
- **惰性提供会保留已请求的 body**——Host 在内存中保留每个 bundle 与惰性响应计划;脚本或 map body 在首次 `GET` 后保留缓存,HMR 还会保留上一代启动响应。内存仅随客户端实际请求的响应 body 增长,同时保留一代竞态容忍。
- **从未请求的上一代 map 会读取当前 map 文件**——combo revision 跟踪可执行 bundle,而不跟踪调试产物。若 HMR 在保留的旧 URL 首次收到 map `GET` 前重建 map,该响应会把当前 authored map 与旧 bundle offset 组合;在重建前请求 map 会固定该 URL 的响应。
- **仅编辑 manifest 不会被察觉**——Host 在包的 Loader 条目启动或重启时读取包 manifest;只编辑 `package.json` 要到下一次条目重启或 Host 重启时才生效。资源路由经 Node 的包配置缓存解析,因此原地修改的 `./assets/*` 导出要等 boot HMR 让该包的缓存失效、或 Host 重启后才生效。
- **Desktop 每次显示都重新读取包资源**——Desktop 转发器把每个 `/plugins/` 缓存头改写为 `no-store`,因此不可变的资源响应头到达不了其浏览器缓存。
<a id="dev-note"></a>
### 开发备注
+86
View File
@@ -0,0 +1,86 @@
/**
* Package-asset format shared by the client build preset, the Host asset
* route, and repository gates: the supported media types, the export that
* publishes built assets, the content revision, and the document-relative
* browser reference. Changing any value here changes a published URL or
* package-manifest requirement, so the preset, the route, and the gates read
* it from this one module.
* @module @deepseek-ai/dsh-client-modules/src/assets
*/
import { createHash } from 'node:crypto'
/** Route segment between a package name and its asset path. */
export const PACKAGE_ASSET_SEGMENT = 'assets'
/** Media type served for each supported asset extension, keyed by the lowercase extension with its dot. */
export const PACKAGE_ASSET_MEDIA_TYPES: ReadonlyMap<string, string> = new Map([
['.svg', 'image/svg+xml'],
['.png', 'image/png'],
['.webp', 'image/webp'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
])
/** Package export key that publishes built assets. */
export const PACKAGE_ASSET_EXPORT = './assets/*'
/** The only accepted target of {@link PACKAGE_ASSET_EXPORT}. */
export const PACKAGE_ASSET_EXPORT_TARGET = './lib/assets/*'
/** The `files` entry that publishes {@link PACKAGE_ASSET_EXPORT_TARGET}. */
export const PACKAGE_ASSET_FILES_ENTRY = 'lib/assets'
/** Hex digits kept from the content digest; the same length as client artifact revisions. */
const PACKAGE_ASSET_REVISION_LENGTH = 12
/**
* Look up the media type of an asset path.
* @param assetPath - file path or URL path; only its final extension is read, case-insensitively.
* @returns the served media type, or undefined when the extension is unsupported.
*/
export function packageAssetMediaType(assetPath: string): string | undefined {
const name = assetPath.slice(Math.max(assetPath.lastIndexOf('/'), assetPath.lastIndexOf('\\')) + 1)
const dot = name.lastIndexOf('.')
return dot <= 0 ? undefined : PACKAGE_ASSET_MEDIA_TYPES.get(name.slice(dot).toLowerCase())
}
/**
* Compute the content revision of one built asset. The build writes it into
* the emitted file name, so a URL names exactly one content and a rebuilt
* asset gets a new URL.
* @param bytes - exact bytes written under `lib/assets/`.
* @returns the first 12 hexadecimal digits of the SHA-1 digest.
*/
export function packageAssetRevision(bytes: Uint8Array): string {
return createHash('sha1').update(bytes).digest('hex').slice(0, PACKAGE_ASSET_REVISION_LENGTH)
}
/**
* Name the emitted file of one asset by inserting its revision before the extension.
* @param assetPath - `/`-separated path below `src/client/assets/`, such as `icons/[email protected]`.
* @param rev - {@link packageAssetRevision} of the asset bytes.
* @returns the path below `lib/assets/`, such as `icons/whale@2x-<rev>.png`.
*/
export function packageAssetRevisionedPath(assetPath: string, rev: string): string {
const dot = assetPath.lastIndexOf('.')
return dot > assetPath.lastIndexOf('/') + 1
? `${assetPath.slice(0, dot)}-${rev}${assetPath.slice(dot)}`
: `${assetPath}-${rev}`
}
/**
* Build the browser reference to one built asset. The reference is relative
* to the shell document, like every other app-owned route, so it resolves
* under any document mount and from injected `<style>` text.
* @param packageName - owning package name, emitted verbatim; a scoped name keeps its `/`.
* @param emittedPath - {@link packageAssetRevisionedPath} result below `lib/assets/`; each segment is
* percent-encoded, including `!`, `'`, `(`, `)`, and `*`, so the reference is also a valid unquoted CSS `url()`.
* @returns `plugins/<packageName>/assets/<path>`.
*/
export function packageAssetReference(packageName: string, emittedPath: string): string {
const path = emittedPath.split('/')
.map(segment => encodeURIComponent(segment).replace(/[!'()*]/gu, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
.join('/')
return `plugins/${packageName}/${PACKAGE_ASSET_SEGMENT}/${path}`
}
@@ -48,6 +48,7 @@ export function createClientModuleSystem(
registrationTarget: target,
bootstrapModule,
...(options.loadBundle === undefined ? {} : { loadBundle: options.loadBundle }),
...(options.resolveAsset === undefined ? {} : { resolveAsset: options.resolveAsset }),
})
}
@@ -351,6 +351,12 @@ export interface ClientBundleRequire {
(specifier: string): unknown
/** Load and resolve a package-local dynamic chunk asynchronously. */
async(specifier: string): Promise<unknown>
/**
* Resolve a package-asset reference (`plugins/<package>/assets/<path>`) the
* build compiled from an asset import or stylesheet `url()` to the URL the
* page loads; the reference itself unless the carrier maps it.
*/
asset(reference: string): string
}
/** One client bundle's factory registration submitted through `window.__ModuleLoader__.load`. */
@@ -375,6 +381,8 @@ export interface ClientModuleCreateOptions {
staticModules: Record<string, unknown>
/** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
loadBundle?: (url: string) => Promise<void>
/** Package-asset reference hook behind {@link ClientBundleRequire.asset}. Defaults to returning the reference. */
resolveAsset?: (reference: string) => string
}
/** The modules bundle after its factory has been materialized by the HTML bootstrap facade. */
@@ -485,4 +493,6 @@ export interface ClientModuleSystemOptions {
bootstrapModule: ClientBootstrapModule
/** Bundle-load hook. Defaults to a same-origin classic `<script src>` element. */
loadBundle?: (url: string) => Promise<void>
/** Package-asset reference hook behind {@link ClientBundleRequire.asset}. Defaults to returning the reference. */
resolveAsset?: (reference: string) => string
}
+4 -1
View File
@@ -111,6 +111,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
private readonly materializing = new Set<string>()
private readonly graphRows = new Map<string, BootModuleRow>()
private readonly loadBundle: (url: string) => Promise<void>
private readonly resolveAsset: (reference: string) => string
/** Last import or prefetch failure per graph row, cleared by a later success or invalidation. */
private readonly importErrors = new Map<string, Error>()
/** Batch URLs whose transport or execution already failed; rows still missing from them go straight to their one-resource URL. */
@@ -134,6 +135,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
})
this.seed = new Map(Object.entries(options.staticModules))
this.loadBundle = options.loadBundle ?? defaultLoadBundle
this.resolveAsset = options.resolveAsset ?? (reference => reference)
for (const row of options.manifest.modules) {
this.graphRows.set(row.id, row)
@@ -321,7 +323,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
}
}
/** Build the synchronous module-table require and its asynchronous chunk operation. */
/** Build the synchronous module-table require, its asynchronous chunk operation, and its asset resolver. */
private makeRequire(ownerId: string, edges: Set<string>): ClientBundleRequire {
const require = (spec: string): unknown => {
edges.add(spec)
@@ -335,6 +337,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
+ 'and no registered package factory (a build-time externals drift, or a dynamic dependency that did not arrive)',
)
}
require.asset = (reference: string): string => this.resolveAsset(reference)
require.async = async (spec: string): Promise<unknown> => {
edges.add(spec)
if (!spec.startsWith('./')) return await this.import(spec)
+147 -55
View File
@@ -3,7 +3,8 @@
* the host Loader's entries for packages declaring `dsh.client`, composes the
* `window.__DSH_BOOT__` entry graph (wire single source: {@link WebBootEntry}
* in `./client/manifest.ts`) in module-graph order, serves one-or-more-plugin
* combo scripts plus their source maps,
* combo scripts plus their source maps, package-local chunks, and the
* content-revisioned `./assets/*` exports of active client packages,
* contributes the registration facade, application preloads, bootstrap scripts,
* and graph to the webserver's index injection table, and provides the
* `clientModules` service (the HMR node half's registration/notification
@@ -14,26 +15,31 @@
* the fiber's entry name dirty; a microtask flush reconciles each dirty name
* against the live loader entries. The activation pass seeds the same dirty
* set with all current entries and flushes synchronously, so first scan and
* steady state share one implementation. Package metadata (including the
* negative "not a client package" verdict) is cached per Loader specifier and
* owning-tree base URL until restart. The manifest package name identifies
* the browser module; distinct active Loader sources for that package are a
* composition error. Bundle content changes reach the graph only through
* {@link ClientModuleRegistry.rebuilt}.
* steady state share one implementation. Each reconciliation reads the
* package manifest and the bundle metadata again, so an entry that restarts
* after its package was reinstalled or moved publishes them as they are now;
* an unchanged result publishes nothing and does not read the bundle. The
* manifest package name identifies the browser module; distinct active Loader
* sources for that package are a composition error. Bundle rebuilds outside
* an entry restart reach the graph through {@link ClientModuleRegistry.rebuilt}.
* @module @deepseek-ai/dsh-client-modules
*/
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, statSync } from 'node:fs'
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { dirname, isAbsolute, join } from 'node:path'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { isDeepStrictEqual } from 'node:util'
import { Service } from '@deepseek-ai/cordis'
import type { Context } from '@deepseek-ai/cordis'
import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
import { PACKAGE_ASSET_SEGMENT, packageAssetMediaType } from './assets.ts'
import { exactPackageSpecifier, parseDshClient, stripClientSuffix } from './client/manifest.ts'
import type { WebBootBatch, WebBootBatchPhase, WebBootEntry, WebBootGraph, WebBootRelease } from './client/manifest.ts'
@@ -69,9 +75,11 @@ export interface ClientArtifactBaseline {
readonly size: number
}
/** Resolved metadata cached for one Loader specifier and owning-tree base URL until restart. */
/** Package metadata read from the manifest of one active Loader source. */
interface PkgMeta extends WebBootRowFields {
clientPath: string
/** The package's own manifest; package-asset requests resolve against it as a self-reference. */
manifestPath: string
}
interface ResolvedPkgMeta {
@@ -85,7 +93,7 @@ interface ClientPackageSource extends ResolvedPkgMeta {
loaderName: string
/** Resolution base of the config tree that owns the row. */
baseUrl: string
/** Stable cache and contribution key for this source. */
/** Stable source identity and contribution key. */
sourceKey: string
}
@@ -225,6 +233,28 @@ function artifactRevision(baseline: ClientArtifactBaseline): string {
/** Absolute route prefix serving every plugin resource. */
const PLUGIN_ROUTE = '/plugins'
/**
* `/plugins/<package>/assets/<path>`. A scoped package name spans two segments
* (`@scope/name`); an unscoped name never starts with `@`, so `@scope/assets/…`
* stays a chunk request of the package `@scope/assets`.
*/
const PACKAGE_ASSET_PATH = /^\/plugins\/(@[^/]+\/[^/]+|[^@/][^/]*)\/assets\/(.*)$/
/** Expose a Node read stream as a shell-carrier Response body; cancelling the body closes the stream. */
function transforStreamBody(stream: Readable): ReadableStream<Uint8Array> {
const chunks: AsyncIterator<Uint8Array> = stream[Symbol.asyncIterator]()
return new ReadableStream<Uint8Array>({
async pull(controller) {
const next = await chunks.next()
if (next.done === true) controller.close()
else controller.enqueue(next.value)
},
async cancel() {
await chunks.return?.()
},
})
}
/** Combo query addressing one ordered plugin-file list. */
function comboSearch(ids: readonly string[], rev: string, sourceMap = false): string {
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
@@ -595,8 +625,8 @@ window.__ModuleLoader__={
/**
* The web plugin table service: incremental `dsh.client` scan + wire composition
* + bundle route + index injection rows. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* + bundle and package-asset route + index injection rows. Construction runs
* the activation scan synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
* boot activation audit reports it).
*/
@@ -610,9 +640,6 @@ export class ClientModuleRegistry extends Service {
private readonly release: WebBootRelease
private readonly table = new Map<string, WebPluginRecord>()
private readonly sources = new Map<string, ClientPackageSource>()
// Resolution is entry-local: the same specifier can resolve differently in
// separate config trees. Negative verdicts remain stable until restart.
private readonly pkgMeta = new Map<string, ResolvedPkgMeta | null>()
private readonly rebuildListeners = new Set<(id: string, rev: string) => void>()
private readonly graphListeners = new Set<() => void>()
private readonly dirty = new Set<string>()
@@ -690,16 +717,20 @@ export class ClientModuleRegistry extends Service {
}
/**
* Serve an advertised revisioned bundle or source map without a Web server.
* Unknown URLs return 404, unsupported methods return 405, and `HEAD`
* returns the same immutable headers without materializing a body. Each body
* is built once on its first `GET`; script construction never reads maps.
* Serve an advertised revisioned bundle, source map, chunk, or package asset
* without a Web server. Unknown URLs return 404, unsupported methods return
* 405, and `HEAD` returns the same immutable headers without materializing a
* body. Each bundle body is built once on its first `GET`; script
* construction never reads maps. A package asset is read on every request
* and answers 304 without a body when `If-None-Match` equals its entity tag;
* its body holds the file open until the caller consumes or cancels it.
* @param request - shell-carrier request for a `/plugins` resource.
* @returns the exact response also exposed by the optional Web route.
*/
async fetchBundle(request: Request): Promise<Response> {
const resource = await this.bundleResource(request.method, request.url)
const body = resource.body === undefined ? null : Uint8Array.from(resource.body)
const resource = await this.bundleResource(request.method, request.url, request.headers.get('if-none-match') ?? undefined)
const body = resource.body === undefined ? null
: resource.body instanceof Readable ? transforStreamBody(resource.body) : Uint8Array.from(resource.body)
return new Response(body, {
status: resource.status,
...(resource.headers === undefined ? {} : { headers: resource.headers }),
@@ -721,7 +752,7 @@ export class ClientModuleRegistry extends Service {
/**
* Publish one completed bundle generation (the HMR watch's registration
* hook — the only entry point through which build changes reach the graph).
* hook; an entry restart publishes through reconciliation instead).
* Unchanged mtime, ctime and size preserve the graph without reading the bundle.
* @param id - entry id (package name).
* @returns the current artifact rev, or undefined for an unknown id.
@@ -751,7 +782,8 @@ export class ClientModuleRegistry extends Service {
}
/**
* Subscribe to bundle rebuilds; fires only when artifact metadata changes the rev.
* Subscribe to bundle rebuilds; fires when {@link rebuilt} changes a rev. A rev an
* entry restart publishes reaches only {@link onGraphChanged} listeners.
* @param listener - receives the entry id and its new bundle rev.
* @returns the unsubscriber.
*/
@@ -842,14 +874,10 @@ export class ClientModuleRegistry extends Service {
}
private resolveMeta(loaderName: string, baseUrl: string): ResolvedPkgMeta | null {
const sourceKey = this.sourceKey(loaderName, baseUrl)
const cached = this.pkgMeta.get(sourceKey)
if (cached !== undefined) return cached
const located = this.locatePkgJson(loaderName, baseUrl)
if (located === undefined) {
// Not a resolvable package root: loader builtins (cordis:include) and
// subpath entries (…/gateway) land here — permanently not a client row.
this.pkgMeta.set(sourceKey, null)
// subpath entries (…/gateway) land here — not a client row.
return null
}
const { packageName, path: pkgPath } = located
@@ -859,23 +887,19 @@ export class ClientModuleRegistry extends Service {
packageName,
dsh !== null && typeof dsh === 'object' ? (dsh as Record<string, unknown>).client : undefined,
)
if (decl === undefined || decl.platform !== 'web') {
this.pkgMeta.set(sourceKey, null)
return null
}
if (decl === undefined || decl.platform !== 'web') return null
const clientRel = clientExportOf(packageName, pkg.exports)
if (clientRel === undefined) {
throw new Error(`client-modules: ${packageName} declares dsh.client but exports no "./client" bundle`)
}
const meta: PkgMeta = {
clientPath: join(dirname(pkgPath), clientRel),
manifestPath: pkgPath,
...(decl.inject !== undefined ? { inject: decl.inject } : {}),
external: decl.external ?? [],
immediately: decl.immediately === true,
}
const resolved = { packageName, meta }
this.pkgMeta.set(sourceKey, resolved)
return resolved
return { packageName, meta }
}
/**
@@ -920,7 +944,7 @@ export class ClientModuleRegistry extends Service {
: internal.resolveSync(loaderName, baseUrl, {}).url
} catch {
// The Loader cannot resolve the name: its row cannot have imported, so
// the name is permanently not a client row.
// it is not a client row.
return undefined
}
return this.nearestPackage(moduleUrl, expectedPackageName)
@@ -968,20 +992,16 @@ export class ClientModuleRegistry extends Service {
}
/**
* Read the activation-time bundle snapshot.
* Run one filesystem read of a declared client bundle.
* @param pkgName - package that declares the client bundle.
* @param clientPath - absolute path of the built client artifact.
* @returns the immutable bytes plus the pre-read filesystem baseline.
* @param read - the stat or read of `clientPath`.
* @returns the read's result.
* @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
*/
private initialBundleSnapshot(pkgName: string, clientPath: string): {
bundle: Buffer
baseline: ClientArtifactBaseline
} {
private readClientBundle<T>(pkgName: string, clientPath: string, read: () => T): T {
try {
const baseline = this.captureArtifactBaseline(clientPath)
const bundle = readFileSync(clientPath)
return { bundle, baseline }
return read()
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
throw new MissingClientBundleError(pkgName, clientPath, error)
@@ -1054,17 +1074,22 @@ export class ClientModuleRegistry extends Service {
}
const source = sources[0]
if (source === undefined) return this.table.delete(packageName)
if (this.table.get(packageName)?.sourceKey === source.sourceKey) return false
const { clientPath } = source.meta
// Startup and HMR share revisions so unchanged artifacts survive a server restart.
const snapshot = this.initialBundleSnapshot(packageName, source.meta.clientPath)
const rev = artifactRevision(snapshot.baseline)
const baseline = this.readClientBundle(packageName, clientPath, () => this.captureArtifactBaseline(clientPath))
const entry = graphRow(packageName, artifactRevision(baseline), source.meta)
const current = this.table.get(packageName)
// A restart that finds the same manifest and bundle metadata leaves the published graph as it is, unread.
const unchanged = current?.sourceKey === source.sourceKey
&& isDeepStrictEqual(current.meta, source.meta) && isDeepStrictEqual(current.entry, entry)
if (unchanged) return false
this.table.set(packageName, {
entry: graphRow(packageName, rev, source.meta),
entry,
loaderName: source.loaderName,
sourceKey: source.sourceKey,
meta: source.meta,
bundle: snapshot.bundle,
baseline: snapshot.baseline,
bundle: this.readClientBundle(packageName, clientPath, () => readFileSync(clientPath)),
baseline,
})
return true
}
@@ -1150,13 +1175,71 @@ export class ClientModuleRegistry extends Service {
return response
}
private async bundleResource(method: string | undefined, url: string): Promise<{
/**
* Answer one package-asset request. Node's `require.resolve` applies the
* package's `./assets/*` export as a self-reference, so unexported,
* `.`/`..`, missing, and directory paths answer 404. The build writes each
* asset's content revision into its file name, so a served path names one
* content: the body streams from disk on every request, and the path is the
* entity tag.
* @param method - request method.
* @param record - the active row of the addressed package, or undefined when it is not active.
* @param assetPath - percent-encoded path below the package's `assets/` route segment.
* @param ifNoneMatch - request `If-None-Match` field.
* @returns the route answer.
*/
private assetResponse(
method: 'GET' | 'HEAD',
record: WebPluginRecord | undefined,
assetPath: string,
ifNoneMatch: string | undefined,
): {
status: number
headers?: Record<string, string>
body?: Buffer
body?: Readable
} {
if (record === undefined || assetPath === '') return { status: 404 }
let file: string
try {
file = createRequire(record.meta.manifestPath)
.resolve(`${record.entry.id}/${PACKAGE_ASSET_SEGMENT}/${decodeURIComponent(assetPath)}`)
} catch {
// Unresolvable or malformed paths name no asset.
return { status: 404 }
}
// The resolved file decides the type: Node reads `#` and `?` in the subpath as URL syntax.
const mediaType = packageAssetMediaType(file)
if (mediaType === undefined) return { status: 404 }
const headers: Record<string, string> = {
'content-type': mediaType,
'cache-control': IMMUTABLE_CACHE,
etag: `"${assetPath}"`,
'x-content-type-options': 'nosniff',
// A directly opened SVG document runs no script.
...(mediaType === 'image/svg+xml' ? { 'content-security-policy': "script-src 'none'" } : {}),
}
if (ifNoneMatch === headers.etag) return { status: 304, headers }
return { status: 200, headers, ...(method === 'HEAD' ? {} : { body: createReadStream(file) }) }
}
/**
* Answer one `/plugins` request. A `/plugins/<package>/assets/` path is
* answered by the package-asset route alone; any other path is an advertised
* combo or its map, a prior startup generation, or a current-revision
* package-local chunk, in that order, and otherwise 404.
*/
private async bundleResource(method: string | undefined, url: string, ifNoneMatch: string | undefined): Promise<{
status: number
headers?: Record<string, string>
body?: Buffer | Readable
}> {
if (method !== 'GET' && method !== 'HEAD') return { status: 405 }
const requestUrl = new URL(url, 'http://x')
const asset = PACKAGE_ASSET_PATH.exec(requestUrl.pathname)
if (asset !== null) {
const [, packageName = '', assetPath = ''] = asset
return this.assetResponse(method, this.table.get(packageName), assetPath, ifNoneMatch)
}
const resourceUrl = `${requestUrl.pathname}${requestUrl.search}`
const response = this.responses.get(resourceUrl)
?? this.previousBatchResponses.get(resourceUrl)
@@ -1175,9 +1258,18 @@ export class ClientModuleRegistry extends Service {
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
const response = await this.bundleResource(req.method, req.url ?? '/')
const response = await this.bundleResource(req.method, req.url ?? '/', req.headers['if-none-match'])
res.writeHead(response.status, response.headers)
res.end(response.body)
if (!(response.body instanceof Readable)) {
res.end(response.body)
return
}
try {
await pipeline(response.body, res)
} catch (error) {
// The browser cancelled the request, as leaving an onboarding step does; pipeline already closed the file.
if ((error as NodeJS.ErrnoException).code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error
}
}
}
@@ -0,0 +1,87 @@
/** Package-asset format: media types, content revisions, revisioned file names, and document-relative references. */
import { describe, expect, it } from 'vitest'
import {
PACKAGE_ASSET_EXPORT,
PACKAGE_ASSET_EXPORT_TARGET,
PACKAGE_ASSET_FILES_ENTRY,
PACKAGE_ASSET_SEGMENT,
packageAssetMediaType,
packageAssetReference,
packageAssetRevision,
packageAssetRevisionedPath,
} from '../src/assets.ts'
describe('packageAssetMediaType', () => {
it.each([
['icon.svg', 'image/svg+xml'],
['photo.png', 'image/png'],
['nested/photo.webp', 'image/webp'],
['photo.jpg', 'image/jpeg'],
['photo.jpeg', 'image/jpeg'],
['PHOTO.PNG', 'image/png'],
['Icon.SvG', 'image/svg+xml'],
['archive.txt.png', 'image/png'],
['nested\\photo.png', 'image/png'],
['C:\\assets\\photo.webp', 'image/webp'],
])('serves %s as %s', (assetPath, mediaType) => {
expect(packageAssetMediaType(assetPath)).toBe(mediaType)
})
it.each([
['photo', 'no extension'],
['.png', 'a dotfile'],
['nested/.png', 'a nested dotfile'],
['photo.png/readme', 'an extension on a parent segment only'],
['photo.png\\readme', 'an extension before a backslash separator'],
['photo.gif', 'an unsupported extension'],
['photo.png.txt', 'an unsupported final extension'],
['photo.', 'an empty extension'],
])('serves nothing for %s (%s)', (assetPath) => {
expect(packageAssetMediaType(assetPath)).toBeUndefined()
})
})
describe('packageAssetRevision', () => {
it('keeps the first 12 hexadecimal digits of the SHA-1 content digest', () => {
expect(packageAssetRevision(new TextEncoder().encode('abc'))).toBe('a9993e364706')
expect(packageAssetRevision(new Uint8Array())).toBe('da39a3ee5e6b')
})
it('changes with the asset bytes', () => {
expect(packageAssetRevision(Uint8Array.of(1))).not.toBe(packageAssetRevision(Uint8Array.of(2)))
})
})
describe('packageAssetRevisionedPath', () => {
it.each([
['photo.png', 'photo-a9993e364706.png'],
['icons/[email protected]', 'icons/[email protected]'],
['icons/badge.zh.svg', 'icons/badge.zh-a9993e364706.svg'],
['icons.v2/photo', 'icons.v2/photo-a9993e364706'],
['icons/.png', 'icons/.png-a9993e364706'],
])('inserts the revision before the extension of %s', (assetPath, emitted) => {
expect(packageAssetRevisionedPath(assetPath, 'a9993e364706')).toBe(emitted)
})
})
describe('packageAssetReference', () => {
it('keeps a scoped package name verbatim and encodes each path segment', () => {
expect(packageAssetReference('@scope/pkg', 'icons/a b#?%-a9993e364706.svg'))
.toBe('plugins/@scope/pkg/assets/icons/a%20b%23%3F%25-a9993e364706.svg')
// Characters encodeURIComponent keeps would end an unquoted CSS url().
expect(packageAssetReference('plain', "whale(2x)!'*-a9993e364706.png"))
.toBe('plugins/plain/assets/whale%282x%29%21%27%2A-a9993e364706.png')
})
it('addresses the document-relative form of the plugin asset route without a query', () => {
const reference = packageAssetReference('plain', 'photo-a9993e364706.png')
expect(reference).toBe('plugins/plain/assets/photo-a9993e364706.png')
expect(reference.startsWith(`plugins/plain/${PACKAGE_ASSET_SEGMENT}/`)).toBe(true)
})
it('pairs the asset export with its only accepted target and published files entry', () => {
expect(PACKAGE_ASSET_EXPORT).toBe('./assets/*')
expect(PACKAGE_ASSET_EXPORT_TARGET).toBe(`./${PACKAGE_ASSET_FILES_ENTRY}/*`)
})
})
@@ -81,6 +81,7 @@ function bench(
transportFailures?: Record<string, number>
/** URLs whose script loads but registers only the listed ids (a runtime throw after those registrations). */
registerOnly?: Record<string, string[]>
resolveAsset?: (reference: string) => string
} = {},
): Bench {
const fetched: string[] = []
@@ -144,6 +145,7 @@ function bench(
},
staticModules: opts.seed ?? {},
...(opts.defaultTransport === true ? {} : { loadBundle }),
...(opts.resolveAsset === undefined ? {} : { resolveAsset: opts.resolveAsset }),
})
return { loader, target, fetched, gates }
}
@@ -249,6 +251,16 @@ describe('lazy CJS arrival', () => {
expect(b.fetched).toHaveLength(1)
})
it('resolves a package-asset reference through the carrier, or returns it unchanged without one', async () => {
const reference = 'plugins/a/assets/mark-0123456789ab.svg'
const factory: Factory = req => ({ url: req.asset(reference) })
const served = bench([row('a')], { a: factory })
expect(await served.loader.import('a', '', {})).toEqual({ url: reference })
const mapped = bench([row('a')], { a: factory }, { resolveAsset: asset => `blob:${asset}` })
expect(await mapped.loader.import('a', '', {})).toEqual({ url: `blob:${reference}` })
})
it('loads a package-local dynamic chunk only when its factory requests it', async () => {
const b = bench([row('a')], {
a: req => ({ load: () => req.async('./client.terminal.js') }),
@@ -224,7 +224,7 @@ async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET
return response
},
} as unknown as ServerResponse
await (await route).handler({ method, url } as IncomingMessage, response)
await (await route).handler({ method, url, headers: {} } as IncomingMessage, response)
return { status, headers, body }
}
@@ -967,6 +967,20 @@ describe('client bundle activation', () => {
expect((await routeRequest(route, url.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
})
it('serves a chunk of a package named @scope/assets instead of treating it as an asset request', async () => {
const packageName = '@fixture/assets'
const clientPath = writePackage(packageName)
mkdirSync(dirname(clientPath), { recursive: true })
writeFileSync(clientPath, 'module.exports = {}\n')
writeFileSync(join(dirname(clientPath), 'client.extra.js'), 'module.exports = { marker: "extra chunk" }\n')
const { service, route } = constructWithRoute([packageName])
const row = service.graph().entries[0]!
const chunk = await routeRequest(route, chunkReference(packageName, 'client.extra.js', row.rev))
expect(chunk.status).toBe(200)
expect(chunk.body.toString('utf8')).toContain('extra chunk')
})
it('reads a package-local chunk and its map body only through the asynchronous filesystem face', async () => {
const packageName = '@fixture/async-chunk'
const clientPath = writePackage(packageName)
@@ -1245,3 +1259,72 @@ describe('module graph order', () => {
.toThrow('module graph cycle @fixture/cycle-a -> @fixture/cycle-b -> @fixture/cycle-a')
})
})
describe('package manifest refresh', () => {
/** Rewrite a fixture package's manifest; its client bundle is left as it is. */
const writeManifest = (packageName: string, manifest: Record<string, unknown>): void => {
writeFileSync(join(root!, 'node_modules', ...packageName.split('/'), 'package.json'), JSON.stringify({ name: packageName, ...manifest }))
}
const clientManifest = (client: Record<string, unknown>, clientExport = './lib/client.js'): Record<string, unknown> => ({
exports: { './client': clientExport, './package.json': './package.json' },
dsh: { client: { platform: 'web', ...client } },
})
it('publishes a reinstalled declaration when its Loader entry restarts, and nothing for an unchanged restart', async () => {
const packageName = '@fixture/refresh-declaration'
writeBuiltPackage(packageName, { external: ['react'] })
const { context, service } = constructWithRoute([packageName])
const before = service.graph().entries[0]!
const changed = vi.fn()
service.onGraphChanged(changed)
const warning = vi.spyOn(context.logger, 'warn')
// An unchanged restart compares bundle metadata and never reads the bundle.
syncRefused.add(service.clientPath(packageName)!)
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(changed).not.toHaveBeenCalled()
expect(warning).not.toHaveBeenCalled()
syncRefused.delete(service.clientPath(packageName)!)
writeManifest(packageName, clientManifest({ external: ['scheduler'], inject: ['@fixture/slots'] }))
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(changed).toHaveBeenCalledTimes(1)
expect(service.graph().entries[0]).toMatchObject({ rev: before.rev, external: ['scheduler'], inject: ['@fixture/slots'] })
})
it('reads a moved client export as a new bundle generation when its Loader entry restarts', async () => {
const packageName = '@fixture/refresh-moved'
writeBuiltPackage(packageName, {})
const { context, service } = constructWithRoute([packageName])
const before = service.graph().entries[0]!
const moved = join(root!, 'node_modules', '@fixture', 'refresh-moved', 'dist', 'client.js')
mkdirSync(dirname(moved), { recursive: true })
writeFileSync(moved, 'module.exports = { moved: true }\n')
writeManifest(packageName, clientManifest({}, './dist/client.js'))
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.clientPath(packageName)).toBe(moved)
expect(service.graph().entries[0]!.rev).not.toBe(before.rev)
})
it('adds or removes the row when a restarted package gains or loses its client declaration', async () => {
const packageName = '@fixture/refresh-membership'
writeBuiltPackage(packageName, {})
writeManifest(packageName, { exports: { './package.json': './package.json' } })
const { context, service } = constructWithRoute([packageName])
expect(service.graph().entries).toEqual([])
writeManifest(packageName, clientManifest({}))
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName])
writeManifest(packageName, { exports: { './package.json': './package.json' } })
emitLoaderEntryChange(context, packageName)
await Promise.resolve()
expect(service.graph().entries).toEqual([])
})
})
@@ -0,0 +1,309 @@
/** Host package-asset route: exported `./assets/*` files streamed under their revisioned names. */
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { request } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context, LoggerLevel } from '@deepseek-ai/cordis'
import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
import WebServer from '@deepseek-ai/dsh-host-webserver'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { packageAssetReference } from '../src/assets.ts'
import { ClientModuleRegistry } from '../src/index.ts'
/** File read streams the route opens, in order, to observe hashing and body streaming. */
const opened = vi.hoisted(() => [] as { path: string; stream: ReadStream }[])
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
...actual,
createReadStream: (path: Parameters<typeof actual.createReadStream>[0], options?: Parameters<typeof actual.createReadStream>[1]) => {
const stream = actual.createReadStream(path, options)
opened.push({ path: String(path), stream })
return stream
},
}
})
const ASSETS_ID = '@fixture/assets'
const NO_EXPORT_ID = '@fixture/no-export'
const INACTIVE_ID = '@fixture/inactive'
/** Response headers the asset route owns. */
const ASSET_HEADERS = ['content-type', 'cache-control', 'etag', 'x-content-type-options', 'content-security-policy'] as const
const IMMUTABLE = 'public, max-age=31536000, immutable'
const SVG = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>\n')
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1])
const WEBP = Buffer.from('RIFF....WEBP')
let root: string | undefined
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
if (root !== undefined) rmSync(root, { recursive: true, force: true })
root = undefined
opened.length = 0
})
/** Own one fresh temporary directory per test. */
function fixtureRoot(): string {
root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-assets-')))
return root
}
/** Write one file, creating its parent directories. */
function writeFile(path: string, contents: string | Buffer): string {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, contents)
return path
}
/** Create a built client package below the fixture's `node_modules`; returns its `lib/assets` directory. */
function writeClientPackage(packageName: string, exports: Record<string, string | null>): string {
const pkgRoot = join(fixtureRoot(), 'node_modules', ...packageName.split('/'))
writeFile(join(pkgRoot, 'package.json'), JSON.stringify({
name: packageName,
exports: { '.': './lib/index.js', './client': './lib/client.js', './package.json': './package.json', ...exports },
dsh: { client: { platform: 'web' } },
}))
writeFile(join(pkgRoot, 'lib', 'index.js'), 'export default {}\n')
writeFile(join(pkgRoot, 'lib', 'client.js'), 'module.exports = {}\n')
writeFile(join(pkgRoot, 'secret.png'), PNG)
return join(pkgRoot, 'lib', 'assets')
}
/** Create the active, inactive, and export-less packages; returns the active package's `lib/assets`. */
function writeFixture(): string {
const assets = writeClientPackage(ASSETS_ID, { './assets/private/*': null, './assets/*': './lib/assets/*' })
writeFile(join(assets, 'icon.svg'), SVG)
writeFile(join(assets, 'photo.png'), PNG)
writeFile(join(assets, 'nested', 'a b.webp'), WEBP)
// `*` is covered by the reference unit test; Windows cannot create it on disk.
writeFile(join(assets, "whale(2x)!'.png"), PNG)
writeFile(join(assets, 'notes.txt'), 'text\n')
writeFile(join(assets, 'folder.png', 'inner.png'), PNG)
writeFile(join(assets, 'private', 'hidden.png'), PNG)
// `a#b.png` resolves here because Node reads `#` in the subpath as a fragment.
writeFile(join(assets, 'a'), PNG)
writeFile(join(writeClientPackage(NO_EXPORT_ID, {}), 'photo.png'), PNG)
writeFile(join(writeClientPackage(INACTIVE_ID, { './assets/*': './lib/assets/*' }), 'photo.png'), PNG)
return assets
}
/** One route answer reduced to the parts the asset route owns. */
interface Answer {
status: number
headers: Partial<Record<typeof ASSET_HEADERS[number], string>>
body: Buffer
}
function assetHeaders(read: (name: string) => string | string[] | null | undefined): Answer['headers'] {
const headers: Answer['headers'] = {}
for (const name of ASSET_HEADERS) {
const value = read(name)
if (typeof value === 'string') headers[name] = value
}
return headers
}
interface AssetRequestInit {
method?: string
headers?: Record<string, string>
}
/** Composed Host: the modules service and a requester through both carriers. */
interface Host {
service: ClientModuleRegistry
/** Port of the real Web server. */
port: number
/** Warnings any plugin of the composed Host logged. */
warnings: unknown[][]
/** Send one raw request target through the Web route and the shell carrier and require identical answers. */
both: (path: string, init?: AssetRequestInit) => Promise<Answer>
}
/** Compose the real Web server and the modules service over a Loader double listing the active rows. */
async function composeHost(): Promise<Host> {
const internal = ModuleLoader.fromInternal()
if (internal === undefined) throw new Error('package-asset tests require the Node module loader internals')
const context = new Context()
contexts.push(context)
const warnings: unknown[][] = []
context.logger.exporter({ levels: { default: LoggerLevel.WARN }, export: (message) => { if (message.type === 'warn') warnings.push(message.args) } })
const baseUrl = pathToFileURL(fixtureRoot()).href + '/'
context.baseUrl = baseUrl
context.provide('loader', {
internal,
*entries() {
for (const name of [NO_EXPORT_ID, ASSETS_ID]) {
yield { options: { name }, fiber: {}, disabled: false, parent: { tree: { ctx: { baseUrl } } } }
}
},
})
await context.plugin(WebServer, { host: '127.0.0.1', port: 0 })
await context.plugin(ClientModuleRegistry)
const service = context.get('clientModules')!
const port = context.webServer.port!
const http = (path: string, init: AssetRequestInit = {}): Promise<Answer> => new Promise((resolve, reject) => {
const outgoing = request({ host: '127.0.0.1', port, path, method: init.method ?? 'GET', headers: init.headers, agent: false }, (res) => {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
res.on('error', reject)
res.on('end', () => {
resolve({ status: res.statusCode!, headers: assetHeaders(name => res.headers[name]), body: Buffer.concat(chunks) })
})
})
outgoing.on('error', reject)
outgoing.end()
})
const both = async (path: string, init?: AssetRequestInit): Promise<Answer> => {
const answer = await http(path, init)
const response = await service.fetchBundle(new Request(`http://localhost${path}`, init))
expect({
status: response.status,
headers: assetHeaders(name => response.headers.get(name)),
body: Buffer.from(await response.arrayBuffer()),
}).toEqual(answer)
return answer
}
// The Web route registers once the injected webServer is ready.
await expect.poll(async () => (await http(assetUrl(ASSETS_ID, 'photo.png'))).status).toBe(200)
return { service, port, warnings, both }
}
/** Absolute route the browser requests for the document-relative build reference to an emitted file. */
function assetUrl(packageName: string, emittedPath: string): string {
return `/${packageAssetReference(packageName, emittedPath)}`
}
describe('package asset route', () => {
it('streams exported assets with immutable typed headers and the path as entity tag', async () => {
writeFixture()
const { both } = await composeHost()
expect(await both(assetUrl(ASSETS_ID, 'icon.svg'))).toEqual({
status: 200,
headers: {
'content-type': 'image/svg+xml',
'cache-control': IMMUTABLE,
etag: '"icon.svg"',
'x-content-type-options': 'nosniff',
'content-security-policy': "script-src 'none'",
},
body: SVG,
})
expect(await both(assetUrl(ASSETS_ID, 'photo.png'))).toEqual({
status: 200,
headers: {
'content-type': 'image/png',
'cache-control': IMMUTABLE,
etag: '"photo.png"',
'x-content-type-options': 'nosniff',
},
body: PNG,
})
const nested = await both(assetUrl(ASSETS_ID, 'nested/a b.webp'))
expect([nested.status, nested.headers.etag, nested.body]).toEqual([200, '"nested/a%20b.webp"', WEBP])
// The reference encodes the characters an unquoted CSS url() rejects; the route decodes them back.
const special = await both(assetUrl(ASSETS_ID, "whale(2x)!'.png"))
expect([special.status, special.headers.etag, special.body]).toEqual([200, '"whale%282x%29%21%27.png"', PNG])
// The revision lives in the emitted name; a query does not select content.
expect((await both(`${assetUrl(ASSETS_ID, 'photo.png')}?rev=anything`)).body).toEqual(PNG)
})
it('answers HEAD with the same headers and no body, and rejects other methods', async () => {
writeFixture()
const { both } = await composeHost()
const url = assetUrl(ASSETS_ID, 'icon.svg')
const get = await both(url)
expect(await both(url, { method: 'HEAD' })).toEqual({ ...get, body: Buffer.alloc(0) })
expect((await both(url, { method: 'POST' })).status).toBe(405)
})
it('answers 304 without opening the file only when If-None-Match equals the entity tag', async () => {
const assets = writeFixture()
const { both } = await composeHost()
const url = assetUrl(ASSETS_ID, 'icon.svg')
const get = await both(url)
const opens = opened.filter(entry => entry.path === join(assets, 'icon.svg')).length
expect(await both(url, { headers: { 'if-none-match': '"icon.svg"' } }))
.toEqual({ status: 304, headers: get.headers, body: Buffer.alloc(0) })
expect(opened.filter(entry => entry.path === join(assets, 'icon.svg'))).toHaveLength(opens)
expect(await both(url, { headers: { 'if-none-match': '"photo.png"' } })).toEqual(get)
})
it('streams an asset spanning several read chunks intact through both carriers', async () => {
const assets = writeFixture()
// Several default 64 KiB read-stream chunks; small enough for the deep body comparison.
const large = Buffer.alloc(160 * 1024, 7)
writeFile(join(assets, 'large.png'), large)
const { both } = await composeHost()
const answer = await both(assetUrl(ASSETS_ID, 'large.png'))
expect(answer.status).toBe(200)
expect(answer.body.equals(large)).toBe(true)
})
it('logs nothing when the browser cancels a streamed asset response', async () => {
const assets = writeFixture()
const file = writeFile(join(assets, 'large.png'), Buffer.alloc(8 * 1024 * 1024, 7))
const { port, warnings } = await composeHost()
await new Promise<void>((resolve) => {
const outgoing = request({ host: '127.0.0.1', port, path: assetUrl(ASSETS_ID, 'large.png'), agent: false }, (res) => {
res.once('data', () => {
outgoing.destroy()
resolve()
})
})
// The destroyed request reports its own abort; the cancellation is the point of the test.
outgoing.on('error', () => undefined)
outgoing.on('close', () => { resolve() })
outgoing.end()
})
const stream = opened.findLast(entry => entry.path === file)?.stream
if (stream === undefined) throw new Error('the asset route opened no file stream')
// events.once would reject on the premature-close error the cancelled pipeline reports; only the close matters here.
if (!stream.closed) await new Promise<void>((resolve) => { stream.once('close', () => { resolve() }) })
// pipeline settles its rejection in the turn after the file stream closes; the route logs nothing then.
await new Promise(resolve => setImmediate(resolve))
expect(warnings).toEqual([])
})
it('closes the file stream when a shell-carrier body is cancelled', async () => {
const assets = writeFixture()
const { service } = await composeHost()
const response = await service.fetchBundle(new Request(`http://localhost${assetUrl(ASSETS_ID, 'photo.png')}`))
const body = opened.findLast(entry => entry.path === join(assets, 'photo.png'))?.stream
await response.body?.cancel()
expect(body?.destroyed).toBe(true)
})
const base = `/plugins/${ASSETS_ID}/assets`
it.each([
['an excluded export subpath', `${base}/private/hidden.png`],
['a package without the asset export', `/plugins/${NO_EXPORT_ID}/assets/photo.png`],
['a missing file', `${base}/missing.png`],
['`..` leaving the asset prefix', `${base}/../../secret.png`],
['`%2e%2e` leaving the asset prefix', `${base}/%2e%2e/%2e%2e/secret.png`],
['an encoded `..` path', `${base}/..%2f..%2fsecret.png`],
['an empty asset path', `${base}/`],
['a malformed percent-escape', `${base}/photo%zz.png`],
['a decoded `%`', `${base}/100%25.png`],
['a decoded `#` that Node reads as a fragment', `${base}/a%23b.png`],
['an unsupported extension', `${base}/notes.txt`],
['a directory', `${base}/folder.png`],
['an inactive package', `/plugins/${INACTIVE_ID}/assets/photo.png`],
])('answers 404 for %s', async (_case, url) => {
writeFixture()
const { both } = await composeHost()
expect(await both(url)).toEqual({ status: 404, headers: {}, body: Buffer.alloc(0) })
})
})
+300 -33
View File
@@ -6,18 +6,34 @@
* lightningcss inside the bundle: `x.module.css` yields its hashed class map
* and injects a tagged style at factory execution, while `x.css?inline`
* exports compiled text for a plugin-owned lifecycle effect. The virtual
* loaders register each real stylesheet as a watch dependency. SVG, PNG, and
* WebP imports become watched base64 data URLs inside the bundle.
* loaders register each real stylesheet as a watch dependency. Images under
* `src/client/assets/`, imported from Client code or referenced by relative
* `url()` in the package's own stylesheets, are emitted under `lib/assets/`
* with their content revision in the file name and become document-relative
* URLs served by the
* client-modules asset route; such a package exports `./assets/*` as
* `./lib/assets/*`, and its Node Client face rejects asset imports.
* Non-experimental client outputs reject experimental module and stylesheet
* inputs, including origins recorded by chained source maps.
*/
import { readFile, stat, utimes } from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { randomBytes } from 'node:crypto'
import { readFile, readdir, rm, rmdir, stat, utimes } from 'node:fs/promises'
import { existsSync, readFileSync, realpathSync } from 'node:fs'
import { createRequire, isBuiltin } from 'node:module'
import { basename, dirname, extname, isAbsolute, relative, resolve as resolvePath, sep } from 'node:path'
import { basename, dirname, isAbsolute, join, posix, relative, resolve as resolvePath, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Rolldown, type TsdownPlugin, type UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
import { transform, type CustomAtRules, type Url, type Visitor } from 'lightningcss'
import {
PACKAGE_ASSET_EXPORT,
PACKAGE_ASSET_EXPORT_TARGET,
PACKAGE_ASSET_FILES_ENTRY,
PACKAGE_ASSET_MEDIA_TYPES,
packageAssetMediaType,
packageAssetReference,
packageAssetRevision,
packageAssetRevisionedPath,
} from './modules/src/assets.ts'
import { optionalStringArray } from './modules/src/client/manifest.ts'
import { PLATFORM_MODULES, PRELOADED_CLIENT_EXTERNALS } from './web/src/platform.ts'
import { clientBuildEnvironmentDefines, clientBuildValueDefines } from '../../scripts/client-build-environment.ts'
@@ -35,10 +51,10 @@ const INLINE_CSS_VIRTUAL_PREFIX = '\0dsh-inline-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
const INLINE_CSS_QUERY = '?inline'
/** Image imports become base64 data URLs embedded in the plugin's own bundle. */
const IMAGE_MEDIA_TYPES = new Map([['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.webp', 'image/webp']])
/** Emit one plugin-owned style injector and an optional CSS Modules export. */
/**
* Emit one plugin-owned style injector and an optional CSS Modules export.
* @param css - JavaScript expression yielding the stylesheet text; see {@link stylesheetSource}.
*/
function styleInjectionModule(
id: string,
fileId: string,
@@ -46,7 +62,7 @@ function styleInjectionModule(
classMap?: Readonly<Record<string, string>>,
): string {
const source = [
`const css = ${JSON.stringify(css)};`,
`const css = ${css};`,
`const tagId = ${JSON.stringify(`${id}/${basename(fileId)}`)};`,
'if (typeof document !== \'undefined\' && document.querySelector(\'style[data-plugin-css=\' + JSON.stringify(tagId) + \']\') === null) {',
' const tag = document.createElement(\'style\');',
@@ -106,7 +122,9 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
* original source content, into the standalone plugin map.
* @param packageDir - the package's own directory, normally the config's
* `import.meta.dirname`. Its `package.json` name is the plugin id stamped into
* the __ModuleLoader__.load handoff and onto the injected style tags.
* the __ModuleLoader__.load handoff and onto the injected style tags, and the
* owner segment of its asset URLs; its `src/client/assets/` holds the only
* images the browser bundle may reference.
* @param libEntry - node-half entries.
* @param options - phase placement, lib overrides, optional independently bundled Node Client, companion configs, and per-file Client banner.
* @returns ENV-selected tsdown config for the current build face.
@@ -122,7 +140,7 @@ export function clientBundle(
return ({ env }) => {
const face = buildFace(env?.DSH_BUILD_FACE)
const clientEntry = face === undefined ? 'src/client/index.ts' : 'lib/types/client/index.js'
const client = clientConfig(manifest, clientEntry, options.clientBanner, options.dualClient === true)
const client = clientConfig(manifest, packageDir, clientEntry, options.clientBanner, options.dualClient === true)
const node = [lib, ...(options.companions ?? [])]
const clientFaces = options.dualClient === true
? [nodeClientConfig(manifest, clientEntry), client]
@@ -277,6 +295,11 @@ interface AssetEmitter {
}): string
}
/** The slice of the rolldown plugin context that emits and watches one package asset. */
interface AssetContext extends AssetEmitter {
addWatchFile(id: string): void
}
function staticLinkedConfig(id: string, entry: string, outputName = basename(entry, '.js')): UserConfig {
const emitted = new Set<string>()
const isolation = clientInputIsolation(id)
@@ -358,6 +381,8 @@ interface PackageManifest {
readonly dependencies?: Record<string, string>
readonly peerDependencies?: Record<string, string>
readonly optionalDependencies?: Record<string, string>
/** Package exports; the browser face emits assets only while `./assets/*` publishes `lib/assets`. */
readonly exports?: Readonly<Record<string, unknown>>
readonly dsh?: { readonly client?: { readonly external?: unknown } }
}
@@ -478,6 +503,255 @@ function asyncChunkRequirePlugin(): TsdownPlugin {
}
}
/** Emitted asset directory below the browser config's `lib` outDir, published as {@link PACKAGE_ASSET_FILES_ENTRY}. */
const ASSET_OUTPUT_DIR = posix.relative('lib', PACKAGE_ASSET_FILES_ENTRY)
/** Characters the asset route rejects in a decoded path segment, because Node resolves export subpaths as URL text. */
const UNSERVABLE_ASSET_SEGMENT = /[%#?\\\0]/
/** A `url()` value with a scheme, such as `data:` or `https:`, which names no package file. */
const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i
/**
* Free identifier an asset reference compiles to before rendering; the CommonJS
* render turns each call into `require.asset(...)`, the module loader's
* synchronous asset resolver, which returns the reference unchanged unless its
* carrier maps it. The fixed hexadecimal suffix keeps plugin strings that happen
* to contain the shorter prefix out of that rewrite, and keeps the source maps
* that carry the loaded module text byte-stable across builds.
*/
const PACKAGE_ASSET_CALL = '__dshPackageAsset_5f8e2c1a9b7d'
/** Compile one asset reference into the resolver call. */
function packageAssetCall(reference: string): string {
return `${PACKAGE_ASSET_CALL}(${JSON.stringify(reference)})`
}
/**
* Compile stylesheet text into a JavaScript expression that passes each package-asset
* reference the `url()` rewrite inserted through the resolver call. The rewrite writes
* a placeholder of letters, digits, and underscores, which lightningcss never escapes,
* with a per-stylesheet random part that stylesheet text cannot contain by chance.
* A placeholder may appear several times or not at all, as when minification copies
* or drops a declaration.
* @param css - stylesheet text after the rewrite.
* @param references - reference of each placeholder the rewrite inserted into this stylesheet.
* @returns a string literal, or a concatenation of literals and resolver calls.
*/
function stylesheetSource(css: string, references: ReadonlyMap<string, string>): string {
if (references.size === 0) return JSON.stringify(css)
const parts: string[] = []
let last = 0
for (const match of css.matchAll(new RegExp([...references.keys()].join('|'), 'gu'))) {
parts.push(JSON.stringify(css.slice(last, match.index)), packageAssetCall(references.get(match[0]) as string))
last = match.index + match[0].length
}
parts.push(JSON.stringify(css.slice(last)))
return parts.join(' + ')
}
/** One stylesheet's `url()` rewrite: the lightningcss options and the references they insert while transforming. */
interface StylesheetAssets {
/** lightningcss options; empty when the stylesheet lies outside the package's own `src`. */
readonly options: { readonly visitor?: Visitor<CustomAtRules> }
/** Reference of each placeholder inserted during the transform that uses {@link options}. */
readonly references: ReadonlyMap<string, string>
}
/** Browser-face package assets: the import plugin and the stylesheet `url()` rewrite share one per-build emission record. */
interface PackageAssets {
/** `dsh-package-asset`: resolves asset imports, emits referenced files, and prunes stale `lib/assets` files after writing. */
readonly plugin: TsdownPlugin
/**
* Select the lightningcss options that rewrite one stylesheet's relative `url()` values.
* @param context - plugin context of the stylesheet loader, which emits each referenced asset.
* @param stylesheet - physical stylesheet path.
* @returns a `url()` visitor when the stylesheet lies inside the package's own `src`, otherwise no options,
* and the references the visitor inserts.
* @throws {Error} from the visitor when a relative `url()` does not name a publishable package asset.
*/
urlRewrite(context: AssetContext, stylesheet: string): StylesheetAssets
}
/**
* Build the package-asset rules of one dynamic browser bundle. A referenced
* file must lie under the package's own `src/client/assets/`, have a supported
* extension, and be published by the `./assets/*` export; it is emitted once
* per build under `lib/assets/` at its relative path with the content revision
* inserted before the extension, and the reference compiles to the module loader's
* synchronous resolver call on its document-relative URL.
* A one-shot build deletes files under `lib/assets/` that it did not emit; a
* watch build keeps them for pages still running an earlier generation.
* @param manifest - the package's own manifest, naming the URL owner and declaring the asset export.
* @param packageDir - the package's own directory.
* @returns the plugin and stylesheet visitor sharing the per-build emission record.
*/
function packageAssets(manifest: PackageManifest, packageDir: string): PackageAssets {
const name = manifest.name
const sourceDir = resolvePath(packageDir, 'src')
const assetsDir = resolvePath(sourceDir, 'client', 'assets')
/** Reference of every asset emitted in the current build, keyed by its real path. */
const references = new Map<string, string>()
const reference = (context: AssetContext, file: string): string => {
let realPath: string
try {
realPath = realpathSync(file)
} catch (error) {
throw new Error(`tsdown: ${name}: cannot resolve asset ${file}`, { cause: error })
}
const emitted = references.get(realPath)
if (emitted !== undefined) return emitted
const below = pathBelow(realDirectory(assetsDir), realPath)
if (below === undefined) throw new Error(`tsdown: ${name}: ${file} is outside src/client/assets; move the asset there`)
const assetPath = below.split(sep).join('/')
if (assetPath.split('/').some(segment => UNSERVABLE_ASSET_SEGMENT.test(segment) || segment.toLowerCase() === 'node_modules')) {
throw new Error(`tsdown: ${name}: ${file} has a node_modules path segment or one containing %, #, ?, \\ or NUL, which the asset route cannot serve; rename it`)
}
const extension = posix.extname(assetPath)
if (extension !== extension.toLowerCase()) {
throw new Error(`tsdown: ${name}: ${file} has an uppercase extension; asset imports are typed by lowercase extensions, so rename it`)
}
if (manifest.exports?.[PACKAGE_ASSET_EXPORT] !== PACKAGE_ASSET_EXPORT_TARGET) {
throw new Error(
`tsdown: ${name}: ${file} needs package.json exports ${JSON.stringify({ [PACKAGE_ASSET_EXPORT]: PACKAGE_ASSET_EXPORT_TARGET })} `
+ `and ${JSON.stringify(PACKAGE_ASSET_FILES_ENTRY)} in files`,
)
}
if (packageAssetMediaType(assetPath) === undefined) {
throw new Error(`tsdown: ${name}: ${file} is not a supported asset; supported extensions: ${[...PACKAGE_ASSET_MEDIA_TYPES.keys()].join(', ')}`)
}
const bytes = readFileSync(realPath)
const emittedPath = packageAssetRevisionedPath(assetPath, packageAssetRevision(bytes))
context.emitFile({ type: 'asset', fileName: `${ASSET_OUTPUT_DIR}/${emittedPath}`, source: bytes, originalFileName: realPath })
context.addWatchFile(realPath)
const url = packageAssetReference(name, emittedPath)
references.set(realPath, url)
return url
}
return {
plugin: {
name: 'dsh-package-asset',
buildStart() { references.clear() },
resolveId(source, importer) {
if (importer === undefined || packageAssetMediaType(source) === undefined) return null
return sourceAssetPath(source, importer)
},
load(id) {
if (!isAbsolute(id) || packageAssetMediaType(id) === undefined) return null
return `export default ${packageAssetCall(reference(this, id))};`
},
renderChunk(code, _chunk, outputOptions) {
if (outputOptions.format !== 'cjs' || !code.includes(PACKAGE_ASSET_CALL)) return null
const transformed = new Rolldown.RolldownMagicString(code)
for (const match of code.matchAll(new RegExp(`\\b${PACKAGE_ASSET_CALL}\\(`, 'gu'))) {
transformed.overwrite(match.index, match.index + match[0].length, 'require.asset(')
}
return transformed
},
async writeBundle(outputOptions, bundle) {
// A watch rebuild keeps earlier files: the Host still serves the previous
// bundle generation, and pages running it request those asset names.
if (this.meta.watchMode || outputOptions.dir === undefined) return
const outDir = outputOptions.dir
const assets = resolvePath(outDir, ASSET_OUTPUT_DIR)
if (!existsSync(assets)) return
await pruneDirectory(assets, file => relative(outDir, file).split(sep).join('/') in bundle)
},
},
urlRewrite(context, stylesheet) {
const inserted = new Map<string, string>()
const nonce = randomBytes(6).toString('hex')
if (pathBelow(realDirectory(sourceDir), realpathSync(stylesheet)) === undefined) return { options: {}, references: inserted }
const base = dirname(stylesheet)
const rewrite = (url: Url): Url | undefined => {
// Empty values, fragments, root-relative paths, schemes such as `data:`, and placeholders name no package file.
if (url.url === '' || url.url.startsWith('#') || url.url.startsWith('/') || URL_SCHEME.test(url.url)) return undefined
if (inserted.has(url.url)) return undefined
if (/[#?]/u.test(url.url)) {
throw new Error(`tsdown: ${name}: ${stylesheet} references ${JSON.stringify(url.url)}; a package asset url() takes no fragment or query`)
}
const placeholder = `__dshPackageAsset_${nonce}_${String(inserted.size)}__`
inserted.set(placeholder, reference(context, resolvePath(base, url.url)))
return { ...url, url: placeholder }
}
return {
options: {
visitor: {
Url: url => rewrite(url),
// lightningcss does not visit the urls inside image-set(), so its options are rewritten here.
Image(image) {
if (image.type !== 'image-set') return undefined
const options = image.value.options.map((option) => {
const url = option.image.type === 'url' ? rewrite(option.image.value) : undefined
return url === undefined ? option : { ...option, image: { type: 'url' as const, value: url } }
})
return { ...image, value: { ...image.value, options } }
},
},
},
references: inserted,
}
},
}
}
/** Real path of an existing directory, or undefined when it does not exist. */
function realDirectory(directory: string): string | undefined {
return existsSync(directory) ? realpathSync(directory) : undefined
}
/**
* Locate a real path strictly inside a real directory.
* @param root - real directory path, or undefined when the directory does not exist.
* @param file - real file path.
* @returns the platform-separated path of `file` below `root`, or undefined when it is not inside.
*/
function pathBelow(root: string | undefined, file: string): string | undefined {
if (root === undefined) return undefined
const path = relative(root, file)
return path === '' || path === '..' || path.startsWith(`..${sep}`) || isAbsolute(path) ? undefined : path
}
/**
* Delete every file a predicate rejects below a directory, then every directory left empty.
* @param directory - directory to prune; removed itself when nothing remains.
* @param keep - whether a file path survives.
* @returns whether the directory was removed.
*/
async function pruneDirectory(directory: string, keep: (file: string) => boolean): Promise<boolean> {
let remaining = 0
for (const entry of await readdir(directory, { withFileTypes: true })) {
const path = join(directory, entry.name)
if (entry.isDirectory()) {
if (!(await pruneDirectory(path, keep))) remaining++
} else if (keep(path)) {
remaining++
} else {
await rm(path)
}
}
if (remaining > 0) return false
await rmdir(directory)
return true
}
/**
* Reject asset imports in a Node Client face, which has no asset route.
* @param id - package name, used in diagnostics.
* @returns a plugin whose resolveId throws for every supported asset extension.
*/
function browserOnlyAssets(id: string): TsdownPlugin {
return {
name: 'dsh-package-asset-browser-only',
resolveId(source) {
if (packageAssetMediaType(source) === undefined) return null
throw new Error(`tsdown: ${id}: asset imports are browser-only (${source})`)
},
}
}
/** Bundle the Client entry for plain Node independently of the Host entry and browser factory. */
function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig {
const isolation = clientInputIsolation(manifest.name)
@@ -486,7 +760,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig
entry: { 'client.node': entry },
sourcemap: true,
inputOptions: { resolve: { conditionNames: ['node', 'import', 'module', 'default'] } },
plugins: [tscSourceMapPlugin(), isolation.plugin],
plugins: [browserOnlyAssets(manifest.name), tscSourceMapPlugin(), isolation.plugin],
outputOptions: {
entryFileNames: 'client.node.js',
chunkFileNames: 'client.node.[name].js',
@@ -498,6 +772,7 @@ function nodeClientConfig(manifest: PackageManifest, entry: string): UserConfig
function clientConfig(
manifest: PackageManifest,
packageDir: string,
entry: string,
clientBanner?: (fileName: string) => string | undefined,
dualClient = false,
@@ -506,6 +781,7 @@ function clientConfig(
let requested: ReadonlySet<string> | undefined
const isRequested = (specifier: string): boolean => (requested ??= clientExternals(manifest)).has(specifier)
const isolation = clientInputIsolation(id)
const assets = packageAssets(manifest, packageDir)
return {
name: `${id}/client`,
entry: { client: entry },
@@ -589,17 +865,19 @@ function clientConfig(
// The virtual id otherwise hides the physical stylesheet from Rolldown's watch graph.
this.addWatchFile(fileId)
const source = await readFile(fileId)
const stylesheetAssets = assets.urlRewrite(this, fileId)
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: '[hash]_[local]' },
minify: true,
...stylesheetAssets.options,
})
const classMap: Record<string, string> = {}
const exportEntries = Object.entries(cssExports ?? {})
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
for (const [local, exp] of exportEntries) classMap[local] = exp.name
return styleInjectionModule(id, fileId, code.toString(), classMap)
return styleInjectionModule(id, fileId, stylesheetSource(code.toString(), stylesheetAssets.references), classMap)
},
}, {
name: 'dsh-css-text-inline',
@@ -614,8 +892,9 @@ function clientConfig(
const fileId = virtualId.slice(INLINE_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
const { code } = transform({ filename: fileId, code: source, minify: true })
return `export default ${JSON.stringify(code.toString())};`
const stylesheetAssets = assets.urlRewrite(this, fileId)
const { code } = transform({ filename: fileId, code: source, minify: true, ...stylesheetAssets.options })
return `export default ${stylesheetSource(code.toString(), stylesheetAssets.references)};`
},
}, {
name: 'dsh-css-global-inline',
@@ -629,23 +908,11 @@ function clientConfig(
const fileId = virtualId.slice(GLOBAL_CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
const { code } = transform({ filename: fileId, code: source, minify: true })
return styleInjectionModule(id, fileId, code.toString())
const stylesheetAssets = assets.urlRewrite(this, fileId)
const { code } = transform({ filename: fileId, code: source, minify: true, ...stylesheetAssets.options })
return styleInjectionModule(id, fileId, stylesheetSource(code.toString(), stylesheetAssets.references))
},
}, {
name: 'dsh-image-inline',
resolveId(source: string, importer: string | undefined) {
if (importer === undefined || !IMAGE_MEDIA_TYPES.has(extname(source))) return null
return sourceAssetPath(source, importer)
},
async load(fileId: string) {
const mediaType = IMAGE_MEDIA_TYPES.get(extname(fileId))
if (mediaType === undefined) return null
this.addWatchFile(fileId)
const data = await readFile(fileId)
return `export default ${JSON.stringify(`data:${mediaType};base64,${data.toString('base64')}`)};`
},
}],
}, assets.plugin],
outputOptions: {
entryFileNames: dualClient ? 'client.browser.js' : 'client.js',
// The imported source basename becomes the published chunk name; package
+2 -2
View File
@@ -33,8 +33,8 @@
en: b0d806604bc17006
zh: 18dabe5dc2d0d419
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
en: f78cbc73be5dc3ac
zh: 893ba03d66b8ab65
en: a14c74ed7de0ef9c
zh: d5e618b2ef809150
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
en: 284737900cb75668
zh: 12e0844082ff79ce
+1 -1
View File
@@ -111,7 +111,7 @@ The process group uses a stable `div` layout box, a scroll body, and an uncapped
Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [TextShimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Tool and process rows sit 6px apart. An expanded group title has 8px before its content, Assistant responses have 12px from adjacent process rows, and completed-Turn duration/status controls have 16px from their input and from a directly following visible row. A reply that follows the control after folded rows keeps the 12px reply gap, so folding never changes its spacing. Hidden and empty rows, including retained empty slot anchors, add no gap. The running “Deep diving” text retains its blue base while its theme-specific highlight sweeps across it.
The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The build embeds the image in the plugin stylesheet; React does not pass image data through DOM styles.
The running status shows a whale whose APNG mask inherits the text color. CSS selects the static SVG when reduced motion or forced colors are active, or alpha masks are unsupported. The APNG is a [package asset](../AGENTS.md#dependency-declaration) referenced by the plugin stylesheet; React does not pass image data through DOM styles.
Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect.
+1 -1
View File
@@ -111,7 +111,7 @@ Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的
运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [TextShimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。工具和过程行之间相隔 6px;展开的分组标题与内容之间相隔 8px;Assistant 回复与相邻过程行之间相隔 12px;每轮完成/耗时控件与其输入、以及紧随其后的可见行之间保留 16px。控件与回复之间隔着已收起的行时,回复保持 12px 回复间距,因此折叠不会改变它的间距。隐藏行与空行(包括保留的空 slot 锚点)不产生间距。“深度求索”运行文字保留蓝色底色,并使用主题专用高光扫过文字。
运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。构建将图片嵌入插件样式表;React 不通过 DOM 样式传递图片数据。
运行状态显示一只鲸鱼,其 APNG 遮罩继承文字颜色。启用减少动态效果或强制颜色模式、或浏览器不支持 alpha 遮罩时,CSS 选择静态 SVG。APNG 是插件样式表引用的[包资源](../AGENTS.md#dependency-declaration);React 不通过 DOM 样式传递图片数据。
滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。
+2
View File
@@ -22,6 +22,7 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./assets/*": "./lib/assets/*",
"./src/*": "./src/*",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
@@ -101,6 +102,7 @@
"locale/*.json",
"lib/index.js",
"lib/client.js",
"lib/assets",
"lib/types/**/*.d.ts"
]
}
@@ -327,7 +327,7 @@
.runningWhaleAnimated {
display: block;
background: currentColor;
mask: url('./[email protected]') center / 100% 100% no-repeat;
mask: url('../assets/[email protected]') center / 100% 100% no-repeat;
mask-mode: alpha;
/* Keep animated-mask repaints on an icon-sized composited layer. */
will-change: transform;
@@ -167,12 +167,19 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
return box.element.parentElement?.closest('[data-chat-motion="collapse"]') === null
}
function runBatch(current: InputFoldDriver): void {
/** @returns whether a batch started; without a closing member the caller settles held rows itself. */
function runBatch(current: InputFoldDriver): boolean {
// Every member continues from the size it has now, so the driver's measured layout and its change
// amounts describe the same boxes: a closing box may have moved on its own transition or sit inside a
// row that finished closing, and a revealing box may have grown part of the way on its own transition.
// amounts describe the same boxes: a closing box may have moved on its own transition, and a revealing
// box may have grown part of the way on its own transition.
for (const box of [...boxes.values()]) {
if (!batchMember(box)) continue
// A row that finished closing is `hidden="until-found"`, which skips its contents without removing
// their boxes: a box under it still reports its own size but contributes nothing to the flow.
if (box.element.parentElement?.closest('[hidden]') != null) {
box.finish()
continue
}
const height = box.element.offsetHeight
const marginTop = Number.parseFloat(getComputedStyle(box.element).marginTop) || 0
if (!box.closing) {
@@ -187,7 +194,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
const owned = [...boxes.values()]
const members = owned.filter(batchMember)
.sort((left, right) => left.element.compareDocumentPosition(right.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)
if (!members.some(box => box.closing)) return
if (!members.some(box => box.closing)) return false
batch = true
cancelled = false
const travelling = current.begin(members.map(box => box.closing ? box : {
@@ -284,6 +291,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
}
if (reduced) tick(0)
else frameRequest = requestAnimationFrame(tick)
return true
}
function foldActive(): boolean { return folding > 0 }
@@ -299,10 +307,7 @@ export function createFlowMotion(driver: () => InputFoldDriver | null, foldingCh
// Rows that change during a batch are held until it settles, which flushes again.
if (batch) return
const current = [...boxes.values()].some(box => box.closing) ? driver() : null
if (current !== null) {
runBatch(current)
return
}
if (current !== null && runBatch(current)) return
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
// Without a batch, a held closing row finishes at once and a held revealing row starts its own transition.
for (const box of [...boxes.values()]) {
@@ -781,11 +781,13 @@ export class ChatViewport {
/**
* Mark the column while the tail is pinned: the reader follows the floor with no unsampled reader
* movement, the test fold batches use for a tail landing, and the content overflows, so following scrolls
* absorb output growth and the status keeps its on-screen position. The first-output slide runs at zero
* duration there. A resize calls this after its own follow scroll, before paint, so a slide that starts in
* that frame finishes before it is drawn; scroll and scrollend call it after delivering the sample, and
* follow-intent commits call it with the latest measurement. The attribute changes only with the pin.
* movement, the test fold batches use for a tail landing, and the content overflows with no fold room
* below it, so following scrolls absorb output growth and the status keeps its on-screen position. Fold
* room, like the empty space below content that fits, takes growth before any scroll, so the status moves
* there. The first-output slide runs at zero duration while pinned. A resize calls this after its own
* follow scroll, before paint, so a slide that starts in that frame finishes before it is drawn; scroll and
* scrollend call it after delivering the sample, and follow-intent commits call it with the latest
* measurement. The attribute changes only with the pin.
* @param overflowing - a fresh overflow measurement; omitted, the latest one is reused.
*/
refreshTailPin(overflowing = this.overflowing): void {
@@ -793,7 +795,7 @@ export class ChatViewport {
const elements = this.elements
if (elements === null) return
const reader = this.reader
const pinned = overflowing && reader !== null && reader.follow.active && !reader.pending()
const pinned = overflowing && this.spacerHeight() === 0 && reader !== null && reader.follow.active && !reader.pending()
if (pinned === this.tailPinned) return
this.tailPinned = pinned
if (pinned) elements.column.dataset.chatTailPinned = ''
@@ -655,3 +655,73 @@ it('pins the tail only while sampled follow intent holds over overflowing conten
viewport.detach()
expect(pinned()).toBe(false)
})
it('leaves the tail unpinned while fold room takes growth, then pins once the room is used', () => {
let notifyResize = () => {}
class Observer implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } }
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', Observer)
const scroller = document.createElement('div')
const column = document.createElement('div')
const spacer = document.createElement('div')
spacer.dataset.chatTurnSpacer = ''
scroller.append(column, spacer)
document.body.append(scroller)
let content = 900
let top = 0
const room = () => Number.parseFloat(spacer.style.height) || 0
const floor = () => Math.max(0, content + room() - 300)
Object.defineProperties(scroller, {
clientHeight: { configurable: true, get: () => 300 },
scrollHeight: { configurable: true, get: () => Math.max(300, content + room()) },
scrollTop: {
configurable: true,
get: () => Math.min(top, floor()),
set: (value: number) => { top = Math.max(0, Math.min(value, floor())) },
},
})
const viewport = new ChatViewport()
viewport.attach(scroller, column)
const follow = new ScrollFollow(true, 4)
const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, {
initialized: true, followingTail: true, activeTurn: 1,
}, vi.fn(), follow)
const unbind = reading.bindFolds()
viewport.connect({
scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, interact: () => {},
resize: () => { reading.onResize() },
})
onTestFinished(() => {
unbind()
reading.dispose()
viewport.detach()
scroller.remove()
vi.unstubAllGlobals()
})
const pinned = () => column.hasAttribute('data-chat-tail-pinned')
notifyResize()
expect(scroller.scrollTop).toBe(600)
expect(pinned()).toBe(true)
// A fold landing leaves room below the content and commits the tail landing.
spacer.style.height = '200px'
scroller.scrollTop = 800
reading.followTail()
expect(pinned()).toBe(false)
// Growth fills the room without scrolling, so the status moves on screen and keeps its slide.
content = 940
notifyResize()
expect(room()).toBe(160)
expect(scroller.scrollTop).toBe(800)
expect(pinned()).toBe(false)
// Once growth has used the room, following scrolls absorb it again.
content = 1_100
notifyResize()
expect(room()).toBe(0)
expect(scroller.scrollTop).toBe(800)
expect(pinned()).toBe(true)
})
@@ -430,6 +430,46 @@ describe('flow motion', () => {
expectCleared(opening)
})
it('finishes a held row whose ancestor finished closing instead of driving it in another batch', async () => {
vi.useFakeTimers()
const calls = batchDriver()
const parent = rowWithHeight(180)
const idle = vi.fn()
motion.collapse(parent, () => { parent.setAttribute('hidden', 'until-found') })
await Promise.resolve()
subscriptions.add(motion.onFoldIdle(idle))
vi.advanceTimersByTime(32)
// A box under a `hidden="until-found"` ancestor keeps reporting its own size in the browser.
const child = rowWithHeight(60, parent)
const childDone = vi.fn()
motion.collapse(child, childDone)
vi.advanceTimersByTime(400)
expect(calls.begin).toHaveBeenCalledTimes(1)
expect(calls.finish).toHaveBeenCalledTimes(1)
expect(childDone).toHaveBeenCalledTimes(1)
expectCleared(child)
expect(idle).toHaveBeenCalledTimes(1)
})
it('starts a held reveal on its own transition when no held closing row still occupies the flow', async () => {
vi.useFakeTimers()
const calls = batchDriver()
const parent = rowWithHeight(180)
motion.collapse(parent, () => { parent.setAttribute('hidden', 'until-found') })
await Promise.resolve()
vi.advanceTimersByTime(32)
motion.collapse(rowWithHeight(60, parent), vi.fn())
const opening = rowWithHeight(40)
motion.reveal(opening)
expect(opening.style.height).toBe('0px')
vi.advanceTimersByTime(200)
expect(calls.begin).toHaveBeenCalledTimes(1)
expect(opening.style.transition).toContain('height')
expect(opening.style.height).toBe('40px')
vi.runAllTimers()
expectCleared(opening)
})
it('reports reader cancellation to the driver and lets a newer request resume the target', async () => {
vi.useFakeTimers()
const calls = batchDriver()
@@ -28,7 +28,7 @@ describe('RunningWhaleTail', () => {
})
it('ships a 28px alpha APNG with sixty 50ms frames and infinite playback', () => {
const png = readFileSync(resolve(import.meta.dirname, '../src/client/chat/[email protected]'))
const png = readFileSync(resolve(import.meta.dirname, '../src/client/assets/[email protected]'))
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
const chunks: { type: string; data: Buffer }[] = []
for (let offset = 8; offset < png.length;) {
+1 -20
View File
@@ -1,22 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
const bundle = clientBundle(import.meta.dirname, ['lib/types/index.js'])
const stylesheet = fileURLToPath(new URL('./src/client/chat/ChatView.module.css', import.meta.url)).replaceAll('\\', '/')
const whaleImage = fileURLToPath(new URL('./src/client/chat/[email protected]', import.meta.url))
export default ((options) => bundle(options).map(config => ({
...config,
plugins: [...(config.plugins ?? []), {
name: 'chat-whale-image',
async transform(code: string, id: string) {
if (id.replaceAll('\\', '/') !== `\0dsh-css:${stylesheet}.mjs`) return null
const imageUrl = './[email protected]'
if (!code.includes(imageUrl)) return null
this.addWatchFile(whaleImage)
const image = await readFile(whaleImage)
return { code: code.replaceAll(imageUrl, `data:image/png;base64,${image.toString('base64')}`), map: null }
},
}],
}))) satisfies typeof bundle
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
@@ -21,8 +21,8 @@
en: c1c4db71fd09ca13
zh: a40c0a01e9951945
/deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation:
en: 149d25b949a24036
zh: a432400e245bf48b
en: 186b2046cfb5ef76
zh: 57b042a618fb612e
/deepseek-ai-dsh-client-ui-settings-account/further-exploration:
en: f5ca95916ef486dc
zh: 1e238d8299e0d838
@@ -68,7 +68,7 @@ The welcome page requires Get started. The credit page always appears. A positiv
The Account section appears first in Settings only while signed in; it is hidden before account state loads and after sign-out. The plugin owns one Host snapshot stream shared through framework hooks by settings.section and settings.launcher. The launcher opens Settings and offers Sign out only while an account credential is stored. Platform failures leave the menu available for retry. It maintains no independent credential state.
The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers are bundled as transparent, lossless 3× WebP assets; welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs.
The desktop controller writes progress to `ui-settings-account` through Host settings. Completion first applies `ui-chat.transcriptView` and `ui-chat.performanceUsage` together, then calls the shared Coding Tools preference to persist `ui-settings.enabled`, and finally saves the done marker; a rejected write keeps the flow available for retry. Successful completion reveals the workspace through a 180ms fade; choices and ordinary navigation preview immediately while writes run in order. A failed final queued write restores saved progress and allows retry. Completion waits for queued choices and retains the page until preferences persist; reduced motion skips the fade. Onboarding choices compact, standard, and detailed apply directly to the corresponding Chat work-detail modes. API-key entry and explicit skip select standard; onboarding does not select verbose. Complete Figma illustration layers ship as transparent, lossless 3× WebP [package assets](../AGENTS.md#dependency-declaration); welcome layers are merged with local sidebar blur; recharge uses a 70% opaque foreground window with local blur of the covered illustration, and headings use the bundled Montserrat brand font; English headings and descriptions use Light (300), except the DeepSeek Harness brand within headings uses Medium (500); card titles retain Regular (400) when selected. English footer navigation uses Light (300). Confirmed onboarding sets a 960px minimum window width and enlarges narrower windows; completion releases the minimum without restoring the previous size. Loading or already-completed onboarding does not resize the window. Empty footer navigation space passes pointer events through to the primary action. Cards exclude native window dragging and own the keyboard focus outline for their checkbox. The account package owns its onboarding overlay and control geometry. Each step and the confirmation dialog has a separate component; the flow coordinates navigation, transitions, and recharge. The overlay hides the workspace while initial account and progress state loads. Question headings and cards follow the Figma positions at 1440 × 920 and 960 × 600, independently of their action buttons. On macOS, content clears the body portal’s inherited no-drag region and a first-child drag band leaves an 8px native resize margin; interactive controls and later dialogs exclude their regions, and a native Platform page disables the underlying onboarding band. The upper workspace popup illustration has an opaque background in both languages and themes; the underlying sidebar omits its selected-row highlight. Welcome keeps its illustration at its original size, repositions and clips the artwork in narrow windows, and reveals the complete illustration on wide windows. The [historical onboarding decision](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md) owns persistence and presentation trade-offs.
<a id="further-exploration"></a>
## Further Exploration
@@ -68,7 +68,7 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本
仅登录后在设置导航首位显示账号页;账号状态加载完成前以及退出登录后隐藏该页。 插件持有一条 Host 快照流,通过框架 hook 供 settings.section 和 settings.launcher 共享。入口菜单可打开设置,并仅在已存储账号凭证时提供退出登录。Platform 请求失败时保留菜单,以供重试。插件不维护独立凭证状态。
桌面控制器通过 Host 设置将进度写入 `ui-settings-account`。完成时先一并应用 `ui-chat.transcriptView` 与 `ui-chat.performanceUsage`,再通过共享代码工作工具偏好保存 `ui-settings.enabled`,最后保存完成标记;写入被拒绝时保留引导以供重试。成功完成后通过 180ms 淡出显露工作区;选择和普通翻页立即预览,写入在后台按顺序执行。队列最后一次写入失败时恢复已保存进度,并允许重试。完成操作会等待选择队列及偏好保存成功,再关闭页面;减少动态效果偏好会跳过淡出。onboarding 选项 compact、standard、detailed 直接应用到对应的 Chat 工作过程模式。API Key 进入与显式跳过均采用 standard;onboarding 不选择 verbose。完整 Figma 示意图层以透明、无损编码的 3× WebP 资源打包;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊,标题使用已打包的 Montserrat 品牌字体,英文标题和描述使用 Light(300),标题中的 DeepSeek Harness 使用 Medium(500);卡片标题选中前后均使用 Regular(400)。英文底部导航使用 Light(300)。确认进入引导后,窗口最小宽度设为 960px,较窄窗口自动放大;完成后解除宽度限制,但不恢复原尺寸。加载中或已完成引导的启动不改变窗口尺寸。底部导航空白区域透过鼠标事件,避免遮挡主操作。卡片区域不参与原生窗口拖动,复选框的键盘焦点仅在卡片外框展示。账号包负责引导覆盖层和控件尺寸样式。每个步骤和确认弹窗各有独立组件,流程组件协调导航、切换动画和充值。初始账号与进度加载期间,覆盖层隐藏工作区。答题标题和卡片独立于操作按钮,位置对齐 Figma 的 1440 × 920 和 960 × 600 断点。macOS 的内容层清除 body 浮层继承的 no-drag,位于内容之前的拖拽层保留 8px 原生缩放边缘;交互控件和后续弹窗排除自身区域;原生平台页面打开时禁用底层引导拖拽层。上方工作区弹窗插画在两种语言和主题下均使用不透明背景,下层侧边栏不绘制选中行高亮。欢迎页保持示意图的原始尺寸,在窄窗口内调整插图位置并裁切,在宽窗口内展示完整插图。[历史引导决策](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md)记录持久化与展示方面的取舍。
桌面控制器通过 Host 设置将进度写入 `ui-settings-account`。完成时先一并应用 `ui-chat.transcriptView` 与 `ui-chat.performanceUsage`,再通过共享代码工作工具偏好保存 `ui-settings.enabled`,最后保存完成标记;写入被拒绝时保留引导以供重试。成功完成后通过 180ms 淡出显露工作区;选择和普通翻页立即预览,写入在后台按顺序执行。队列最后一次写入失败时恢复已保存进度,并允许重试。完成操作会等待选择队列及偏好保存成功,再关闭页面;减少动态效果偏好会跳过淡出。onboarding 选项 compact、standard、detailed 直接应用到对应的 Chat 工作过程模式。API Key 进入与显式跳过均采用 standard;onboarding 不选择 verbose。完整 Figma 示意图层以透明、无损编码的 3× WebP [包资源](../AGENTS.md#dependency-declaration)形式提供;欢迎页图层合并并烘焙侧栏局部模糊;充值页前景窗口不透明度为 70%,仅烘焙其覆盖插图区域的模糊,标题使用已打包的 Montserrat 品牌字体,英文标题和描述使用 Light(300),标题中的 DeepSeek Harness 使用 Medium(500);卡片标题选中前后均使用 Regular(400)。英文底部导航使用 Light(300)。确认进入引导后,窗口最小宽度设为 960px,较窄窗口自动放大;完成后解除宽度限制,但不恢复原尺寸。加载中或已完成引导的启动不改变窗口尺寸。底部导航空白区域透过鼠标事件,避免遮挡主操作。卡片区域不参与原生窗口拖动,复选框的键盘焦点仅在卡片外框展示。账号包负责引导覆盖层和控件尺寸样式。每个步骤和确认弹窗各有独立组件,流程组件协调导航、切换动画和充值。初始账号与进度加载期间,覆盖层隐藏工作区。答题标题和卡片独立于操作按钮,位置对齐 Figma 的 1440 × 920 和 960 × 600 断点。macOS 的内容层清除 body 浮层继承的 no-drag,位于内容之前的拖拽层保留 8px 原生缩放边缘;交互控件和后续弹窗排除自身区域;原生平台页面打开时禁用底层引导拖拽层。上方工作区弹窗插画在两种语言和主题下均使用不透明背景,下层侧边栏不绘制选中行高亮。欢迎页保持示意图的原始尺寸,在窄窗口内调整插图位置并裁切,在宽窗口内展示完整插图。[历史引导决策](../../../.agents/notes/archived/feature/2026-09-16-desktop-onboarding.md)记录持久化与展示方面的取舍。
<a id="further-exploration"></a>
## 深入探索
@@ -22,6 +22,7 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./assets/*": "./lib/assets/*",
"./src/*": "./src/*",
"./package.json": "./package.json",
"./locale/*.json": "./locale/*.json"
@@ -75,6 +76,7 @@
"locale/*.json",
"lib/index.js",
"lib/client.js",
"lib/assets",
"lib/types/**/*.d.ts"
],
"dependencies": {
@@ -1,9 +0,0 @@
/** Illustration imports are embedded in the account client bundle. */
declare module '*.webp' {
const url: string
export default url
}
declare module '*.svg' {
const url: string
export default url
}
@@ -36,8 +36,8 @@
en: 8d82539e4e4e890d
zh: 7b97a3927de90ea8
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 721d5a49fe231191
zh: 3ef1dd720219e00e
en: 812c108a2b5f6c0d
zh: 68c344c03789b215
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole:
en: 868904dbfb5713f8
zh: 225906bfef7398bf
@@ -66,8 +66,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-ui-workspace/known-limitations-and-deferred-work:
en: 00d7b1ddda7390f5
zh: 1e5a1055d1be4d1d
en: 8211860a1b10ac61
zh: 4ef11b1fce30cacd
/deepseek-ai-dsh-client-ui-workspace/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+11 -2
View File
@@ -91,7 +91,15 @@ The package injects the Client `remoteAccess` service and provides the root stan
The package is one composition: both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored.
The browser entry also declares two root-scoped `list` child seats on each Session row: `sidebar.session.row.leading`, rendered only while that row's primary status is idle and left blank on an archived row, and `sidebar.session.row.hover`, mounted only while that row's hover card is open. Both take the row's Session identity and nothing else, so an occupant reads its own data by that id; a Session-scoped seat would force a Session binding, which would activate and retain every listed Session.
The browser entry declares three root-scoped `list` child seats on each Session row: `sidebar.session.row.leading`, `sidebar.session.row.trailing`, and `sidebar.session.row.hover`. All receive only `sessionId`, which occupants use to address their own data without creating a Session binding. A Session-scoped seat would activate and retain every listed Session.
The leading seat occupies the status cell only while the row's primary status is idle and leaves archived rows blank. The trailing seat accepts non-interactive icons in ascending `order`, after the time label and before the pin marker. Blank rows omit the trailing seat; an empty seat adds no spacing. The hover seat mounts only while the row's hover card is open.
On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves.
Hoverless devices keep the static details and the More menu trigger visible, including while the menu is open; pin and archive remain available in that menu.
Session rows accept Tab focus and open with unmodified Enter or Space; Tab also reaches their action buttons, while modified keys remain available to global shortcuts.
Client callers can pass `prompt` and `clearPreviousDraft` in the second argument to `uiWorkspace.startSession`. A prompt is plain text; structured drafts are handled internally during restoration and Workspace transfer. Without clearing, existing text or attachments are preserved; explicit clearing replaces only the target Session's text and inline references, leaving attachments intact. Option values are captured before asynchronous creation and applied to the exact retained binding before navigation commits. Superseded navigation does not write content; a submission-blocked input refuses preparation and keeps the previous selection. Calls without options create a fresh Session using the current Agent preset definition. Workspace reconnection and explicit draft preparation may reuse an existing blank Session. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) describes the input owner's data flow.
@@ -216,11 +224,12 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
These limits define the search depth, the archive surface, and the picking carrier; they are current package constraints.
These limits cover search, navigation, archival, and folder selection.
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
- **No Session deletion** — sessions can be archived but never deleted; archived rows stay recoverable in place through the archived view filter and the search results' unarchive action, and Workspace registration deletion does not delete Sessions.
- **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded.
- **Tree keyboard navigation is incomplete** — Workspace and search-result rows do not accept Tab focus, and the browser tree does not implement arrow-key navigation.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, in-process or remote browser deployments cannot open a local operating-system dialog; remote-capable picking is the `-browse` composition's in-app flow.
<a id="dev-note"></a>
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more