Ver código fonte

perf(client-modules): defer per-plugin revision hashing

Preserve sourcemaps through the production Client build and verify batched loading across Host, HMR, and Web Worker paths.
imccyu 1 mês atrás
pai
commit
9c3a0893f6

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
-2026-07-23-client-plugin-loading-model.md: a16c022bc2d4f96bd2680a637e02a485ba4f2697
-2026-07-23-client-plugin-loading-model.zh.md: 96c6b85cd6d7bdb0cbfce4479d2cfe5b7e74f2a5
+2026-07-23-client-plugin-loading-model.md: dfa9f34276f20ffa99541db1544539d693313a2f
+2026-07-23-client-plugin-loading-model.zh.md: 68fe9b912c60aceb2ecea315ed0121f9f96c1ecf

+ 5 - 5
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md

@@ -14,7 +14,7 @@ The browser client runs the same cordis plugin mechanism, so it needs the same s
 
 Conventional frontend engineering digests all dependencies at build time: one bundle, externals resolved by the bundler, nothing left to manage at runtime. Runtime module management on top of that is the unusual requirement here. The client therefore splits into two layers: the upper layer is cordis plugin loading through the same vendored Loader, and the lower layer is module-granular dependency management — `dsh-client-modules`.
 
-The lower layer supplies four capabilities: externals (the platform list), remote arrival (same-origin external classic scripts plus lazy factory registration), versioning (content-hash revs), and hot update (invalidate/prefetch).
+The lower layer supplies four capabilities: externals (the platform list), remote arrival (same-origin external classic scripts plus lazy factory registration), immutable revisioned delivery, and hot update (invalidate/prefetch).
 
 Plugin bundles are built independently outside Vite's module graph. Feeding response text into an inline script leaves the browser with a dynamic source execution: no standard source-map chain connects the network resource, generated bundle, and TypeScript/TSX source, so performance profiles and stacks stop at generated `client.js`; the module system must also buffer the complete source and split one arrival responsibility across fetch and execute transport boundaries.
 
@@ -42,9 +42,9 @@ The vendored Loader consumes the module system through its `internal` contract 
 
 The Host snapshots every built plugin artifact and concatenates its factory registration into one of two same-origin classic scripts. The parser-blocking `bootstrap` batch contains the modules row; the HTML preloads the `application` batch containing every other graph row while bootstrap executes. The module system keys in-flight transport by batch URL, so concurrent row arrivals execute one application script. Successful settlement still requires each requested row's factory id to exist in the module table, and registration does not run the factory, so the side-effect boundary remains first materialization.
 
-The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages/<group>/<package>/src/...`. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged; `sourcesContent` carries the source. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX.
+The shared tsdown preset emits `client.js.map` for every plugin and rewrites first-party source paths into the browser-resolvable repository shape `/packages/<group>/<package>/src/...`. The production Client pass consumes `lib/types`; the preset supplies each tsc map to Rolldown and fills `sourcesContent` from the original files, so the final map reaches TypeScript/TSX instead of stopping at emitted JavaScript. Other workspace sources inlined into a bundle likewise resolve to their `packages/` owner, while dependency paths remain unchanged. Batch generation strips each local `sourceMappingURL`, records its generated-line offset, resolves every source against the original per-plugin map URL, and emits one indexed Source Map v3 file whose sections embed the available plugin maps. The Vite shell also emits source maps, letting shell code and batched or individually reloaded plugins map stacks and performance profiles back to TypeScript/TSX.
 
-The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
+The graph retains each row's revisioned individual URL for HMR and adds content-addressed descriptors for the two startup batches. Initial row revisions are opaque process nonces rather than content hashes; they keep an exceptional initial individual request immutable without hashing every plugin at startup. After the watcher observes one artifact change, `rebuilt(id)` hashes only that bundle and map and publishes the resulting revision. Versioned scripts and maps use immutable caching. The Host serves snapshotted bytes only when the requested revision matches; stale or missing revisions return 404 instead of aliasing newer bytes. An external script's `error` event exposes neither response status nor body, so failure diagnostics name only the URL; the same-origin Host and build-stamped registration id form the identity boundary, while the post-`load` factory-presence check rejects an artifact that did not register the expected id.
 
 ### The loading flow, end to end
 
@@ -54,7 +54,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. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`. 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 name forever and bundle re-hashing 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. Each bundle plus its available map hashes into the row `rev`; batch revisions hash their script plus indexed map, 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 bundle 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, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing 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 receive an opaque process nonce plus sequence without hashing their artifacts; batch revisions hash the generated script plus indexed map, 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 bundle 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.
 
@@ -72,7 +72,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
 
 Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
 
-How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModules.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row's script and optional map. Adding a row is ordered as synchronous artifact baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a script/map mtime or size delta, or a dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
+How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. Before reading each startup snapshot, the module host captures the bundle and optional-map stat baseline and exposes it through `ctx.clientModules.artifactBaseline(id)`. One HMR-owned interval compares every current graph row with that baseline. An unchanged row starts watching without a content read or hash; a write after baseline capture is already a stat delta and only that row enters `rebuilt(id)`. This avoids both an initial all-row re-hash and `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a script/map mtime or size delta, or a dirty row, `rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding artifacts is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
 
 On the browser side, the driver reloads one plugin per frame, serialized:
 

+ 5 - 5
.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md

@@ -14,7 +14,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
 
 常规前端工程在构建期消化全部依赖:单一 bundle,external 由打包器解决,运行时无物可管。在此之上再做运行时模块管理,正是这里的特殊需求。client 因此拆成两层:上层是经同一份 vendored Loader 的 cordis 插件装载,下层是模块粒度的依赖管理——`dsh-client-modules`。
 
-下层供给四项能力:external(平台清单)、远程到达(同源外部 classic script 加惰性工厂登记)、版本化(内容哈希 rev)、热更新(invalidate/prefetch)。
+下层供给四项能力:external(平台清单)、远程到达(同源外部 classic script 加惰性工厂登记)、不可变的版本化交付、热更新(invalidate/prefetch)。
 
 插件 bundle 独立构建在 Vite 模块图之外。若把响应文本塞进内联 script,浏览器只能看到一次动态源码执行:网络资源、生成 bundle、TypeScript/TSX 源码之间没有标准 sourcemap 链,性能 profile 与 stack 只能落到生成后的 `client.js`;模块系统还要持有整份源码文本,并把同一项到达职责拆成 fetch 与 execute 两道传输边界。
 
@@ -42,9 +42,9 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 Host 会快照每个已构建插件产物,并把其 factory registration 拼入两个同源 classic script 之一。阻塞 parser 的 `bootstrap` 批次包含 modules row;HTML 在 bootstrap 执行期间预加载包含其余全部 graph row 的 `application` 批次。模块系统按批次 URL 复用进行中的传输,因此并发 row 到达只执行一次 application 脚本。成功结算仍要求模块表中已经存在被请求 row 的 factory id;登记不会运行 factory,所以副作用边界依然是首次物化。
 
-共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages/<group>/<package>/src/...`。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样;`sourcesContent` 承载源码。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
+共享 tsdown 预设为每个插件产出 `client.js.map`,并把第一方源码路径重写成浏览器可识别的仓库形状 `/packages/<group>/<package>/src/...`。生产 Client 构建会消费 `lib/types`;预设把每份 tsc map 交给 Rolldown,并从原文件补齐 `sourcesContent`,使最终 map 回到 TypeScript/TSX,而不是停在编译后的 JavaScript。内联进 bundle 的其他 workspace 源码同样回到其 `packages/` 归属,依赖包路径保持原样。批次生成会移除每个局部 `sourceMappingURL`、记录其生成行偏移、以原插件 map URL 解析每个 source,再产出一份以 section 内嵌现有插件 map 的 indexed Source Map v3 文件。Vite 壳也产出 sourcemap,使壳代码以及批量或独立重载的插件都能从 stack 和性能 profile 回到 TypeScript/TSX。
 
-图为 HMR 保留每个 row 带 revision 的独立 URL,并为两个启动批次增加按内容寻址的描述。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
+图为 HMR 保留每个 row 带 revision 的独立 URL,并为两个启动批次增加按内容寻址的描述。初始 row revision 是进程级不透明 nonce,而不是内容哈希;它无需在启动时哈希每个插件,也能保证异常情况下的初始独立请求不可变。watcher 观察到某个产物变化后,`rebuilt(id)` 只哈希该 bundle 与 map,并发布所得 revision。版本化脚本与 map 使用 immutable 缓存。Host 只在请求 revision 匹配时提供已快照字节;陈旧或缺失 revision 返回 404,不会在旧 URL 下别名到新字节。外部脚本的 `error` 事件不给响应状态与正文,因此失败诊断只报告 URL;同源 Host 与构建期写入的 registration id 是身份边界,`load` 后的 factory 存在性检查负责拒绝未登记预期 id 的产物。
 
 ### 装载流程,端到端
 
@@ -54,7 +54,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
 
 1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。
 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately?, external? }], batches: [{ phase, url, rev, entries }] }`。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 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 及其可用 map 共同哈希为 row `rev`;批次 revision 对脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 bundle 路由并贡献结构化 index 注入行。
+3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。初始 row 使用不透明的进程 nonce 加序号,不对其产物求哈希;批次 revision 对生成的脚本及 indexed map 求哈希,row 与批次描述再共同哈希进 `graph.rev`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知;modules 会注册 bundle 路由并贡献结构化 index 注入行。
 
 为什么名册是 yml 行而不是扫描?因为哪些插件组合进一次部署是组合决策,不是包属性——一个在仓库中声明了 dsh.client 的包,不代表这次部署要挂载它,扫描发现无从替人做这个决定;node 半只扫描配置树实际挂载了的东西。
 
@@ -72,7 +72,7 @@ Host 会快照每个已构建插件产物,并把其 factory registration 拼
 
 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不应暴露它的组合可以禁用该行。
 
-重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModules.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图每一行的脚本及可选 map 做 stat 轮询。新增图行时,顺序固定为先同步取得产物基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
+重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。模块 host 在读取每份启动快照前捕获 bundle 与可选 map 的 stat 基线,并通过 `ctx.clientModules.artifactBaseline(id)` 暴露它。HMR 自持的单个定时器把当前图的每个 row 与这份基线比较:未变化的 row 直接开始监视,不读取内容也不求哈希;基线捕获后的写入已经形成 stat 差异,只有该 row 会进入 `rebuilt(id)`。这同时消除了启动期的全量重哈希,并避开 `fs.watchFile` 以异步首次 stat 建立基线、可能静默吸收构造期重建的问题。监视集合的成员随 `onGraphChanged` 更新;消失的 row 撤下监视,轮询时缺失的 bundle 则让对应 row 保持标脏状态,文件重现时即使元数据相同也强制重哈希。脚本/map 的 mtime 或 size 变化,或 row 处于标脏状态时,`rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建产物是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
 浏览器侧,驱动插件每帧重载一个插件,串行执行:
 

+ 41 - 3
apps/web/tests/smoke-real.e2e.ts

@@ -19,7 +19,7 @@ import { spawn } from 'node:child_process'
 import { randomUUID } from 'node:crypto'
 import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
 import { createServer } from 'node:http'
-import { createRequire } from 'node:module'
+import { createRequire, SourceMap } from 'node:module'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -203,6 +203,23 @@ async function waitForAssistantMarker(baseUrl: string, sessionId: string, marker
   }).toBe(true)
 }
 
+/** Find one real source location through a served indexed map. */
+function firstMappedSource(script: string, payload: ConstructorParameters<typeof SourceMap>[0]): string | undefined {
+  const consumer = new SourceMap(payload)
+  const lines = script.split('\n')
+  for (let line = 0; line < lines.length; line++) {
+    const lastColumn = Math.min(lines[line]!.length, 512)
+    for (let column = 0; column <= lastColumn; column++) {
+      const entry = consumer.findEntry(line, column)
+      if (!('originalSource' in entry) || typeof entry.originalSource !== 'string') continue
+      if (entry.originalSource.startsWith('/packages/') && entry.originalSource.includes('/src/')) {
+        return entry.originalSource
+      }
+    }
+  }
+  return undefined
+}
+
 /** Real-host smoke screenshot: evidence for the figma comparison, not a failure artifact. */
 async function screen(page: Page, name: string): Promise<void> {
   await page.screenshot({ path: join(REPO_ROOT, '.artifacts', `w5-${name}.png`) })
@@ -237,7 +254,7 @@ const notReady = UI_PLUGIN_DIRS.filter((dir) => {
 if (notReady.length > 0) console.warn(`[smoke-real] skipped — client bundles not ready: ${notReady.join(', ')}`)
 
 describe('dsh web keyless CLI smoke', () => {
-  it('listens on 127.0.0.1 by default', async () => {
+  it('serves a usable app from two immutable plugin batches', async () => {
     requireDist()
     const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-keyless-'))
     const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
@@ -281,7 +298,8 @@ describe('dsh web keyless CLI smoke', () => {
       })
       await page.goto(readyUrl)
       await page.getByRole('button', { name: 'New session', exact: true }).first().waitFor({ timeout: 30_000 })
-      expect([...new Set(pluginScripts)].sort()).toEqual([
+      const batchPaths = [...new Set(pluginScripts)].sort()
+      expect(batchPaths).toEqual([
         expect.stringMatching(/^\/plugins\/_batch\/application\/[a-f\d]{12}\/client\.js$/),
         expect.stringMatching(/^\/plugins\/_batch\/bootstrap\/[a-f\d]{12}\/client\.js$/),
       ])
@@ -289,6 +307,26 @@ describe('dsh web keyless CLI smoke', () => {
         'public, max-age=31536000, immutable',
         'public, max-age=31536000, immutable',
       ])
+      for (const path of batchPaths) {
+        const [scriptResponse, mapResponse] = await Promise.all([
+          fetch(`${readyUrl}${path}`),
+          fetch(`${readyUrl}${path}.map`),
+        ])
+        expect(scriptResponse.status).toBe(200)
+        expect(mapResponse.status).toBe(200)
+        const script = await scriptResponse.text()
+        const payload = await mapResponse.json() as ConstructorParameters<typeof SourceMap>[0]
+        const sections = (payload as unknown as {
+          sections: { map: { sources?: unknown[]; sourcesContent?: unknown[] } }[]
+        }).sections
+        expect(sections.every(section => (
+          Array.isArray(section.map.sources)
+          && Array.isArray(section.map.sourcesContent)
+          && section.map.sourcesContent.length === section.map.sources.length
+          && section.map.sourcesContent.every(source => typeof source === 'string')
+        ))).toBe(true)
+        expect(firstMappedSource(script, payload)).toMatch(/^\/packages\/.+\/src\//)
+      }
     } finally {
       await browser?.close()
       const closed = child.exitCode === null

+ 2 - 2
docs/subsystems/client-modules.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md
-client-modules.md: b935c7328ff2fc5e8ac5ab427a95fcae01e8df22
-client-modules.zh.md: f65b764a1d66239c40117cabf2b286a3894ce07d
+client-modules.md: 8dae05be292d8d5628c59b768d84dfd97dec1ef2
+client-modules.zh.md: 3668a4a3958b3a5957f094c0f925edb120d9b8c6

+ 30 - 4
docs/subsystems/client-modules.md

@@ -24,7 +24,7 @@ interface WebBootEntry {
   id: string
   /** Revisioned individual endpoint used by HMR. */
   url: string
-  /** Hash over the individual bundle and available source map. */
+  /** Opaque individual-artifact revision used for HMR cache busting. */
   rev: string
   /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
@@ -70,7 +70,7 @@ interface WebBootGraph {
 }
 ```
 
-Each row's `rev` hashes the individual bundle and its available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark.
+Each initial row's `rev` is an opaque process nonce plus sequence, so graph composition does not hash every individual artifact. After HMR observes a change, that row's revision becomes the hash of its new bundle and available source map. The bootstrap batch contains the modules row; the preloaded application batch contains every other row. Batch revisions hash the generated script and indexed source map, and the graph revision hashes both rows and batch descriptors. `immediately` marks the stage-one registration barrier; application rows share one script transport even when only some carry the mark.
 
 ## The scan
 
@@ -86,9 +86,25 @@ Package metadata — including the negative "not a client package" verdict — i
 
 ## The service
 
-`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) and `clientPath(id)` the bundle's absolute path. `rebuilt(id)` is the only entry point through which bundle content reaches the graph: it re-hashes the file, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev 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.
+```ts type-equiv
+/** Filesystem baseline captured before a client artifact snapshot is read. */
+interface ClientArtifactBaseline {
+  /** Absolute path of the client bundle. */
+  readonly path: string
+  /** Bundle modification time in milliseconds. */
+  readonly mtimeMs: number
+  /** Bundle size in bytes. */
+  readonly size: number
+  /** Source-map modification time, or null when no map was observable. */
+  readonly mapMtimeMs: number | null
+  /** Source-map size in bytes, or null when no map was observable. */
+  readonly mapSize: number | null
+}
+```
+
+`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/map stat values captured before the current snapshot was read. `rebuilt(id)` is the only entry point through which changed bundle content reaches the graph: it re-hashes that artifact, and only a real rev change recomposes the graph and notifies. `onRebuilt` fires per changed bundle with the new rev; `onGraphChanged` fires after any flush that recomposed the graph (row added or removed, or a rebuilt rev 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.
 
-In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle from a synchronously captured baseline, calls `rebuilt(id)` on change, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files.
+In development, [dsh-client-hmr](../../packages/client/hmr/README.md) is the registry's watch driver: its node half stat-polls every graph row's bundle and optional map from the module host's pre-read baseline, calls `rebuilt(id)` only for a changed or dirty row, resyncs its watch set through `onGraphChanged`, and broadcasts rev changes to the browser half over SSE. Production graphs omit the HMR row entirely; the module host itself never watches files.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -118,6 +134,16 @@ graph(): WebBootGraph
  */
 clientPath(id: string): string | undefined
 
+/**
+ * Filesystem baseline captured before an entry's current bytes were read.
+ * HMR compares it with the live files when installing a watch, so a write
+ * between startup composition and watch installation cannot disappear into
+ * the watcher's initial state.
+ * @param id - entry id (package name).
+ * @returns the path and baseline, or undefined for an unknown id.
+ */
+artifactBaseline(id: string): ClientArtifactBaseline | undefined
+
 /**
  * Re-hash one bundle (the HMR watch's registration hook — the only entry
  * point through which bundle content changes reach the graph).

+ 30 - 4
docs/subsystems/client-modules.zh.md

@@ -24,7 +24,7 @@ interface WebBootEntry {
   id: string
   /** Revisioned individual endpoint used by HMR. */
   url: string
-  /** Hash over the individual bundle and available source map. */
+  /** Opaque individual-artifact revision used for HMR cache busting. */
   rev: string
   /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
@@ -70,7 +70,7 @@ interface WebBootGraph {
 }
 ```
 
-每一行的 `rev` 都对独立 bundle 及其可用 sourcemap 求哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输。
+每个初始 row 的 `rev` 都是不透明的进程 nonce 加序号,因此组合图时不会哈希每个独立产物。HMR 观察到变化后,该 row 的 revision 才改为新 bundle 及其可用 sourcemap 的哈希。Bootstrap 批次包含 modules row;预加载的 application 批次包含其他全部 row。批次 revision 对生成的脚本与 indexed sourcemap 求哈希,图 revision 则对 row 与批次描述一并求哈希。`immediately` 标记第一阶段的 registration barrier;即使只有部分 application row 携带该标记,它们仍共享一次脚本传输。
 
 ## 扫描
 
@@ -86,9 +86,25 @@ interface WebBootGraph {
 
 ## 服务
 
-`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回该 bundle 的绝对路径。`rebuilt(id)` 是 bundle 内容到达图的唯一入口:它对文件重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
+```ts type-equiv
+/** Filesystem baseline captured before a client artifact snapshot is read. */
+interface ClientArtifactBaseline {
+  /** Absolute path of the client bundle. */
+  readonly path: string
+  /** Bundle modification time in milliseconds. */
+  readonly mtimeMs: number
+  /** Bundle size in bytes. */
+  readonly size: number
+  /** Source-map modification time, or null when no map was observable. */
+  readonly mapMtimeMs: number | null
+  /** Source-map size in bytes, or null when no map was observable. */
+  readonly mapSize: number | null
+}
+```
+
+`ClientModuleRegistry`(`ctx.clientModules`,定义于 [`packages/client/modules/src/index.ts`](../../packages/client/modules/src/index.ts))暴露读取面与重建面;签名见生成的[服务目录](#ctxclientmodules--clientmoduleregistry)。`graph()` 返回当前组合出的图(两次变更之间是同一个稳定对象),`clientPath(id)` 返回 bundle 的绝对路径,`artifactBaseline(id)` 返回读取当前快照前捕获的 bundle/map stat 值。`rebuilt(id)` 是变化后的 bundle 内容到达图的唯一入口:它只对该产物重新哈希,只有 rev 真正变化才会重新组合图并发出通知。`onRebuilt` 按发生变化的 bundle 逐个触发并携带新 rev;`onGraphChanged` 在任何一次重新组合了图的 flush 之后触发(行的增删,或 rebuilt 带来的 rev 变化),并采用拉取模型——监听器自行重读 `graph()`。两条通知路径都会兜住监听器异常,因此一个抛错的订阅者既不能让后续订阅者被跳过,也不能杀死触发这次 flush 的一方。
 
-开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从同步取得的基线出发,对图中每一行的 bundle 做 stat 轮询,变化时调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;模块宿主自身从不监视文件。
+开发环境下,[dsh-client-hmr](../../packages/client/hmr/README.zh.md) 是注册表的监视驱动:它的 Node 半从 module host 读文件前记录的基线出发,对图中每一行的 bundle 与可选 map 做 stat 轮询,只为变化或标脏的 row 调用 `rebuilt(id)`,经 `onGraphChanged` 重新同步监视集合,并通过 SSE(Server-Sent Events)把 rev 变化广播给浏览器半。生产环境的图完全不含 HMR(热模块替换)行;module host 自身从不监视文件。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -118,6 +134,16 @@ graph(): WebBootGraph
  */
 clientPath(id: string): string | undefined
 
+/**
+ * Filesystem baseline captured before an entry's current bytes were read.
+ * HMR compares it with the live files when installing a watch, so a write
+ * between startup composition and watch installation cannot disappear into
+ * the watcher's initial state.
+ * @param id - entry id (package name).
+ * @returns the path and baseline, or undefined for an unknown id.
+ */
+artifactBaseline(id: string): ClientArtifactBaseline | undefined
+
 /**
  * Re-hash one bundle (the HMR watch's registration hook — the only entry
  * point through which bundle content changes reach the graph).

+ 2 - 2
packages/client/hmr/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/hmr/README.md
-README.md: 5e1d960515094d1c4f18b69ec01d49dd780d0cb5
-README.zh.md: 2c82baac20f2a68b91c25e80ee4714b6ceb2e80c
+README.md: 089b3ba35780ccb7a24bc8fed10cc0a5353c9eb9
+README.zh.md: e100dde3ced0f7272e9a75bc4d0a69f6beb4d4ee

+ 1 - 1
packages/client/hmr/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
 
-The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The frame revision makes `invalidate` select that plugin's immutable individual URL instead of its initial batch; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, then `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle and optional source map from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the artifacts therefore triggers HMR with no builder→host channel.
+The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The frame revision makes `invalidate` select that plugin's immutable individual URL instead of its initial batch; `prefetch` loads and registers the new factory while the old fiber still serves. The remaining sequence is `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, then `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle and optional source map from the module host's pre-read baseline. An unchanged startup row begins watching without a content read or hash; a changed row, or a dirty row after its artifact reappears, enters `rebuilt()`, and only real revision changes are broadcast. Any tsdown watch process producing the artifacts therefore triggers HMR with no builder→host channel.
 
 ## Model Experience
 

+ 1 - 1
packages/client/hmr/README.zh.md

@@ -4,7 +4,7 @@
 
 为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
 
-浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。帧内 revision 会让 `invalidate` 选择该插件的不可变独立 URL,而不是初始批次;`prefetch` 在旧 fiber 仍在服务时加载并登记新 factory。其余顺序是:`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载,最后通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图 bundle 及其可选 sourcemap;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
+浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。帧内 revision 会让 `invalidate` 选择该插件的不可变独立 URL,而不是初始批次;`prefetch` 在旧 fiber 仍在服务时加载并登记新 factory。其余顺序是:`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载,最后通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建,以 module host 读文件前记录的基线 stat-poll 每个图 bundle 及其可选 sourcemap。未变化的启动 row 无需读取内容或求哈希即可开始监视;只有发生变化的 row,或产物恢复后的 dirty row,才会进入 `rebuilt()`,并且只广播真实 revision 变更。因此,任何生成这些产物的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
 
 ## 模型体验
 

+ 20 - 27
packages/client/hmr/src/index.ts

@@ -13,7 +13,7 @@ import type { ServerResponse } from 'node:http'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 // Empty type imports carry the clientModuleHost/webServer Context merges.
-import type {} from '@deepseek-ai/dsh-client-modules'
+import type { ClientArtifactBaseline } from '@deepseek-ai/dsh-client-modules'
 import type {} from '@deepseek-ai/dsh-host-webserver'
 import type { PluginsEventFrame } from './events.ts'
 import { EVENTS_ENDPOINT } from './events.ts'
@@ -42,17 +42,11 @@ function sseData(frame: PluginsEventFrame): string {
   return `data: ${JSON.stringify(frame)}\n\n`
 }
 
-interface WatchedArtifactStat {
-  mtimeMs: number
-  size: number
-  mapMtimeMs: number | null
-  mapSize: number | null
-}
+type WatchedArtifactStat = Omit<ClientArtifactBaseline, 'path'>
 
-interface WatchedBundle extends WatchedArtifactStat {
-  path: string
-  dirty: boolean
-}
+type WatchedBundle = {
+  -readonly [K in keyof ClientArtifactBaseline]: ClientArtifactBaseline[K]
+} & { dirty: boolean }
 
 /** Snapshot the bundle plus its optional development source map. */
 function artifactStat(path: string): WatchedArtifactStat {
@@ -106,21 +100,20 @@ export function apply(ctx: Context, config: Config): void {
     watch.dirty = false
   }
 
-  const watchRow = (id: string, path: string): void => {
-    let baseline: WatchedArtifactStat
+  const watchRow = (id: string, baseline: ClientArtifactBaseline): void => {
+    const watch: WatchedBundle = { ...baseline, dirty: false }
+    watched.set(id, watch)
+    let current: WatchedArtifactStat
     try {
-      baseline = artifactStat(path)
+      current = artifactStat(baseline.path)
     } catch (error) {
-      watched.set(id, { path, mtimeMs: 0, size: 0, mapMtimeMs: null, mapSize: null, dirty: true })
+      watch.dirty = true
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') ctx.logger.warn(error)
       return
     }
-    const watch: WatchedBundle = { path, ...baseline, dirty: false }
-    watched.set(id, watch)
-    // The module host hashed before publishing the graph. Re-hash immediately
-    // after capturing this baseline so a write in between cannot become an
-    // already-current baseline paired with a stale graph rev.
-    rehash(id, watch, baseline)
+    // The module host captured its baseline before reading the bytes in the
+    // startup batch. Only a mismatch crosses into the content-hash path.
+    if (!sameArtifactStat(current, watch)) rehash(id, watch, current)
   }
 
   const pollWatches = (): void => {
@@ -143,17 +136,17 @@ export function apply(ctx: Context, config: Config): void {
   // Diff the watch set against the current graph: drop watches for removed
   // rows (or rows whose bundle path moved), add watches for new rows.
   const syncWatches = (): void => {
-    const rows = new Map<string, string>()
+    const rows = new Map<string, ClientArtifactBaseline>()
     for (const row of ctx.clientModules.graph().entries) {
-      const path = ctx.clientModules.clientPath(row.id)
-      if (path !== undefined) rows.set(row.id, path)
+      const watch = ctx.clientModules.artifactBaseline(row.id)
+      if (watch !== undefined) rows.set(row.id, watch)
     }
     for (const [id, watch] of watched) {
-      if (rows.get(id) === watch.path) continue
+      if (rows.get(id)?.path === watch.path) continue
       watched.delete(id)
     }
-    for (const [id, path] of rows) {
-      if (!watched.has(id)) watchRow(id, path)
+    for (const [id, watch] of rows) {
+      if (!watched.has(id)) watchRow(id, watch)
     }
   }
 

+ 38 - 12
packages/client/hmr/tests/node-half.client.spec.ts

@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import type { WebBootGraph, ClientModuleRegistry } from '@deepseek-ai/dsh-client-modules'
+import type { ClientArtifactBaseline, ClientModuleRegistry, WebBootGraph } from '@deepseek-ai/dsh-client-modules'
 import type { WebRoute, WebServer } from '@deepseek-ai/dsh-host-webserver'
 import { apply, Config, EVENTS_ENDPOINT, inject } from '../src/index.ts'
 
@@ -29,10 +29,28 @@ interface FakeHostOptions {
   rebuilt?: (id: string) => string | undefined
 }
 
+function artifactBaseline(path: string): ClientArtifactBaseline {
+  const bundle = statSync(path)
+  try {
+    const sourceMap = statSync(`${path}.map`)
+    return {
+      path,
+      mtimeMs: bundle.mtimeMs,
+      size: bundle.size,
+      mapMtimeMs: sourceMap.mtimeMs,
+      mapSize: sourceMap.size,
+    }
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    return { path, mtimeMs: bundle.mtimeMs, size: bundle.size, mapMtimeMs: null, mapSize: null }
+  }
+}
+
 function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOptions = {}): FakeHost {
   const graphListeners = new Set<() => void>()
   const rebuiltCalls: string[] = []
-  const fake: Pick<FakeHost, 'graph' | 'clientPath' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
+  const baselines = new Map([...rows].map(([id, path]) => [id, artifactBaseline(path)]))
+  const fake: Pick<FakeHost, 'graph' | 'artifactBaseline' | 'rebuilt' | 'onRebuilt' | 'onGraphChanged' | 'rebuiltCalls' | 'fireGraphChanged'> = {
     rebuiltCalls,
     fireGraphChanged: () => { for (const l of graphListeners) l() },
     graph: (): WebBootGraph => {
@@ -43,7 +61,16 @@ function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOption
         batches: [],
       }
     },
-    clientPath: id => rows.get(id),
+    artifactBaseline: (id) => {
+      const path = rows.get(id)
+      if (path === undefined) return undefined
+      let baseline = baselines.get(id)
+      if (baseline?.path !== path) {
+        baseline = artifactBaseline(path)
+        baselines.set(id, baseline)
+      }
+      return { ...baseline }
+    },
     rebuilt: (id) => {
       rebuiltCalls.push(id)
       return options.rebuilt?.(id) ?? 'r2'
@@ -93,8 +120,7 @@ describe('hmr node half', () => {
 
     expect(routes).toHaveLength(1)
     expect(routes[0]).toMatchObject({ kind: 'exact', path: EVENTS_ENDPOINT })
-    expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-a'])
-    clientModuleHost.rebuiltCalls.length = 0
+    expect(clientModuleHost.rebuiltCalls).toEqual([])
 
     // Nudge mtime past stat granularity so the poller sees a content signal.
     await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
@@ -127,8 +153,7 @@ describe('hmr node half', () => {
     writeFileSync(late, 'v1')
     rows.set('pkg-late', late)
     clientModuleHost.fireGraphChanged()
-    expect(clientModuleHost.rebuiltCalls).toEqual(['pkg-late'])
-    clientModuleHost.rebuiltCalls.length = 0
+    expect(clientModuleHost.rebuiltCalls).toEqual([])
 
     await new Promise(resolve => setTimeout(resolve, POLL_MS * 2))
     writeFileSync(late, 'v2-longer')
@@ -143,7 +168,7 @@ describe('hmr node half', () => {
     await fiber.dispose()
   })
 
-  it('rehashes after baseline capture so a construction-window write cannot become the baseline', async () => {
+  it('rehashes only a row changed between its startup snapshot and watch installation', async () => {
     const bundle = join(dir, 'construction.js')
     writeFileSync(bundle, 'v1')
     let rewrite = true
@@ -151,9 +176,6 @@ describe('hmr node half', () => {
       beforeGraphRead: () => {
         if (!rewrite) return
         rewrite = false
-        // The graph carries the hash from before this write. The old
-        // fs.watchFile registration asynchronously captured the new file as
-        // its first baseline and never requested a re-hash.
         writeFileSync(bundle, 'v2-written-during-watch-construction')
       },
     })
@@ -190,11 +212,15 @@ describe('hmr node half', () => {
     await fiber.dispose()
   })
 
-  it('retains a dirty baseline when the immediate re-hash races a rename', async () => {
+  it('retains a dirty baseline when a catch-up re-hash races a rename', async () => {
     const bundle = join(dir, 'rename.js')
     writeFileSync(bundle, 'v1')
     let first = true
     const clientModuleHost = fakeClientModuleHost(new Map([['pkg-a', bundle]]), {
+      beforeGraphRead: () => {
+        if (!first) return
+        writeFileSync(bundle, 'v2-written-during-watch-construction')
+      },
       rebuilt: () => {
         if (!first) return 'r2'
         first = false

+ 2 - 2
packages/client/modules/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/modules/README.md
-README.md: 67f6b6216dee90148f78d5b62edbe6009dffb8c9
-README.zh.md: d385b7c3854c9da1c4f22224bc5910ae7cfcdf2f
+README.md: 2e8ed59614a30a1c737e57e5e5869bccf6bfb6db
+README.zh.md: 982d4a444f691cb575b77d35283e39940b66b42a

+ 1 - 1
packages/client/modules/README.md

@@ -10,7 +10,7 @@ The Host installs `window.__ModuleLoader__`, preloads the application batch, the
 
 Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → exports; graph row (`window.__DSH_BOOT__`) → register its initial-batch factory; registered factory → materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous graph-row load and records observed edges into the module record. `prefetch` is the stage-one arrival hook; rows sharing a batch URL share one in-flight script task. `invalidate(id, rev)` drops a non-bootstrap factory and materialized record and switches that row to its revisioned individual script, so HMR reloads one plugin without executing the batch again.
 
-The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It emits a bootstrap batch for the modules row and an application batch for every other row. Each batch has a content-addressed script and an indexed Source Map v3 file assembled from the available plugin maps. Individual revisioned scripts and maps remain available for HMR; every versioned response is immutable, and a revision mismatch returns 404 instead of serving newer bytes under an older URL. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
+The Node half scans enabled Loader entries for web `dsh.client` packages, resolves and snapshots each `exports["./client"]` plus its available source map, carries package-specific `dsh.client.external` requests, and orders dynamic providers before consumers. It emits a bootstrap batch for the modules row and an application batch for every other row. Each batch has a content-addressed script and an indexed Source Map v3 file assembled from the available plugin maps. Initial individual revisions are opaque process nonces, so startup does not hash every plugin; HMR hashes only an artifact whose watcher reports a change. Individual revisioned scripts and maps remain available for HMR; every versioned response is immutable, and a revision mismatch returns 404 instead of serving newer bytes under an older URL. Source launch maps host imports to TypeScript source but still consumes these built client exports; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
 
 `dsh.client.external` is an optional exact-specifier request list beyond the implicit baseline of shell-seeded React, Cordis, and static UI libraries. A request is answered by the dynamic package row it names or an exact static-table key; only a trailing `/client` aliases a package row, and there is no provider-alias declaration. Type-only imports are erased and create no request. Composition rejects malformed requests, missing suppliers, self-requests, and synchronous request cycles; import and prefetch recursively register dynamic suppliers before their consumers materialize. See [shared modules and the module graph](../AGENTS.md#shared-modules-and-the-module-graph).
 

+ 1 - 1
packages/client/modules/README.zh.md

@@ -10,7 +10,7 @@ Host 会先安装 `window.__ModuleLoader__`、预加载 application 批次,再
 
 解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 导出;模块图记录(`window.__DSH_BOOT__`)→ 登记其初始批次 factory;已登记 factory → 物化;其他情况一律抛出异常。这是构建时 bundle 纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含异步 graph-row 加载分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段到达钩子;共享同一批次 URL 的 row 会共享一个进行中的脚本任务。`invalidate(id, rev)` 会丢弃非 bootstrap factory 与物化记录,并让该 row 改用带 revision 的独立脚本,因此 HMR(热模块替换)只重载一个插件,不会再次执行整批脚本。
 
-Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它为 modules row 生成 bootstrap 批次,为其余 row 生成 application 批次;每个批次都有按内容寻址的脚本,以及由现有插件 map 组合而成的 indexed Source Map v3 文件。HMR 仍可访问带 revision 的独立脚本与 map;所有版本化响应都不可变,revision 不匹配时返回 404,绝不在旧 URL 下提供新字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
+Node 侧会扫描已启用的 Loader 配置项以发现 web `dsh.client` 包,解析并快照每个 `exports["./client"]` 及其可用 sourcemap,携带包专属 `dsh.client.external` 请求,并把动态提供方排在消费者之前。它为 modules row 生成 bootstrap 批次,为其余 row 生成 application 批次;每个批次都有按内容寻址的脚本,以及由现有插件 map 组合而成的 indexed Source Map v3 文件。初始独立 revision 使用不透明的进程 nonce,因此启动时不会哈希每个插件;HMR 只哈希 watcher 报告发生变化的产物。HMR 仍可访问带 revision 的独立脚本与 map;所有版本化响应都不可变,revision 不匹配时返回 404,绝不在旧 URL 下提供新字节。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费这些构建后的客户端导出;缺失文件共享一条构建说明,随后以包/路径列表列出各项,而无关的文件系统错误仍是独立故障。
 
 `dsh.client.external` 是外壳播种的 React、Cordis 和静态 UI 库这一统一基座之外的可选精确 specifier 请求列表。请求由其命名的动态 package row 或精确静态表键回答;只有末尾 `/client` 会别名到 package row,并且不存在 provider 别名声明。纯类型 import 会被擦除,不产生请求。组合阶段会拒绝畸形请求、缺失提供方、自请求和同步请求环;import 与 prefetch 会在消费者物化前递归登记动态提供方。参见[共享模块与模块图](../AGENTS.md#shared-modules-and-the-module-graph)。
 

+ 2 - 2
packages/client/modules/src/client/manifest.ts

@@ -52,7 +52,7 @@ export interface WebBootEntry {
   id: string
   /** Revisioned individual endpoint used by HMR. */
   url: string
-  /** Hash over the individual bundle and available source map. */
+  /** Opaque individual-artifact revision used for HMR cache busting. */
   rev: string
   /** Package-name dependency edges used for factory arrival and plugin composition. */
   inject?: string[]
@@ -99,7 +99,7 @@ export interface BootModuleRow {
   url: string
   /** Content-addressed batch endpoint used before the first HMR invalidation. */
   initialUrl: string
-  /** Hash over the individual bundle and available source map. */
+  /** Opaque individual-artifact revision used after HMR invalidation. */
   rev: string
   /** Injected package rows whose factories arrive before this row materializes. */
   inject: string[]

+ 72 - 12
packages/client/modules/src/index.ts

@@ -22,8 +22,8 @@
  * @module @deepseek-ai/dsh-client-modules
  */
 
-import { createHash } from 'node:crypto'
-import { readFileSync } from 'node:fs'
+import { createHash, randomBytes } from 'node:crypto'
+import { readFileSync, statSync, type Stats } from 'node:fs'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { createRequire } from 'node:module'
 import { dirname, join } from 'node:path'
@@ -70,6 +70,20 @@ interface WebBootRowFields {
   immediately: boolean
 }
 
+/** Filesystem baseline captured before a client artifact snapshot is read. */
+export interface ClientArtifactBaseline {
+  /** Absolute path of the client bundle. */
+  readonly path: string
+  /** Bundle modification time in milliseconds. */
+  readonly mtimeMs: number
+  /** Bundle size in bytes. */
+  readonly size: number
+  /** Source-map modification time, or null when no map was observable. */
+  readonly mapMtimeMs: number | null
+  /** Source-map size in bytes, or null when no map was observable. */
+  readonly mapSize: number | null
+}
+
 /** Resolved package metadata for one `dsh.client` package (cached per name, never expires). */
 interface PkgMeta extends WebBootRowFields {
   clientPath: string
@@ -120,8 +134,10 @@ class ClientPackageCompositionError extends AggregateError {
 interface WebPluginRecord {
   entry: WebBootEntry
   meta: PkgMeta
-  /** Exact build artifact whose hash produced entry.rev. */
+  /** Exact build artifact included in the startup batches. */
   bundle: Buffer
+  /** Pre-read filesystem baseline handed to the HMR watcher. */
+  baseline: ClientArtifactBaseline
   /** Revision-stamped individual response used after HMR invalidation. */
   individualBundle: Buffer
   /** Optional parsed and original source map snapshot for immutable delivery. */
@@ -177,7 +193,7 @@ function clientExportOf(pkgName: string, exportsField: unknown): string | undefi
   throw new Error(`client-modules: ${pkgName} exports["./client"] must be a string or an object with a string default`)
 }
 
-/** sha1 content hash shortened to 12 hex chars (bundle rev / graph rev). */
+/** sha1 content hash shortened to 12 hex chars (batch / graph / rebuilt-artifact rev). */
 function shortHash(input: string | Buffer): string {
   return createHash('sha1').update(input).digest('hex').slice(0, 12)
 }
@@ -189,7 +205,7 @@ function framedHash(domain: string, parts: readonly Buffer[]): string {
   return hash.digest('hex').slice(0, 12)
 }
 
-/** Hash every byte served under one individual artifact revision. */
+/** Hash every byte served after HMR observes one artifact change. */
 function artifactRevision(bundle: Buffer, sourceMap: WebPluginRecord['sourceMap']): string {
   return framedHash('individual', sourceMap === undefined ? [bundle] : [bundle, sourceMap.body])
 }
@@ -417,6 +433,8 @@ export class ClientModuleRegistry extends Service {
   private readonly graphListeners = new Set<() => void>()
   private readonly dirty = new Set<string>()
   private readonly resolvePkgJson: (spec: string) => string
+  private readonly initialRevisionNonce = randomBytes(8).toString('hex')
+  private nextInitialRevision = 0
   private batchResponses = new Map<string, { body: Buffer; contentType: string }>()
   /** One prior graph generation covers a request racing the HMR recomposition that replaced its URL. */
   private previousBatchResponses = new Map<string, { body: Buffer; contentType: string }>()
@@ -491,6 +509,19 @@ export class ClientModuleRegistry extends Service {
     return this.table.get(id)?.meta.clientPath
   }
 
+  /**
+   * Filesystem baseline captured before an entry's current bytes were read.
+   * HMR compares it with the live files when installing a watch, so a write
+   * between startup composition and watch installation cannot disappear into
+   * the watcher's initial state.
+   * @param id - entry id (package name).
+   * @returns the path and baseline, or undefined for an unknown id.
+   */
+  artifactBaseline(id: string): ClientArtifactBaseline | undefined {
+    const baseline = this.table.get(id)?.baseline
+    return baseline === undefined ? undefined : { ...baseline }
+  }
+
   /**
    * Re-hash one bundle (the HMR watch's registration hook — the only entry
    * point through which bundle content changes reach the graph).
@@ -500,9 +531,11 @@ export class ClientModuleRegistry extends Service {
   rebuilt(id: string): string | undefined {
     const record = this.table.get(id)
     if (record === undefined) return undefined
+    const baseline = this.captureArtifactBaseline(record.meta.clientPath)
     const bundle = readFileSync(record.meta.clientPath)
     const sourceMap = this.readSourceMapSnapshot(record.meta.clientPath)
     const rev = artifactRevision(bundle, sourceMap)
+    record.baseline = baseline
     if (rev === record.entry.rev) return rev
     record.entry = graphRow(id, rev, record.meta)
     record.bundle = bundle
@@ -625,22 +658,47 @@ export class ClientModuleRegistry extends Service {
     return meta
   }
 
+  /** Capture the bundle and optional-map stats before reading their bytes. */
+  private captureArtifactBaseline(clientPath: string): ClientArtifactBaseline {
+    const bundle = statSync(clientPath)
+    let sourceMap: Stats | undefined
+    try {
+      sourceMap = statSync(`${clientPath}.map`)
+    } catch {
+      // Optional map metadata only seeds HMR; the following map read reports
+      // malformed or inaccessible bytes and a later stat change self-heals.
+    }
+    return {
+      path: clientPath,
+      mtimeMs: bundle.mtimeMs,
+      size: bundle.size,
+      mapMtimeMs: sourceMap?.mtimeMs ?? null,
+      mapSize: sourceMap?.size ?? null,
+    }
+  }
+
+  /** Allocate an opaque initial row revision without inspecting artifact bytes. */
+  private allocateInitialRevision(): string {
+    return `${this.initialRevisionNonce}-${String(this.nextInitialRevision++)}`
+  }
+
   /**
    * Read the activation-time bundle and optional source-map snapshots.
    * @param pkgName - package that declares the client bundle.
    * @param clientPath - absolute path of the built client artifact.
-   * @returns the immutable bytes plus the bundle content revision.
+   * @returns the immutable bytes plus the pre-read filesystem baseline.
    * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
    */
   private initialBundleSnapshot(pkgName: string, clientPath: string): {
     bundle: Buffer
-    rev: string
+    baseline: ClientArtifactBaseline
     sourceMap?: WebPluginRecord['sourceMap']
   } {
     try {
+      const baseline = this.captureArtifactBaseline(clientPath)
       const bundle = readFileSync(clientPath)
       const sourceMap = this.readSourceMapSnapshot(clientPath)
-      return { bundle, rev: artifactRevision(bundle, sourceMap), ...(sourceMap === undefined ? {} : { sourceMap }) }
+      return { bundle, baseline, ...(sourceMap === undefined ? {} : { sourceMap }) }
     } catch (error) {
       if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
       throw new MissingClientBundleError(pkgName, clientPath, error)
@@ -670,14 +728,16 @@ export class ClientModuleRegistry extends Service {
     if (this.table.has(entryName)) return false
     const meta = this.resolveMeta(entryName)
     if (meta === null) return false
-    // The rev rides the row from here on: a fiber restart reuses the row (and
-    // its rev) untouched; only rebuilt() re-reads the bundle.
+    // The opaque initial rev rides the row until HMR observes a file change;
+    // a fiber restart reuses the existing row without inspecting bytes.
     const snapshot = this.initialBundleSnapshot(entryName, meta.clientPath)
+    const rev = this.allocateInitialRevision()
     this.table.set(entryName, {
-      entry: graphRow(entryName, snapshot.rev, meta),
+      entry: graphRow(entryName, rev, meta),
       meta,
       bundle: snapshot.bundle,
-      individualBundle: individualBundle(snapshot.bundle, snapshot.rev, snapshot.sourceMap !== undefined),
+      baseline: snapshot.baseline,
+      individualBundle: individualBundle(snapshot.bundle, rev, snapshot.sourceMap !== undefined),
       ...(snapshot.sourceMap === undefined ? {} : { sourceMap: snapshot.sourceMap }),
     })
     return true

+ 54 - 16
packages/client/modules/tests/node-half.client.spec.ts

@@ -1,6 +1,6 @@
 /** Node-half composition diagnostics for package metadata and built client bundles. */
 
-import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
 import type { IncomingMessage, ServerResponse } from 'node:http'
 import { SourceMap } from 'node:module'
 import { tmpdir } from 'node:os'
@@ -281,11 +281,13 @@ describe('client bundle activation', () => {
     writeFileSync(clientPath, 'module.exports = { generation: 1 }\n')
     const { service, route } = constructWithRoute([packageName])
     const first = service.graph().batches[0]!.url
+    const firstSize = service.artifactBaseline(packageName)!.size
 
-    writeFileSync(clientPath, 'module.exports = { generation: 2 }\n')
+    writeFileSync(clientPath, 'module.exports = { generation: 200 }\n')
     service.rebuilt(packageName)
     const second = service.graph().batches[0]!.url
     expect(second).not.toBe(first)
+    expect(service.artifactBaseline(packageName)!.size).toBeGreaterThan(firstSize)
     expect((await routeRequest(route, first)).status).toBe(200)
     expect((await routeRequest(route, second)).status).toBe(200)
 
@@ -297,19 +299,28 @@ describe('client bundle activation', () => {
     expect((await routeRequest(route, third)).status).toBe(200)
   })
 
-  it('frames bundle and map fields before hashing an immutable revision', () => {
-    const packageName = '@fixture/framed-artifact-hash'
-    const clientPath = writePackage(packageName)
-    mkdirSync(dirname(clientPath), { recursive: true })
-    const map = '{"version":3,"names":[],"mappings":"AAAA","sources":["src.ts"]}\n'
-    writeFileSync(clientPath, 'module.exports = {} ')
-    writeFileSync(`${clientPath}.map`, map)
-    const first = construct([packageName]).graph().entries[0]!.rev
-
-    writeFileSync(clientPath, 'module.exports = {}')
-    writeFileSync(`${clientPath}.map`, ` ${map}`)
-    const second = construct([packageName]).graph().entries[0]!.rev
-    expect(second).not.toBe(first)
+  it('assigns opaque startup revisions instead of deriving them from artifact content', () => {
+    const firstName = '@fixture/startup-revision-first'
+    const secondName = '@fixture/startup-revision-second'
+    writeBuiltPackage(firstName, {})
+    writeBuiltPackage(secondName, {})
+
+    const service = construct([firstName, secondName])
+    const [first, second] = service.graph().entries
+    const firstMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(first!.rev)
+    const secondMatch = /^(?<nonce>[a-f\d]{16})-(?<sequence>\d+)$/.exec(second!.rev)
+    expect(firstMatch?.groups).toMatchObject({ sequence: '0' })
+    expect(secondMatch?.groups).toMatchObject({ nonce: firstMatch?.groups?.nonce, sequence: '1' })
+    const firstPath = service.clientPath(firstName)!
+    const firstStat = statSync(firstPath)
+    expect(service.artifactBaseline(firstName)).toEqual({
+      path: firstPath,
+      mtimeMs: firstStat.mtimeMs,
+      size: firstStat.size,
+      mapMtimeMs: null,
+      mapSize: null,
+    })
+    expect(service.artifactBaseline('@fixture/unknown')).toBeUndefined()
   })
 
   it('serves the source map beside a registered client bundle', async () => {
@@ -357,7 +368,10 @@ describe('client bundle activation', () => {
     expect((await routeRequest(route, `${row.url}&stale=1`.replace(`rev=${row.rev}`, 'rev=stale'))).status).toBe(404)
 
     writeFileSync(`${clientPath}.map`, '{"version":3,"names":[],"mappings":"AAAA","sources":["src/changed.tsx"]}\n')
-    expect(construct([packageName]).graph().entries[0]?.rev).not.toBe(row.rev)
+    const nextRev = service.rebuilt(packageName)
+    expect(nextRev).not.toBe(row.rev)
+    const nextMap = await routeRequest(route, `/plugins/${packageName}/client.js.map?rev=${String(nextRev)}`)
+    expect(JSON.parse(nextMap.body.toString('utf8'))).toMatchObject({ sources: ['src/changed.tsx'] })
   })
 
   it('applies sourceRoot before relocating absolute-looking section sources', async () => {
@@ -416,6 +430,30 @@ describe('client bundle activation', () => {
     expect(consumer.findEntry(0, 0)).toMatchObject({ originalSource: '/packages/demo/first.ts' })
     expect(consumer.findEntry(3, 0)).toMatchObject({ originalSource: '/packages/demo/second.ts' })
   })
+
+  it('keeps a later source-map section usable when an earlier bundle has no map', async () => {
+    const unmappedName = '@fixture/unmapped-first'
+    const mappedName = '@fixture/mapped-second'
+    const unmappedPath = writePackage(unmappedName)
+    const mappedPath = writePackage(mappedName)
+    mkdirSync(dirname(unmappedPath), { recursive: true })
+    mkdirSync(dirname(mappedPath), { recursive: true })
+    writeFileSync(unmappedPath, 'window.unmapped = true\n')
+    writeFileSync(mappedPath, 'window.mapped = true\n')
+    writeFileSync(`${mappedPath}.map`, JSON.stringify({
+      version: 3,
+      names: [],
+      mappings: 'AAAA',
+      sources: ['../../../packages/demo/mapped.ts'],
+      sourcesContent: ['export {}\n'],
+    }))
+
+    const { service, route } = constructWithRoute([unmappedName, mappedName])
+    const response = await routeRequest(route, `${service.graph().batches[0]!.url}.map`)
+    const payload = JSON.parse(response.body.toString('utf8')) as ConstructorParameters<typeof SourceMap>[0]
+    const consumer = new SourceMap(payload)
+    expect(consumer.findEntry(2, 0)).toMatchObject({ originalSource: '/packages/demo/mapped.ts' })
+  })
 })
 
 describe('shared module declarations', () => {

+ 38 - 14
packages/client/tsdown.client.ts

@@ -94,7 +94,8 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
  * earlier Host pass. A package-level tsdown.config.ts REPLACES the root
  * workspace layout, so the lib half must be restated here — dropping it leaves
  * the package without lib/index.js and the host Loader cannot import its node
- * half.
+ * half. The Client build consumes `lib/types` and chains those tsc maps, with
+ * original source content, into the standalone plugin map.
  * @param id - plugin id (package name), stamped into the __ModuleLoader__.load
  * handoff and onto the injected style tags.
  * @param libEntry - node-half entries, spelled at the call site so the
@@ -267,6 +268,7 @@ function staticLinkedConfig(id: string, entry: string, outputName = basename(ent
     // The shell compiles this artifact, so its map is the only path from a
     // browser stack frame back to the TSX (tsc emits the lib/types half).
     sourcemap: true,
+    outputOptions: { sourcemapExcludeSources: false },
     plugins: [{
       // Contract 1. `pre` because tsdown's own deps plugin would otherwise
       // resolve and inline every specifier missing from the npm production
@@ -281,18 +283,7 @@ function staticLinkedConfig(id: string, entry: string, outputName = basename(ent
           return isBareSpecifier(source) ? { id: source, external: true } : null
         },
       },
-    }, {
-      // Contract 3. Rolldown does not read the `//# sourceMappingURL` of its
-      // inputs, so each tsc map is handed over as that module's map and
-      // composed into the bundle map; without it frames stop at the emitted
-      // lib/types JavaScript instead of reaching the TSX.
-      name: 'dsh-tsc-sourcemap',
-      async load(id: string) {
-        if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
-        const code = await readFile(id, 'utf8')
-        return { code: code.replace(SOURCEMAP_COMMENT, ''), map: await readFile(`${id}.map`, 'utf8') }
-      },
-    }, {
+    }, tscSourceMapPlugin(), {
       // Contract 4. The import survives verbatim and the sheet lands beside the
       // JavaScript, so the shell's CSS Modules pipeline sees a real stylesheet.
       name: 'dsh-css-asset',
@@ -495,7 +486,7 @@ function clientConfig(id: string, entry: string): UserConfig {
           + '(type-only imports are erased and never reach this gate)',
         )
       },
-    }, {
+    }, tscSourceMapPlugin(), {
       name: 'dsh-css-modules-inline',
       resolveId(source: string, importer: string | undefined) {
         if (!source.endsWith('.module.css')) return null
@@ -554,6 +545,7 @@ function clientConfig(id: string, entry: string): UserConfig {
     }],
     outputOptions: {
       entryFileNames: 'client.js',
+      sourcemapExcludeSources: false,
       // The map is served from /plugins/<scoped-package>/client.js.map. The
       // browser resolves its local sources back into URLs that mirror the
       // /packages/<group>/<package>/src directories; sourcesContent keeps them usable
@@ -566,6 +558,38 @@ function clientConfig(id: string, entry: string): UserConfig {
   }
 }
 
+/** Chain tsc's emitted maps into any Client bundle that consumes `lib/types`. */
+function tscSourceMapPlugin() {
+  return {
+    name: 'dsh-tsc-sourcemap',
+    async load(id: string) {
+      if (!id.includes(TYPES_MARKER) || !id.endsWith('.js') || !existsSync(`${id}.map`)) return null
+      const code = await readFile(id, 'utf8')
+      const mapPath = `${id}.map`
+      const map = JSON.parse(await readFile(mapPath, 'utf8')) as {
+        sourceRoot?: unknown
+        sources?: unknown
+        sourcesContent?: unknown
+        [key: string]: unknown
+      }
+      if (!Array.isArray(map.sources) || map.sources.some(source => typeof source !== 'string')) {
+        throw new Error(`client sourcemap: ${mapPath} has invalid sources`)
+      }
+      const sources = map.sources as string[]
+      if (
+        !Array.isArray(map.sourcesContent)
+        || map.sourcesContent.length !== sources.length
+        || map.sourcesContent.some(source => typeof source !== 'string')
+      ) {
+        const sourceRoot = typeof map.sourceRoot === 'string' ? map.sourceRoot : ''
+        map.sourcesContent = await Promise.all(sources.map(async source =>
+          await readFile(resolvePath(dirname(mapPath), sourceRoot, source), 'utf8')))
+      }
+      return { code: code.replace(SOURCEMAP_COMMENT, ''), map }
+    },
+  }
+}
+
 /** Path segment separating a package's tsc output from the sources it was emitted from. */
 const TYPES_MARKER = `${sep}lib${sep}types${sep}`
 

+ 10 - 0
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -519,6 +519,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'id', description: 'entry id (package name).' }],
         returns: 'the path, or undefined for an unknown id.',
       },
+      {
+        signature: 'artifactBaseline(id: string): ClientArtifactBaseline | undefined',
+        description: 'Filesystem baseline captured before an entry\'s current bytes were read. HMR compares it with the live files when installing a watch, so a write between startup composition and watch installation cannot disappear into the watcher\'s initial state.',
+        parameters: [{ name: 'id', description: 'entry id (package name).' }],
+        returns: 'the path and baseline, or undefined for an unknown id.',
+      },
       {
         signature: 'rebuilt(id: string): string | undefined',
         description: 'Re-hash one bundle (the HMR watch\'s registration hook — the only entry point through which bundle content changes reach the graph).',
@@ -3303,6 +3309,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'Branded',
     declaration: 'export type Branded<B extends string> = string & {\n    readonly [BRAND]: B;\n};',
   },
+  {
+    name: 'ClientArtifactBaseline',
+    declaration: 'export interface ClientArtifactBaseline {\n    readonly path: string;\n    readonly mtimeMs: number;\n    readonly size: number;\n    readonly mapMtimeMs: number | null;\n    readonly mapSize: number | null;\n}',
+  },
   {
     name: 'CodeBindingErrorClass',
     declaration: 'export interface CodeBindingErrorClass {\n    name: string;\n    memberNameProperty: string;\n}',

+ 40 - 2
scripts/client-bundle-purity.spec.ts

@@ -1,7 +1,10 @@
 /**
- * Pins shared client-bundle preset rules: the module-edge purity gate and
- * the physical watch dependencies hidden behind virtual CSS Modules.
+ * Pins shared client-bundle preset rules: module-edge purity, source-map
+ * chaining, and physical watch dependencies hidden behind virtual CSS Modules.
  */
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it, vi } from 'vitest'
 import { clientBundle, requestedExternals } from '../packages/client/tsdown.client.ts'
@@ -14,6 +17,11 @@ interface CssModulePlugin {
   load?: (this: { addWatchFile: (id: string) => void }, id: string) => Promise<unknown>
 }
 
+interface SourceMapPlugin {
+  name: string
+  load?: (id: string) => Promise<unknown>
+}
+
 /** A representative dynamic bundle using the shared client baseline. */
 const REQUESTING_PACKAGE = '@deepseek-ai/dsh-client-ui-conversation'
 
@@ -59,6 +67,14 @@ function cssModulePlugin(): CssModulePlugin {
   return plugin
 }
 
+function sourceMapPlugin(): SourceMapPlugin {
+  const configs = clientConfigs()
+  const plugins = (configs[0] as { plugins: SourceMapPlugin[] }).plugins
+  const plugin = plugins.find(candidate => candidate.name === 'dsh-tsc-sourcemap')
+  if (plugin?.load === undefined) throw new Error('tsc sourcemap plugin missing from client config')
+  return plugin
+}
+
 describe('client bundle purity gate', () => {
   const resolveId = purityResolveId()
 
@@ -143,6 +159,28 @@ describe('client bundle debug artifacts', () => {
   it('emits source maps for plugin TS and TSX outside the Vite module graph', () => {
     const configs = clientConfigs()
     expect(configs[0]?.sourcemap).toBe(true)
+    expect(configs[0]?.outputOptions).toMatchObject({ sourcemapExcludeSources: false })
+  })
+
+  it('chains emitted tsc maps when the production Client build consumes lib/types', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-client-sourcemap-'))
+    try {
+      const entry = join(root, 'lib', 'types', 'client', 'index.js')
+      const source = join(root, 'src', 'client', 'index.ts')
+      const map = { version: 3, names: [], mappings: 'AAAA', sources: ['../../../src/client/index.ts'] }
+      mkdirSync(join(root, 'lib', 'types', 'client'), { recursive: true })
+      mkdirSync(join(root, 'src', 'client'), { recursive: true })
+      writeFileSync(entry, 'export const marker = true\n//# sourceMappingURL=index.js.map\n')
+      writeFileSync(`${entry}.map`, JSON.stringify(map))
+      writeFileSync(source, 'export const marker: true = true\n')
+
+      await expect(sourceMapPlugin().load!(entry)).resolves.toEqual({
+        code: 'export const marker = true',
+        map: { ...map, sourcesContent: ['export const marker: true = true\n'] },
+      })
+    } finally {
+      rmSync(root, { recursive: true, force: true })
+    }
   })
 
   it('maps first-party sources to their repository package paths', () => {

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -573,6 +573,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   WorkspaceOrderValue: 'workspace.md',
   WorkspaceRenameRequest: 'workspace.md',
   WorkspaceValue: 'workspace.md',
+  ClientArtifactBaseline: 'client-modules.md',
   WebBootGraph: 'client-modules.md',
   SessionTelemetryRecord: 'session-telemetry.md',
   WorkflowRunInfo: 'workflow.md',

+ 5 - 0
scripts/type-equiv.manifest.json

@@ -1776,6 +1776,11 @@
       "symbol": "WebBootGraph",
       "source": "packages/client/modules/src/client/manifest.ts"
     },
+    {
+      "doc": "docs/subsystems/client-modules.md",
+      "symbol": "ClientArtifactBaseline",
+      "source": "packages/client/modules/src/index.ts"
+    },
     {
       "doc": "docs/subsystems/session-telemetry.md",
       "symbol": "SessionTelemetrySharingStatus",