Selaa lähdekoodia

Merge pull request #2712 from deepseek-harness/worktree-webworker

Web Worker host runtime with packed VFS image and static preview
imccyu 1 kuukausi sitten
vanhempi
sitoutus
060070203f
100 muutettua tiedostoa jossa 7713 lisäystä ja 57 poistoa
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml
  2. 27 0
      .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md
  3. 27 0
      .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml
  5. 34 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
  6. 34 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml
  8. 34 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
  9. 34 0
      .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md
  10. 170 0
      .github/workflows/build-preview-cloudflare.yml
  11. 23 0
      .oxlintrc.json
  12. 6 0
      THIRD_PARTY_NOTICES.md
  13. 5 0
      apps/cli/package.json
  14. 11 4
      apps/web/package.json
  15. 12 0
      apps/web/src/preview.ts
  16. 1 0
      apps/web/src/vite-env.d.ts
  17. 3 1
      apps/web/tests/hmr-live.e2e.ts
  18. 242 0
      apps/web/tests/preview-boot.e2e.ts
  19. 1 1
      apps/web/tests/pwa-manifest.e2e.ts
  20. 2 1
      apps/web/tests/subagent-interrupt.e2e.ts
  21. 1 0
      apps/web/tsconfig.json
  22. 54 1
      apps/web/vite.config.ts
  23. 2 2
      docs/config-catalog.i18n.yaml
  24. 4 1
      docs/config-catalog.md
  25. 4 1
      docs/config-catalog.zh.md
  26. 2 2
      docs/event-producer-consumer.i18n.yaml
  27. 2 2
      docs/event-producer-consumer.md
  28. 2 2
      docs/event-producer-consumer.zh.md
  29. 2 2
      docs/module-graph.i18n.yaml
  30. 14 1
      docs/module-graph.md
  31. 14 1
      docs/module-graph.zh.md
  32. 16 1
      knip.json
  33. 11 4
      packages/bundle/web-app/src/index.ts
  34. 6 10
      packages/bundle/web-app/tests/web-app.spec.ts
  35. 15 2
      packages/client/connection/src/client/index.ts
  36. 1 1
      packages/client/tsdown.client.ts
  37. 14 12
      packages/client/ui-conversation/package.json
  38. 2 1
      packages/client/ui-conversation/src/client/service.ts
  39. 3 0
      packages/client/ui-conversation/tsconfig.json
  40. 2 1
      packages/client/ui-renderer/package.json
  41. 4 1
      packages/client/ui-renderer/src/client/bind.ts
  42. 7 0
      packages/client/web/src/boot.ts
  43. 2 2
      packages/experimental/README.i18n.yaml
  44. 2 0
      packages/experimental/README.md
  45. 2 0
      packages/experimental/README.zh.md
  46. 6 0
      packages/experimental/webworker-packer/README.i18n.yaml
  47. 27 0
      packages/experimental/webworker-packer/README.md
  48. 27 0
      packages/experimental/webworker-packer/README.zh.md
  49. 23 0
      packages/experimental/webworker-packer/bin.js
  50. 55 0
      packages/experimental/webworker-packer/package.json
  51. 57 0
      packages/experimental/webworker-packer/src/bin.ts
  52. 15 0
      packages/experimental/webworker-packer/src/index.ts
  53. 31 0
      packages/experimental/webworker-packer/src/invariant.ts
  54. 623 0
      packages/experimental/webworker-packer/src/pack.ts
  55. 173 0
      packages/experimental/webworker-packer/src/repository.ts
  56. 71 0
      packages/experimental/webworker-packer/src/rules.ts
  57. 26 0
      packages/experimental/webworker-packer/src/transform-image.ts
  58. 159 0
      packages/experimental/webworker-packer/tests/image-loadable.spec.ts
  59. 24 0
      packages/experimental/webworker-packer/tsconfig.json
  60. 18 0
      packages/experimental/webworker-packer/tsdown.config.ts
  61. 6 0
      packages/experimental/webworker-runtime/README.i18n.yaml
  62. 33 0
      packages/experimental/webworker-runtime/README.md
  63. 33 0
      packages/experimental/webworker-runtime/README.zh.md
  64. 65 0
      packages/experimental/webworker-runtime/package.json
  65. 33 0
      packages/experimental/webworker-runtime/src/client/api-client.ts
  66. 50 0
      packages/experimental/webworker-runtime/src/client/apply-injections.ts
  67. 345 0
      packages/experimental/webworker-runtime/src/client/client.ts
  68. 99 0
      packages/experimental/webworker-runtime/src/client/index.ts
  69. 571 0
      packages/experimental/webworker-runtime/src/compile/transform.ts
  70. 47 0
      packages/experimental/webworker-runtime/src/image-layout.ts
  71. 44 0
      packages/experimental/webworker-runtime/src/index.ts
  72. 32 0
      packages/experimental/webworker-runtime/src/invariant.ts
  73. 79 0
      packages/experimental/webworker-runtime/src/module-proxies.ts
  74. 407 0
      packages/experimental/webworker-runtime/src/module-system/module-loader.ts
  75. 169 0
      packages/experimental/webworker-runtime/src/module-system/posix-path.ts
  76. 406 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/async_hooks.ts
  77. 28 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts
  78. 398 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts
  79. 126 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/crypto.ts
  80. 156 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/events.ts
  81. 585 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts
  82. 20 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts
  83. 179 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/http.ts
  84. 73 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts
  85. 118 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/os.ts
  86. 396 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/path.ts
  87. 27 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/perf_hooks.ts
  88. 60 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/timers/promises.ts
  89. 73 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/url.ts
  90. 156 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/util.ts
  91. 14 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/util/types.ts
  92. 85 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/zlib.ts
  93. 90 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/mock/net.ts
  94. 31 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/mock/sqlite.ts
  95. 38 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts
  96. 37 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/mock/vm.ts
  97. 50 0
      packages/experimental/webworker-runtime/src/node/builtin_modules/mock/worker_threads.ts
  98. 124 0
      packages/experimental/webworker-runtime/src/node/builtins.ts
  99. 68 0
      packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts
  100. 155 0
      packages/experimental/webworker-runtime/src/node/external_packages/koffi.ts

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md
+2026-08-20-preview-cloudflare-pages-deploy.md: 38b2834d612153d235d3b0aaffead3bd2b33eec7
+2026-08-20-preview-cloudflare-pages-deploy.zh.md: 3ebb8eba95666729ee1021ffe2c958dad40aed1c

+ 27 - 0
.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.md

@@ -0,0 +1,27 @@
+# Agent Note: per-PR preview deployments on Cloudflare Pages
+
+Status: implemented
+
+English | [中文](2026-08-20-preview-cloudflare-pages-deploy.zh.md)
+
+## Problem
+
+The browser worker preview exists to observe a pull request's frontend and host code running, so it needs a static host per pull request that outsiders cannot reach. GitHub Pages publishes privately only on GitHub Enterprise Cloud, which this organization has not settled, and one Pages site per repository cannot isolate pull requests. The first deployment run also exposed a packaging defect: on a clean checkout `pnpm install` never creates the `dsh-pack-vfs-image` bin link, so `build:preview` fails with `command not found` anywhere but a working tree whose install ran after a build.
+
+## Decision
+
+**Deployment.** Every push to a pull request publishes `apps/web/dist` to the Cloudflare Pages project `dsh-build-preview` under the branch alias `pr-<number>`, behind Cloudflare Access (`.github/workflows/build-preview-cloudflare.yml`). The upload carries build products only — the platform never holds repository sources, and sourcemaps are deleted before upload because they embed complete sources. `preview.html` replaces `index.html` as the deployment root: the served page cannot boot without a host injecting `window.__DSH_BOOT__`, so the root must be the page that boots. Per pull request the newest build wins; across pull requests each alias is its own URL, so nothing contends. The run passes only after a service-token request proves the protected URL serves the packed image: HTTP 200 (Access admitted the token; 302 means the Access policy lacks its Service Auth rule), no `content-encoding` (the platform must not claim transport compression over an already-compressed body, which would leave the worker's `DecompressionStream` inflating a plain tar), and the gzip magic `1f 8b`. A marker-guarded comment states the stable alias URL once per pull request.
+
+**Bin link.** pnpm creates a workspace bin link only when the link target exists at install time. A `bin` entry naming a build product (`lib/bin.js`) therefore never gets its link on a clean checkout — building later does not revisit linking. The packer commits a root `bin.js` as the stable link target; it forwards to `lib/bin.js` and, when the build product is missing, names `pnpm run build` and exits 1. Same pattern as `dsh-subprocess-local`'s committed spawn-helper entry.
+
+## Alternatives considered
+
+**GitHub Pages, privately published.** Enterprise-Cloud-only, and `deploy-pages` replaces the whole site, so pull requests would overwrite each other; per-branch subdirectories require the legacy branch-deploy path and its build-rate limits.
+
+**Actions artifact as the preview.** Download permission aligns exactly with repository read access and costs nothing, but an artifact is a zip download, not a browsable site. Kept as the fallback if the Cloudflare surface goes away.
+
+**Documenting "install again after building" instead of committing a link target.** Leaves every clean checkout broken in an order-dependent way the error message does not explain; CI is precisely such a checkout on every run.
+
+## Consequences
+
+A pull request's preview lives at `https://pr-<number>.dsh-build-preview.pages.dev` and demands a Cloudflare Access sign-in; automation reaches it with a service token. The deployment platform holds no sources and no sourcemaps, which also means the preview cannot map its bundles back to source until sourcemap handling is designed deliberately. The image byte path — bytes stored compressed, served without transport re-encoding — is asserted on every deployment, so a platform behavior change fails the run instead of the worker boot. The packer bin works from any clean checkout after one full build, and the constraints table pins `bin.js` in the published file list.

+ 27 - 0
.agents/notes/implemented/architecture/2026-08-20-preview-cloudflare-pages-deploy.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:每 PR 预览部署上 Cloudflare Pages
+
+状态:已实现
+
+[English](2026-08-20-preview-cloudflare-pages-deploy.md) | 中文
+
+## 问题
+
+浏览器 worker 预览的存在意义是观察某个 pull request 的前端与 host 代码运行态,因此需要一个外人无法访问的、按 pull request 隔离的静态托管。GitHub Pages 的私有发布只在 GitHub Enterprise Cloud 上可用,而本组织尚未定夺;且一个仓库一个 Pages 站点无法隔离多个 pull request。首次部署运行还暴露了一个打包缺陷:干净 checkout 上 `pnpm install` 永远不会创建 `dsh-pack-vfs-image` 的 bin 链接,`build:preview` 在任何「install 不是在 build 之后跑的」工作树上都以 `command not found` 失败。
+
+## 决定
+
+**部署。**pull request 的每次推送把 `apps/web/dist` 发布到 Cloudflare Pages 项目 `dsh-build-preview` 的分支别名 `pr-<number>` 下,置于 Cloudflare Access 之后(`.github/workflows/build-preview-cloudflare.yml`)。上传只携带构建产物——平台永远拿不到仓库源码,sourcemap 因内嵌完整源码在上传前删除。`preview.html` 顶替 `index.html` 成为部署根:served 页面没有 host 注入 `window.__DSH_BOOT__` 就无法启动,所以根必须是能启动的那张页。同一 pull request 内最新构建胜出;不同 pull request 各占各的别名 URL,互不争抢。运行只有在 service token 请求证明受保护 URL 真的送达打包镜像后才算通过:HTTP 200(Access 放行了该 token;302 意味着 Access 策略缺 Service Auth 规则)、无 `content-encoding`(平台不得对已压缩的 body 声明传输压缩,否则 worker 的 `DecompressionStream` 会对着解开的裸 tar 充气)、gzip 魔数 `1f 8b`。带标记守卫的评论对每个 pull request 只报一次稳定别名 URL。
+
+**bin 链接。**pnpm 只在链接目标于 install 时已存在的情况下创建 workspace bin 链接。`bin` 指向构建产物(`lib/bin.js`)因此在干净 checkout 上永远得不到链接——事后构建不会补建链接。packer 在包根提交 `bin.js` 作为稳定链接目标;它转发到 `lib/bin.js`,构建产物缺失时点名 `pnpm run build` 并以 1 退出。与 `dsh-subprocess-local` 提交 spawn-helper 入口是同一模式。
+
+## 曾考虑的替代方案
+
+**GitHub Pages 私有发布。**Enterprise Cloud 独占,且 `deploy-pages` 整站替换,多个 pull request 会互相覆盖;按分支子目录要走遗留的分支部署通道并吃其构建频率限制。
+
+**用 Actions artifact 当预览。**下载权限与仓库 read 权限逐字对齐、零成本,但 artifact 是 zip 下载不是可浏览的站点。留作 Cloudflare 面失效时的兜底。
+
+**用「build 之后再 install 一次」的文档说明代替提交链接目标。**让每个干净 checkout 都以一种错误信息解释不了的、依赖顺序的方式坏掉;CI 每次运行恰恰就是这样的 checkout。
+
+## 后果
+
+pull request 的预览位于 `https://pr-<number>.dsh-build-preview.pages.dev`,访问要求 Cloudflare Access 登录;自动化用 service token 通行。部署平台不持有源码与 sourcemap,这也意味着在 sourcemap 处理被专门设计之前,预览无法把 bundle 映射回源码。镜像的字节通路——压缩存储、无传输再编码送达——在每次部署时被断言,平台行为变化会让运行失败而不是让 worker 启动失败。packer bin 在任何干净 checkout 上一次完整构建后即可用,constraints 表把 `bin.js` 钉进发布文件清单。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md
+2026-08-20-webworker-node-face.md: 08119cce96eff244f8e9ada3462ce5d35c1b538d
+2026-08-20-webworker-node-face.zh.md: 573c0be055d066d2d6d0db11a2476ba528517727

+ 34 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.md

@@ -0,0 +1,34 @@
+# Agent Note: the worker's Node face — builtins, VFS, and the shell process layer
+
+Status: implemented
+
+English | [中文](2026-08-20-webworker-node-face.zh.md)
+
+## Problem
+
+The worker runs the web profile's Cordis configuration byte for byte — no worker-specific rows — so a browser's missing platform must be replaced at the module layer, where a proxied module keeps its identity and changes its implementation. That covers three fronts: the Node builtins the tree imports, the filesystem those builtins answer from, and a process layer for the bash tool, which mounted, advertised itself to the model, and then failed on every call while `node:child_process` was a structural stub.
+
+## Decision
+
+**Builtins.** The proxy table replaces Node builtins and external npm packages, never workspace or vendored modules. `./implemented/<module>.ts` carries real semantics over a worker data source; `./mock/<module>.ts` mounts silently and reports the missing capability when a call reaches it. The loader's table holds one memoized thunk per specifier — evaluation happens at first `require`, not at assembly — and each shim's exported face typechecks against Node's own module type, with the narrow, documented exceptions where structural identity (a real class) cannot be satisfied. The worker installs the `process` global itself and fills it into the table at assembly.
+
+**VFS.** Memory is the truth. `statSync(path, { bigint: true })` returns Node's BigInt shape, and two fields carry real information because `dsh-fs-local`'s stale-write guard depends on them: `ino` is per-path identity from a monotonic counter (a recreated path reports a new identity), and `mtimeMs` is strictly increasing per entry (`max(now, previous + 1)`), because in-memory writes routinely land in one millisecond and an equal timestamp would let a stale overwrite pass. The hunt that produced this also fixed the silence around it: cordis's logger verbosity counts UP, so an exporter that declares no level drops every warning — `startWorkerHost` installs a console exporter with `levels: { default: 2 }` before any entry mounts.
+
+**Shell.** `node:child_process` is a real implementation over the VFS. The grammar is bought — `@yarnpkg/parsers`' `parseShell` — and the evaluator and command table are owned, because every candidate interpreter brings its own filesystem: pipelines are strings handed along, and each program is a function over the VFS. The table is the machine's whole `/bin`; an absent name reports `command not found` (127). Each `spawn` starts a child Web Worker from this same bundle, its first frame declaring the shell-process role, so the termination ladder is real: `SIGTERM` asks at the next command boundary, `SIGKILL` terminates the worker mid-loop — the preemption an in-thread interpreter can never have. The filesystem face is asynchronous end to end (child frames to the host VFS); `execSync`, `execFileSync`, and `fork` refuse, and `node-pty` stays a stub.
+
+## Alternatives considered
+
+**Replacing `dsh-subprocess-local` or the bash executor.** The first would let the proxy table replace a workspace package against its own classification and invert the layering; the second trips `dsh-permission-presets`' boot-time `sandboxMode` validation and drops tested timeout/output behavior.
+
+**`@yarnpkg/shell`, WASM shells, WebContainer.** The matching interpreter is built on real Node streams (~1.5 MB closure to own); WASM was removed from this deployment by decision and WASI has no `fork`; all of them arrive with their own filesystem, the one part that cannot be reused.
+
+**`SharedArrayBuffer` + `Atomics.wait` for a synchronous child filesystem.** Measured on the deployment target: without COOP/COEP headers `SharedArrayBuffer` is not defined, and GitHub Pages cannot set response headers. The asynchronous face is a superset; a SAB backend can slot under it later without touching a program.
+
+**Fabricating stats or widening error predicates instead of honoring `bigint`.** Constant `ino`/wall-clock `mtimeNs` silently disable the stale-write guard; swallowing `FS_IO_ERROR` in skill discovery would have made the same bug a permanently empty catalog with no failure anywhere.
+
+## Consequences
+
+- Sandbox modes other than `danger-full-access` fail loud: `SandboxEnforcement` has no "nothing was enforced" value and a browser has no kernel, so `ctx.sandbox.confine` fails closed and the command never starts. Real enforcement at the VFS frame gate is a designed follow-up, not this note.
+- The Node-host ladder test (`tests/node/child-process.spec.ts`) is registered windows-unsupported: the ladder's win32 kill rung is taskkill-by-real-pid, undeliverable to a process-table pid, while the worker itself always reports `linux`.
+- Output is incremental but not streamed: programs write into sinks forwarded as `data` events, and a pipeline stage completes before the next starts.
+- The runtime's tests mirror `src/` (`tests/node/`, `tests/shell/`, `tests/storage/`, …), so each shim family owns its behavior cases beside the oracle-diff suites.

+ 34 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-node-face.zh.md

@@ -0,0 +1,34 @@
+# Agent Note:worker 的 Node 面——builtin、VFS 与 shell 进程层
+
+状态:已实施
+
+[English](2026-08-20-webworker-node-face.md) | 中文
+
+## 问题
+
+worker 逐字节运行 web profile 的 Cordis 配置——没有 worker 专属行——因此浏览器缺失的平台必须在模块层被替换:被代理的模块保持身份、更换实现。这覆盖三条战线:树所 import 的 Node builtin、这些 builtin 背后应答的文件系统,以及 bash 工具的进程层——在 `node:child_process` 还是结构桩的时期,工具照常挂载、向模型自我宣告,然后每次调用都失败。
+
+## 决定
+
+**Builtin。** 代理表只替换 Node builtin 与外部 npm 包,绝不替换 workspace 或 vendored 模块。`./implemented/<module>.ts` 在 worker 数据源之上承载真语义;`./mock/<module>.ts` 静默挂载、在调用真正抵达时报告缺失的能力。装载器的表按 specifier 各持一个 memoized thunk——求值发生在首次 `require` 而非装配期——且每个垫片的导出面对 Node 自身的模块类型作类型检查,仅在结构身份(真实类)确不可满足处留最窄的、有说明的例外。`process` 全局由 worker 自装,装配期填入表中。
+
+**VFS。** 内存为真相。`statSync(path, { bigint: true })` 返回 Node 的 BigInt 形状,其中两个字段承载真实信息,因为 `dsh-fs-local` 的 stale-write guard 依赖它们:`ino` 是按路径的身份(单调计数器分配,路径重建即新身份),`mtimeMs` 按条目严格递增(`max(now, previous + 1)`)——内存写例行落在同一毫秒内,相等的时间戳会放过陈旧覆写。这场排查同时修掉了它周围的静默:cordis 日志器的详细度数值向上计数,未声明等级的 exporter 会丢掉所有 warning——`startWorkerHost` 在任何 entry 挂载前安装 `levels: { default: 2 }` 的 console exporter。
+
+**Shell。** `node:child_process` 是 VFS 之上的真实现。语法是买来的——`@yarnpkg/parsers` 的 `parseShell`——求值器与命令表是自有的,因为每个候选解释器都自带文件系统:管道是逐段传递的字符串,每个程序是 VFS 上的一个函数。命令表就是这台机器的全部 `/bin`;不存在的名字报告 `command not found`(127)。每次 `spawn` 从同一个 bundle 起一个子 Web Worker,首帧声明 shell 进程角色,因此终止梯是真的:`SIGTERM` 在下一命令边界处请求停止,`SIGKILL` 在任意时刻终止 worker——这是线程内解释器永远没有的抢占。文件系统面端到端异步(子进程经帧到宿主 VFS);`execSync`、`execFileSync`、`fork` 拒绝,`node-pty` 保持桩。
+
+## 曾考虑的替代方案
+
+**整包替换 `dsh-subprocess-local` 或替换 bash 执行器。** 前者让代理表首次替换 workspace 包、违背其自身分类并倒置分层;后者撞上 `dsh-permission-presets` 对 `sandboxMode` 的 boot 期硬校验,并丢掉执行器已被测试钉住的超时/输出行为。
+
+**`@yarnpkg/shell`、WASM shell、WebContainer。** 配套解释器建立在真实 Node streams 之上(约 1.5 MB 闭包要自养);WASM 已被本部署的决定排除,WASI 没有 `fork`;且它们全都自带文件系统——恰是无法复用的那部分。
+
+**`SharedArrayBuffer` + `Atomics.wait` 给子进程同步文件系统。** 在部署目标实测:无 COOP/COEP 头时 `SharedArrayBuffer` 未定义,而 GitHub Pages 无法设置响应头。异步面是超集;SAB 后端将来可垫入其下而不动任何程序。
+
+**伪造 stats 或放宽错误谓词,而非如实实现 `bigint`。** 常量 `ino`/纯挂钟 `mtimeNs` 会静默废掉 stale-write guard;让技能发现吞下 `FS_IO_ERROR` 则会把同一个 bug 变成处处无失败的永久空目录。
+
+## 后果
+
+- `danger-full-access` 之外的沙箱档 fail loud:`SandboxEnforcement` 没有「未执法」值、浏览器没有内核,`ctx.sandbox.confine` 落闭、命令零启动。在 VFS 帧闸口做真执法是设计中的后续,不属本条。
+- Node 宿主的阶梯测试(`tests/node/child-process.spec.ts`)登记为 windows 不支持:阶梯的 win32 kill 梯级是按真 pid 的 taskkill,对进程表 pid 不可投递,而 worker 自身恒报 `linux`。
+- 输出增量但不流式:程序写入的 sink 以 `data` 事件转发,一个管道阶段完成后下一阶段才开始。
+- 运行时的测试镜像 `src/`(`tests/node/`、`tests/shell/`、`tests/storage/`……),每个垫片族在 oracle-diff 套件旁拥有自己的行为用例。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md
+2026-08-20-webworker-pack-lowering-and-preview.md: d4a3d0b2125421e761eb1616a7605b58d0d77da3
+2026-08-20-webworker-pack-lowering-and-preview.zh.md: 24ff21957c31783d1b375c6589bc6114b3be1972

+ 34 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md

@@ -0,0 +1,34 @@
+# Agent Note: pack-time lowering and the single-build preview
+
+Status: implemented
+
+English | [中文](2026-08-20-webworker-pack-lowering-and-preview.zh.md)
+
+## Problem
+
+The browser worker can neither compile modules at load nor be served by the product webserver: every module body must arrive runnable, and the page must be a static artifact. Both surfaces drifted early. The loader carried a fallback compiler, so a collector gap surfaced as a slow boot instead of a broken image — and `acorn` rode into `lib/worker.js` through the package barrel, a parser a runtime that only wraps pre-lowered bodies never needs. The preview was a second HTML template beside the served one, a page the served index could silently drift away from.
+
+## Decision
+
+**Lowering happens at pack time only.** `@deepseek-ai/dsh-experimental-webworker-packer` composes the profile, materializes the closure, and lowers every JavaScript body; `LOWERING_VERSION` and `WRAPPER_PARAMS` are the pack↔worker contract and live in `src/image-layout.ts` beside the rest of the image layout. The loader wraps bodies exactly as the image holds them: a body still carrying module syntax is a refusal naming the image, and `startWorkerHost` requires the manifest's `lowered` to equal this build's contract before it mounts a single module. `lowerModuleSource` is the transform's only face and the packer its only caller; inside the worker graph, imports name the module that owns the value — never the package barrel, which is the edge that smuggled the parser in.
+
+**The preview is the served page plus one tag.** One Vite build emits `dist/index.html` and `dist/preview.html` sharing every chunk; the only difference is a prepended bootstrap entry whose module connects the worker host. Startup then converges on one protocol: whichever side applies the injection table settles the `__DSH_BOOT_READY__` deferred — the served renderer resolves it in a tail script after the rendered rows, the worker bootstrap installs it before its first await and settles it after the last row — and the client entry awaits it before reading any injected state, so the chain from the stock entry onward is the served chain verbatim. The build uses a relative base so the output mounts under any static directory; the served form anchors deep SPA-fallback paths by rendering `<base href="/">` at serve time, keeping the on-disk pages byte-shared.
+
+Both packages live in `packages/experimental/` as `@deepseek-ai/dsh-experimental-*`, private and outside official releases. The boundary that carries product promises stays in the product packages: the injection table, `__DSH_TRANSPORT__`, and the `/plugins` bundle bytes are owned by `dsh-host-webserver`, `dsh-client-modules`, and `dsh-client-connection`.
+
+## Alternatives considered
+
+**A load-time transform as a safety net.** It turned a broken image into a timing regression nobody attributed, and made "which path lowered this body" unanswerable from outside.
+
+**Contract constants inside the transform, trusting tree shaking.** The transform functions did shake out, but `acorn` declares no `sideEffects`, so the barrel edge alone carried the whole parser into the worker bundle.
+
+**A separate preview template.** The retired `preview.html` template duplicated the served document and drifted (language, title, entry wiring). Deriving the page from the built index at `closeBundle` removes the second document entirely.
+
+**Gating the stock entry on top-level await ordering instead of a deferred.** Sibling module scripts do not wait for one another's top-level awaits; the `??=`-installed deferred makes the handshake order-independent and lets a failed handshake reject into the boot page's failure rendering.
+
+## Consequences
+
+- `lib/worker.js` contains no parser (423.5 kB → 246.3 kB at the time of the cut, before the shell process layer landed).
+- `diff dist/index.html dist/preview.html` is exactly one script tag; `packages/experimental/webworker-packer/tests/image-loadable.spec.ts` pins both halves of the loader contract, and `apps/web/tests/preview-boot.e2e.ts` pins preview usability (boot to an interactive page) in the web browser lane, replacing the retired `apps/web/scripts/preview/` probe scripts.
+- The served `<base href="/">` anchor exists because relative asset URLs would resolve under the request directory on SPA-fallback paths; remove it only together with the relative build base.
+- The image ships as a deterministically gzip-compressed tar (`vfs-image.tar.gz`; MTIME 0, OS byte 0xff): static hosts do not compress binary content types (type allowlists, CDN size caps), so the compression rides the artifact, and the worker inflates the fetch body through the browser's native `DecompressionStream` while it downloads.

+ 34 - 0
.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md

@@ -0,0 +1,34 @@
+# Agent Note:pack 期 lowering 与单构建 preview
+
+状态:已实施
+
+[English](2026-08-20-webworker-pack-lowering-and-preview.md) | 中文
+
+## 问题
+
+浏览器 worker 既不能在装载期编译模块,也不能由产品 webserver 提供页面:每个模块体必须以可直接运行的形态到达,页面必须是静态产物。两个面早期都发生过漂移。装载器曾携带一个兜底编译器,于是收集器的缺口表现为「启动变慢」而不是「镜像坏了」——而且 `acorn` 经包 barrel 混进了 `lib/worker.js`,一个只包装预 lowered 模块体的运行时根本不需要解析器。preview 曾是服务页面旁的第二份 HTML 模板,一个 served index 可以悄悄漂离的页面。
+
+## 决定
+
+**Lowering 只发生在 pack 期。** `@deepseek-ai/dsh-experimental-webworker-packer` 组合 profile、物化闭包、lower 每个 JavaScript 模块体;`LOWERING_VERSION` 与 `WRAPPER_PARAMS` 是 pack↔worker 的契约,与镜像布局的其余部分一起放在 `src/image-layout.ts`。装载器完全按镜像持有的形态包装模块体:仍带模块语法的模块体是一次点名镜像的拒绝,且 `startWorkerHost` 在挂载任何模块之前要求 manifest 的 `lowered` 等于本构建的契约。`lowerModuleSource` 是转换器唯一的面、packer 是它唯一的调用方;worker 图内部的 import 一律指向拥有该值的模块——绝不指向包 barrel,那正是把解析器偷运进来的那条边。
+
+**preview 就是服务页面加一个标签。** 一次 Vite 构建产出共享全部 chunk 的 `dist/index.html` 与 `dist/preview.html`;唯一差异是前插的一个引导入口,其模块负责连接 worker host。启动随之汇于一个协议:应用注入表的一方 settle `__DSH_BOOT_READY__` deferred——served 渲染器在渲染完的行之后用尾部脚本 resolve,worker 引导段在首个 await 之前安装、末行生效后 settle——client 入口在读取任何注入状态前 await 它,因此从标准入口起的链路逐字就是 served 链路。构建使用相对 base,产物可挂载于任意静态目录;served 形态在 serve 期渲染 `<base href="/">` 锚定深层 SPA fallback 路径,磁盘上的两个页面保持字节共享。
+
+两个包以 `@deepseek-ai/dsh-experimental-*` 名义放在 `packages/experimental/`,私有且在官方发布之外。承载产品承诺的边界仍在产品包里:注入表、`__DSH_TRANSPORT__` 与 `/plugins` bundle 字节由 `dsh-host-webserver`、`dsh-client-modules`、`dsh-client-connection` 拥有。
+
+## 曾考虑的替代方案
+
+**保留装载期转换器作安全网。** 它把坏镜像变成无人归因的耗时回归,并且让「这个模块体是谁 lower 的」从外部不可回答。
+
+**契约常量留在转换器里,信任 tree shaking。** 转换函数确实被摇掉了,但 `acorn` 未声明 `sideEffects`,仅 barrel 一条边就把整个解析器带进了 worker bundle。
+
+**独立的 preview 模板。** 已退役的 `preview.html` 模板复制了服务文档并发生漂移(语言、标题、入口接线)。在 `closeBundle` 从 built index 派生页面则彻底消灭了第二份文档。
+
+**用顶层 await 顺序而非 deferred 去闸标准入口。** 兄弟 module script 互不等待对方的顶层 await;`??=` 安装的 deferred 使握手与求值顺序无关,且失败的握手能 reject 进 boot 页的失败呈现。
+
+## 后果
+
+- `lib/worker.js` 不含解析器(当刀落时为 423.5 kB → 246.3 kB,早于 shell 进程层落地)。
+- `diff dist/index.html dist/preview.html` 恰为一个 script 标签;`packages/experimental/webworker-packer/tests/image-loadable.spec.ts` 钉住装载器契约的两半,`apps/web/tests/preview-boot.e2e.ts` 在 web 浏览器车道钉住 preview 可用性(boot 到可交互页面),替代已撤编的 `apps/web/scripts/preview/` 探针脚本。
+- served 的 `<base href="/">` 锚存在的原因是:相对资产 URL 在 SPA fallback 深路径下会解析进请求目录;只有与相对构建 base 一起才可移除它。
+- 镜像以确定性 gzip 压缩的 tar 交付(`vfs-image.tar.gz`;MTIME 0、OS 字节 0xff):静态托管不压缩二进制 content-type(类型白名单、CDN 尺寸帽),压缩必须随制品走;worker 用浏览器原生 `DecompressionStream` 在下载的同时解压 fetch body。

+ 170 - 0
.github/workflows/build-preview-cloudflare.yml

@@ -0,0 +1,170 @@
+name: Build PR preview
+
+# Every push to a pull request publishes that pull request's preview to
+# Cloudflare Pages under its own branch alias, behind Cloudflare Access. The
+# upload carries build products only: the workflow never grants the deployment
+# platform access to this repository's sources.
+
+on:
+  pull_request:
+    types: [opened, synchronize, reopened]
+
+# Within one pull request the newest build wins. Across pull requests there is
+# nothing to serialize: each uploads to its own branch alias, so two deployments
+# never contend for the same URL.
+concurrency:
+  group: build-preview-cloudflare-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+permissions:
+  contents: read
+  pull-requests: write
+
+env:
+  PRIMARY_NODE_VERSION: '24'
+  # Cloudflare Pages project receiving the upload. Its preview deployments are
+  # the surface the Access application protects; the project's production branch
+  # is deliberately a name no deployment uses, so no unprotected URL exists.
+  CF_PROJECT: dsh-build-preview
+  # CI runs must never report to the production telemetry endpoint baked into
+  # apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+  DSH_TELEMETRY_DISABLED: '1'
+
+jobs:
+  preview:
+    runs-on: dsh-ubuntu-24-04-16core
+    name: cloudflare pages preview
+    steps:
+      - uses: actions/checkout@v6
+        with:
+          persist-credentials: false
+
+      - uses: pnpm/action-setup@v4
+        with:
+          dest: ${{ runner.temp }}/setup-pnpm
+
+      - uses: actions/setup-node@v6
+        with:
+          node-version: ${{ env.PRIMARY_NODE_VERSION }}
+
+      - name: Configure pnpm store path
+        id: pnpm-store
+        run: |
+          store_root="$HOME/.local/share/pnpm/store"
+          echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
+          store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
+          echo "path=$store_path" >> "$GITHUB_OUTPUT"
+
+      # Read-only: the preview lane consumes the default-branch cache without
+      # putting cache upload on its own path.
+      - uses: actions/cache/restore@v4
+        with:
+          path: ${{ steps.pnpm-store.outputs.path }}
+          key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
+          restore-keys: |
+            ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
+
+      - name: Install (immutable)
+        run: pnpm install --frozen-lockfile
+
+      # apps/web consumes workspace packages as built lib products, and
+      # build:preview packs the image through the packer's installed bin
+      # (lib/bin.js), so neither half exists before the full build runs.
+      - name: Build workspace
+        run: pnpm run build
+
+      - name: Build the preview page and pack the VFS image
+        env:
+          DSH_CLIENT_TITLE: DSH preview pr-${{ github.event.pull_request.number }}
+        run: pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview
+
+      # Sourcemaps carry complete sources and stay off the deployment platform.
+      # index.html is the served page, which cannot boot without a host
+      # injecting window.__DSH_BOOT__; replacing it with the worker page makes
+      # the deployment root the usable entry instead of a page that never boots.
+      - name: Shape the upload
+        run: |
+          find apps/web/dist -name '*.map' -delete
+          cp apps/web/dist/preview.html apps/web/dist/index.html
+
+      - name: Upload to Cloudflare Pages
+        env:
+          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+        run: |
+          npx --yes wrangler@4 pages deploy apps/web/dist \
+            --project-name "$CF_PROJECT" \
+            --branch "pr-${{ github.event.pull_request.number }}" \
+            --commit-dirty=true
+
+      # The image is what a worker boot fails on first and least visibly, so the
+      # run only passes once the protected URL serves it as gzip bytes. Three
+      # facts are asserted, each with its own failure meaning:
+      #   200                     Access admitted the request; a 302 means the
+      #                           Access policy is missing its Service Auth rule
+      #                           for this token
+      #   no content-encoding     the platform did not claim transport
+      #                           compression, which would make the browser
+      #                           decode the body and leave the worker's
+      #                           DecompressionStream inflating a plain tar
+      #   gzip magic 1f 8b        the bytes really are the gzip member the
+      #                           packer wrote
+      # Accept-Encoding is sent because a browser sends it; the assertion is
+      # about what the platform does with a body that is already compressed.
+      - name: Verify the protected deployment serves the image
+        env:
+          CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
+          CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
+        run: |
+          url="https://pr-${{ github.event.pull_request.number }}.${CF_PROJECT}.pages.dev"
+          image="$url/preview/vfs-image.tar.gz"
+          code=000
+          for attempt in 1 2 3 4 5; do
+            code=$(curl -sS -o image.bin -D headers.txt -w '%{http_code}' \
+              -H 'Accept-Encoding: gzip' \
+              -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
+              -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
+              "$image" || echo 000)
+            echo "attempt $attempt: HTTP $code"
+            if [ "$code" = "200" ]; then break; fi
+            sleep 10
+          done
+          if [ "$code" != "200" ]; then
+            echo "the protected image URL answered $code, not 200"
+            head -20 headers.txt
+            exit 1
+          fi
+          if grep -qi '^content-encoding:' headers.txt; then
+            echo "the platform declared transport compression on an already-compressed image:"
+            grep -i '^content-encoding:' headers.txt
+            exit 1
+          fi
+          magic=$(head -c 2 image.bin | od -An -tx1 | tr -d ' \n')
+          if [ "$magic" != "1f8b" ]; then
+            echo "image does not start with the gzip magic number: $magic"
+            exit 1
+          fi
+          echo "image served as $(wc -c < image.bin) gzip bytes"
+
+      # The alias URL follows from the pull request number, so it is stable
+      # across redeploys and worth stating once. The marker makes the comment
+      # idempotent: a pull request opened before this workflow existed never
+      # sees an `opened` event, and every later push must not restate the URL.
+      - name: Comment the preview URL
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          PR: ${{ github.event.pull_request.number }}
+        run: |
+          marker='<!-- dsh-preview-url -->'
+          existing=$(gh pr view "$PR" --json comments \
+            --jq "[.comments[] | select(.body | contains(\"$marker\")) | .url] | first // empty")
+          if [ -n "$existing" ]; then
+            echo "preview URL already commented: $existing"
+            exit 0
+          fi
+          # The marker sits on its own line: markdown renders no link on a
+          # line that opens with a raw HTML comment.
+          printf '%s\n\n%s\n' \
+            "$marker" \
+            "[Preview for #$PR](https://pr-$PR.${CF_PROJECT}.pages.dev) (requires Cloudflare Access sign-in)" \
+            | gh pr comment "$PR" --body-file -

+ 23 - 0
.oxlintrc.json

@@ -57,6 +57,14 @@
           }
         ],
         "no-useless-constructor": "error",
+        "no-restricted-properties": [
+          "error",
+          {
+            "object": "crypto",
+            "property": "randomUUID",
+            "message": "browsers withhold crypto.randomUUID outside secure contexts (plain-HTTP LAN pages); mint through @deepseek-ai/dsh-util-crypto instead"
+          }
+        ],
         "typescript/await-thenable": "error",
         "typescript/ban-ts-comment": [
           "error",
@@ -316,6 +324,21 @@
       "rules": {
         "@stylistic/quotes": "off"
       }
+    },
+    {
+      "files": [
+        "packages/experimental/webworker-runtime/src/node/**/*.ts",
+        "packages/experimental/webworker-runtime/src/storage/memory.ts",
+        "packages/experimental/webworker-runtime/src/module-system/module-loader.ts",
+        "packages/experimental/webworker-runtime/src/transport/synthetic-http.ts"
+      ],
+      "rules": {
+        "typescript/require-await": "off", // Async faces Node and Cordis define (fs promises, the module seam) reject rather than throw; the VFS beneath them never awaits.
+        "typescript/no-extraneous-class": "off" // Node constructs these (`new Script()`, `new Worker()`), so a stub that refuses must still be a class.
+      },
+      "plugins": [
+        "typescript"
+      ]
     }
   ]
 }

+ 6 - 0
THIRD_PARTY_NOTICES.md

@@ -39,6 +39,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
 | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT |
 | [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT |
+| [`@noble/hashes`](https://github.com/paulmillr/noble-hashes) | MIT |
 | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 |
 | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
 | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 |
@@ -51,7 +52,10 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
 | [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
+| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
+| [`acorn`](https://github.com/acornjs/acorn) | MIT |
 | [`anser`](https://github.com/IonicaBizau/anser) | MIT |
+| [`buffer`](https://github.com/feross/buffer) | MIT |
 | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
 | [`clsx`](https://github.com/lukeed/clsx) | MIT |
 | [`commander`](https://github.com/tj/commander.js) | MIT |
@@ -136,6 +140,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/use-sync-external-store`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/ws`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT |
 | [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT |
@@ -148,6 +153,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only |
 | [`execa`](https://github.com/sindresorhus/execa) | MIT |
 | [`fast-check`](https://github.com/dubzzz/fast-check) | MIT |
+| [`http-server`](https://github.com/http-party/http-server) | MIT |
 | [`istanbul-lib-report`](https://github.com/istanbuljs/istanbuljs) | BSD-3-Clause |
 | [`jscpd`](https://github.com/kucherenko/jscpd) | MIT |
 | [`jsdom`](https://github.com/jsdom/jsdom) | MIT |

+ 5 - 0
apps/cli/package.json

@@ -18,6 +18,11 @@
     "lib/*.js",
     "config"
   ],
+  "dsh": {
+    "configTrees": [
+      { "mount": "config/agent-presets", "path": "config/agent-presets", "scanRoster": true }
+    ]
+  },
   "license": "MIT",
   "dependencies": {
     "@deepseek-ai/cordis-plugin-hmr": "workspace:^",

+ 11 - 4
apps/web/package.json

@@ -17,12 +17,16 @@
   },
   "files": [
     "dist",
-    "!dist/**/*.map"
+    "!dist/**/*.map",
+    "!dist/preview.html",
+    "!dist/preview"
   ],
   "scripts": {
     "build": "vite build",
     "dev": "vite",
-    "watch": "vite build --watch --no-emptyOutDir"
+    "watch": "vite build --watch --no-emptyOutDir",
+    "build:preview": "pnpm --filter @deepseek-ai/dsh-experimental-webworker-runtime exec tsdown && pnpm --filter @deepseek-ai/dsh-experimental-webworker-packer exec tsdown && vite build && dsh-pack-vfs-image --out dist/preview/vfs-image.tar.gz",
+    "serve:preview": "http-server dist -a 0.0.0.0 -p 4173 -c-1"
   },
   "license": "MIT",
   "devDependencies": {
@@ -33,16 +37,19 @@
     "@deepseek-ai/dsh-client-web": "workspace:^",
     "@deepseek-ai/dsh-cmdline": "workspace:^",
     "@deepseek-ai/dsh-pwsh-local": "workspace:^",
+    "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^",
+    "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
     "@types/node": "^22.0.0",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
     "@vitejs/plugin-react": "^4.0.0",
+    "http-server": "^14.1.1",
+    "fflate": "^0.8.2",
     "playwright": "^1.49.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "typescript": "^6.0.3",
     "vite": "^6.0.0",
-    "vitest": "^4.1.8",
-    "fflate": "^0.8.2"
+    "vitest": "^4.1.8"
   }
 }

+ 12 - 0
apps/web/src/preview.ts

@@ -0,0 +1,12 @@
+/**
+ * Worker-preview bootstrap: the one module preview.html adds ahead of the
+ * stock entry tag. Connecting the worker host installs the boot globals and
+ * settles `__DSH_BOOT_READY__`, where the stock entry's pre-boot await holds,
+ * so everything after this module is the served startup chain verbatim. A
+ * failed handshake rejects the deferred into the boot page's failure
+ * rendering; this module owns no page painting.
+ */
+import DshWorker from '@deepseek-ai/dsh-experimental-webworker-runtime/worker?worker'
+import { connectWorkerHost, IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime/client'
+
+await connectWorkerHost(new DshWorker({ name: 'dsh-host' }), { image: `preview/${IMAGE_FILE_NAME}` })

+ 1 - 0
apps/web/src/vite-env.d.ts

@@ -0,0 +1 @@
+/// <reference types="vite/client" />

+ 3 - 1
apps/web/tests/hmr-live.e2e.ts

@@ -114,7 +114,9 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
     await page.goto(baseUrl, { waitUntil: 'load' })
     await page.getByText(oldText, { exact: true }).waitFor({ timeout: 15_000 })
     const pageIdentity = await page.evaluate(() => {
-      const identity = crypto.randomUUID()
+      // In-page code: an import would not survive serialization, and the page
+      // entropy source available in every context is getRandomValues.
+      const identity = Array.from(crypto.getRandomValues(new Uint8Array(8)), byte => byte.toString(16).padStart(2, '0')).join('')
       Object.defineProperty(window, '__dshHmrPageIdentity', { value: identity })
       return identity
     })

+ 242 - 0
apps/web/tests/preview-boot.e2e.ts

@@ -0,0 +1,242 @@
+/**
+ * Preview acceptance: the browser-only worker deployment boots the real Cordis
+ * tree out of the packed VFS image and reaches an interactive page.
+ *
+ * `dist/preview.html` is the served page plus one bootstrap script tag, so this
+ * run exercises the shipped startup chain: the worker mounts the image,
+ * activates the tree, and answers the page's tunnel until the client settles.
+ * Two milestones prove that happened — the host's `tree active` boot line,
+ * whose lowering contract must be the one this checkout's packer emits, and the
+ * workspace hero, which paints only after the client tree comes up over the
+ * tunnel.
+ *
+ * The site is served the way a static host serves it: bytes from `dist/` with
+ * no rewrite rules, so a missing file is a 404 rather than the index page.
+ */
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import type { IncomingMessage, ServerResponse } from 'node:http'
+import { tmpdir } from 'node:os'
+import { extname, join, normalize } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium } from 'playwright'
+import type { Browser } from 'playwright'
+import { expect, it } from 'vitest'
+import {
+  composeProfile, configTrees, indexWorkspacePackages, packVfsImage, WRAPPER_CONTRACT,
+} from '@deepseek-ai/dsh-experimental-webworker-packer'
+import { IMAGE_FILE_NAME } from '@deepseek-ai/dsh-experimental-webworker-runtime'
+import { newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
+
+const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
+
+/** Where the client looks for the image: the runtime's own name, beside the page. */
+const IMAGE_FILE = join(DIST_ROOT, 'preview', IMAGE_FILE_NAME)
+
+/** Profile the preview deployment composes; `build:preview` packs the same one. */
+const PROFILE = 'web'
+
+/** Pages the preview needs; the Vite build emits both. */
+const PAGES = ['index.html', 'preview.html']
+
+/**
+ * Content types the preview loads. Anything else is served as opaque bytes.
+ *
+ * The image goes out as `application/gzip` with no `content-encoding`: the
+ * worker inflates the gzip member itself, so a transport-decoded body would
+ * leave its `DecompressionStream('gzip')` with plain tar bytes to inflate.
+ */
+const MIME: Record<string, string> = {
+  '.html': 'text/html; charset=utf-8',
+  '.js': 'text/javascript; charset=utf-8',
+  '.css': 'text/css; charset=utf-8',
+  '.json': 'application/json; charset=utf-8',
+  '.map': 'application/json; charset=utf-8',
+  '.svg': 'image/svg+xml',
+  '.gz': 'application/gzip',
+  '.webmanifest': 'application/manifest+json',
+  '.woff2': 'font/woff2',
+}
+
+/** Boot line the worker host writes once its tree finished activating. */
+const TREE_ACTIVE = 'webworker host: tree active'
+
+/** Image fetch, mount, and tree activation on a loaded machine. */
+const BOOT_TIMEOUT_MS = 240_000
+
+/** Client tree settle after the tunnel starts answering. */
+const HERO_TIMEOUT_MS = 240_000
+
+/** One served origin over `dist/`. */
+interface Site {
+  readonly origin: string
+  /** Release the port; call after the browser is gone. */
+  close(): Promise<void>
+}
+
+/**
+ * Fail before the browser opens a page the build never produced.
+ * @throws When either preview page is missing from `dist/`.
+ */
+function requirePreviewPages(): void {
+  for (const page of PAGES) {
+    if (existsSync(join(DIST_ROOT, page))) continue
+    throw new Error(`preview boot needs apps/web/dist/${page} — run \`pnpm run build\` from the repository root`)
+  }
+}
+
+/**
+ * The image file to serve, packed here when `dist/` carries none: `pnpm run
+ * build` emits the pages but only `build:preview` packs, so this lane packs
+ * for itself rather than skipping the deployment it is here to accept. An
+ * image already in place is used as it stands — the worker refuses one lowered
+ * against another wrapper contract, and that refusal names the rebuild. A
+ * self-packed image lands in a temp directory, never in `dist/`: the
+ * client-artifact digest record treats `dist/` as build-owned, so a test write
+ * there fails the record check for every later consumer.
+ * @returns The file to answer `preview/<image>` with, and its teardown.
+ * @throws When the closure leaves dependencies unresolved, which would pack an
+ * incomplete image the tree fails on later and further from the cause.
+ */
+function requireVfsImage(): { path: string; cleanup(): void } {
+  if (existsSync(IMAGE_FILE)) return { path: IMAGE_FILE, cleanup: () => {} }
+  const packed = packVfsImage({
+    config: composeProfile(REPO_ROOT, PROFILE),
+    profile: PROFILE,
+    workspaces: indexWorkspacePackages(REPO_ROOT),
+    resolveFrom: REPO_ROOT,
+    configTrees: configTrees(REPO_ROOT),
+  })
+  if (packed.missing.length > 0) {
+    throw new Error(`preview boot: ${String(packed.missing.length)} dependencies did not resolve: ${packed.missing.join(', ')}`)
+  }
+  const directory = mkdtempSync(join(tmpdir(), 'dsh-preview-boot-'))
+  const path = join(directory, IMAGE_FILE_NAME)
+  writeFileSync(path, packed.image)
+  return { path, cleanup: () => { rmSync(directory, { recursive: true, force: true }) } }
+}
+
+/**
+ * Answer one request with the file it names under `dist/`; the image path
+ * answers from wherever {@link requireVfsImage} put the file.
+ * @param request - Incoming request; only its path is read.
+ * @param response - Response to write the bytes or the 404 to.
+ * @param imagePath - File behind `preview/<image>`.
+ */
+async function respond(request: IncomingMessage, response: ServerResponse, imagePath: string): Promise<void> {
+  const path = new URL(request.url ?? '/', 'http://127.0.0.1').pathname
+  const relative = normalize(decodeURIComponent(path)).replace(/^\/+/, '')
+  try {
+    const body = await readFile(relative === `preview/${IMAGE_FILE_NAME}` ? imagePath : join(DIST_ROOT, relative))
+    response.writeHead(200, { 'content-type': MIME[extname(relative)] ?? 'application/octet-stream' })
+    response.end(body)
+  } catch {
+    // A miss is a miss: the deployment has no SPA fallback, and hiding one
+    // behind the index page would make a broken asset URL look like a boot
+    // failure.
+    response.writeHead(404)
+    response.end(`not found: ${relative}`)
+  }
+}
+
+/**
+ * Serve `dist/` over loopback with static-host semantics.
+ * @param imagePath - File behind `preview/<image>`.
+ * @returns The origin to navigate, and its teardown.
+ */
+async function serveDist(imagePath: string): Promise<Site> {
+  const server = createServer((request, response) => { void respond(request, response, imagePath) })
+  await new Promise<void>((listening) => { server.listen(0, '127.0.0.1', listening) })
+  const address = server.address()
+  if (address === null || typeof address === 'string') throw new Error('preview boot: the static server bound no port')
+  return {
+    origin: `http://127.0.0.1:${String(address.port)}`,
+    close: async () => {
+      server.closeAllConnections()
+      await new Promise<void>((closed, reject) => {
+        server.close((error) => {
+          if (error === undefined) closed()
+          else reject(error)
+        })
+      })
+    },
+  }
+}
+
+/**
+ * Bound one boot milestone so a stall names the milestone instead of surfacing
+ * as the lane's generic test timeout.
+ * @param work - The milestone to wait for.
+ * @param ms - How long it may take.
+ * @param stalled - Error message when it does not arrive in time.
+ * @returns What `work` resolved to.
+ */
+async function within<T>(work: Promise<T>, ms: number, stalled: string): Promise<T> {
+  let timer: NodeJS.Timeout | undefined
+  try {
+    return await Promise.race([
+      work,
+      new Promise<never>((_, reject) => { timer = setTimeout(() => { reject(new Error(stalled)) }, ms) }),
+    ])
+  } finally {
+    clearTimeout(timer)
+  }
+}
+
+it('boots the packed worker deployment to an interactive page', async () => {
+  requirePreviewPages()
+  const image = requireVfsImage()
+  try {
+    const site = await serveDist(image.path)
+    try {
+      const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] })
+      try {
+        await bootPreview(site.origin, browser)
+      } finally {
+        await browser.close()
+      }
+    } finally {
+      await site.close()
+    }
+  } finally {
+    image.cleanup()
+  }
+}, 600_000)
+
+/**
+ * Open the preview page and hold it to both boot milestones.
+ * @param origin - Origin serving `dist/`.
+ * @param browser - Browser to open the page in.
+ */
+async function bootPreview(origin: string, browser: Browser): Promise<void> {
+  const page = await newEnglishPage(browser)
+  const pageErrors: Error[] = []
+  page.on('pageerror', (error) => { pageErrors.push(error) })
+  // Registered before navigation: the worker reports its tree long before the
+  // tunnel serves the client, so a listener added later would miss the line.
+  const treeActive = new Promise<string>((reported) => {
+    page.on('console', (message) => {
+      const text = message.text()
+      if (text.includes(TREE_ACTIVE)) reported(text)
+    })
+  })
+  try {
+    await page.goto(`${origin}/preview.html`, { waitUntil: 'domcontentloaded' })
+    const bootLine = await within(treeActive, BOOT_TIMEOUT_MS, `preview boot: the worker never reported "${TREE_ACTIVE}"`)
+    // The activated tree ran bodies lowered against the contract this
+    // checkout's packer emits; a dist built before a contract change would
+    // report the older one.
+    expect(bootLine).toContain(`image lowering=${WRAPPER_CONTRACT}`)
+    // The hero's workspace picker is the client tree's first interactive
+    // surface, so it appears only once the startup chain completed over the
+    // tunnel.
+    await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
+    expect(pageErrors.map(error => error.message)).toEqual([])
+  } catch (error) {
+    await saveFailureShot(page, 'preview-boot')
+    throw pageErrors.length === 0
+      ? error
+      : new AggregateError([error, ...pageErrors], 'preview boot failed, with uncaught page errors')
+  }
+}

+ 1 - 1
apps/web/tests/pwa-manifest.e2e.ts

@@ -7,7 +7,7 @@ const DIST_ROOT = fileURLToPath(new URL('../dist', import.meta.url))
 
 it('ships install metadata with the built web application', async () => {
   const index = await readFile(join(DIST_ROOT, 'index.html'), 'utf8')
-  expect(index).toContain('<link rel="manifest" href="/manifest.webmanifest" />')
+  expect(index).toContain('<link rel="manifest" href="./manifest.webmanifest" />')
 
   const manifest: unknown = JSON.parse(await readFile(join(DIST_ROOT, 'manifest.webmanifest'), 'utf8'))
   expect(manifest).toEqual({

+ 2 - 1
apps/web/tests/subagent-interrupt.e2e.ts

@@ -5,6 +5,7 @@
 // parked without auto-starting a new turn, and a later waking send resumed the
 // preserved FIFO order. No browser: the RPC surface is the product surface
 // under test, and subagent-interrupt-ui.e2e.ts owns the composer interaction.
+import { randomUUID } from 'node:crypto'
 import { existsSync } from 'node:fs'
 import { mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
@@ -28,7 +29,7 @@ async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promis
     headers: { 'content-type': 'application/json' },
     body: JSON.stringify({
       type: 'client-request',
-      rpcId: `interrupt-e2e-${method}-${crypto.randomUUID()}`,
+      rpcId: `interrupt-e2e-${method}-${randomUUID()}`,
       method,
       payload,
     }),

+ 1 - 0
apps/web/tsconfig.json

@@ -49,6 +49,7 @@
     "tests/workspace-management.e2e.ts",
     "tests/replay-round-trip.e2e.ts",
     "tests/hmr-live.e2e.ts",
+    "tests/preview-boot.e2e.ts",
     "tests/seeded-history.e2e.ts",
     "tests/cold-blank-session.e2e.ts",
     "tests/stats-paged-history.e2e.ts",

+ 54 - 1
apps/web/vite.config.ts

@@ -1,3 +1,4 @@
+import { readFile, writeFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { defineConfig } from 'vite'
 import type { Plugin } from 'vite'
@@ -36,6 +37,35 @@ function rejectStandaloneServe(): Plugin {
   }
 }
 
+/**
+ * Emit preview.html beside index.html: the built index page with one module
+ * script — the worker bootstrap entry — spliced ahead of its entry tag. Both
+ * pages share every chunk; the extra tag is the only difference, so the
+ * static worker deployment ships the served page verbatim plus its
+ * bootstrap.
+ */
+function emitPreviewPage(): Plugin {
+  let bootstrapFile: string | undefined
+  return {
+    name: 'dsh-emit-preview-page',
+    generateBundle(_options, bundle) {
+      for (const item of Object.values(bundle)) {
+        if (item.type === 'chunk' && item.isEntry && item.name === 'bootstrap') bootstrapFile = item.fileName
+      }
+      if (bootstrapFile === undefined) throw new Error('vite: preview bootstrap entry missing from the bundle')
+    },
+    async closeBundle() {
+      // A build that failed before generateBundle has no page to splice.
+      if (bootstrapFile === undefined) return
+      const page = await readFile(src('./dist/index.html'), 'utf8')
+      const anchor = page.indexOf('<script type="module"')
+      if (anchor === -1) throw new Error('vite: built index.html lost its module entry tag')
+      const tag = `<script type="module" crossorigin src="./${bootstrapFile}"></script>`
+      await writeFile(src('./dist/preview.html'), `${page.slice(0, anchor)}${tag}${page.slice(anchor)}`)
+    },
+  }
+}
+
 /**
  * Vendor-chunk membership, by exact npm package name — the heavy render
  * families (math, highlight, markdown) that change only on dependency bumps.
@@ -108,11 +138,30 @@ function npmPackageOf(id: string): string | undefined {
 }
 
 export default defineConfig({
-  plugins: [rejectStandaloneServe(), clientDocumentTitle(), react()],
+  // Relative asset URLs: preview.html mounts the same output under any base
+  // directory, and the served index resolves identically from the site root.
+  base: './',
+  plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
   build: {
+    // The worker bootstrap holds its page at top-level await; Vite's default
+    // `modules` target (es2020-era) rejects that syntax.
+    target: 'es2022',
     sourcemap: true,
     rollupOptions: {
+      input: {
+        index: src('./index.html'),
+        // Standalone entry, not an index.html script tag: Vite folds every
+        // module tag of one page into a single synthetic entry, and only a
+        // separate input keeps the shared page chunks bootstrap-free.
+        bootstrap: src('./src/preview.ts'),
+      },
       output: {
+        // The worker-preview surface groups under dist/preview/ (the page
+        // itself stays at dist/preview.html), so the published payload can
+        // exclude it as one directory.
+        entryFileNames(chunk): string {
+          return chunk.name === 'bootstrap' ? 'preview/[name]-[hash].js' : 'assets/[name]-[hash].js'
+        },
         // Output layout: the two main chunks stay at assets/ root; lazy
         // @shikijs/langs grammar chunks group under assets/langs/; fonts
         // (all KaTeX faces referenced by vendor.css) group under
@@ -144,6 +193,10 @@ export default defineConfig({
       },
     },
   },
+  worker: {
+    // The preview worker rides dist/preview/ with the rest of that surface.
+    rollupOptions: { output: { entryFileNames: 'preview/[name]-[hash].js' } },
+  },
   resolve: {
     // One instance per shared npm identity: a bare specifier otherwise resolves
     // from the importer's directory, so a diverging range ships a second React

+ 2 - 2
docs/config-catalog.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/config-catalog.md
-config-catalog.md: de340b7ffade528301b4538b0553bc11ec969985
-config-catalog.zh.md: eb17ee89fd7860cc0774073bea542aca315ce652
+config-catalog.md: bc79f900e863fa9ef357a533fe4ba857a20a8bcb
+config-catalog.zh.md: cfddaf2620dd8c3dba05ccd785c6aeceb14a9bba

+ 4 - 1
docs/config-catalog.md

@@ -3084,7 +3084,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts)
+Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
 
 <a id="deepseek-aidsh-web-fetch-http"></a>
 
@@ -3331,6 +3331,8 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
 - `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts))
 - `@deepseek-ai/dsh-code-runtime-python` ([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts))
+- `@deepseek-ai/dsh-experimental-webworker-packer` ([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts))
+- `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts))
 - `@deepseek-ai/dsh-home-paths` ([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts))
 - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
 - `@deepseek-ai/dsh-launch-environment` ([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts))
@@ -3350,3 +3352,4 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
 - `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts))
 - `@deepseek-ai/dsh-typert-protocol` ([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts))
 - `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts))
+- `@deepseek-ai/dsh-util-crypto` ([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts))

+ 4 - 1
docs/config-catalog.zh.md

@@ -3086,7 +3086,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/bundle/web-app/src/index.ts:42`](../packages/bundle/web-app/src/index.ts)
+来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
 
 <a id="deepseek-aidsh-web-fetch-http"></a>
 
@@ -3332,6 +3332,8 @@ export interface Config {
 - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts))
 - `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts))
 - `@deepseek-ai/dsh-code-runtime-python`([`packages/code-runtime/code-runtime-python/src/index.ts`](../packages/code-runtime/code-runtime-python/src/index.ts))
+- `@deepseek-ai/dsh-experimental-webworker-packer`([`packages/experimental/webworker-packer/src/index.ts`](../packages/experimental/webworker-packer/src/index.ts))
+- `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts))
 - `@deepseek-ai/dsh-home-paths`([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts))
 - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
 - `@deepseek-ai/dsh-launch-environment`([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts))
@@ -3351,3 +3353,4 @@ export interface Config {
 - `@deepseek-ai/dsh-typert-generator`([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts))
 - `@deepseek-ai/dsh-typert-protocol`([`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts))
 - `@deepseek-ai/dsh-typert-registry`([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts))
+- `@deepseek-ai/dsh-util-crypto`([`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: 1fb65d55f5a0d8121f4f171c956196fde746103f
-event-producer-consumer.zh.md: d8db21e5266f83a5fc403a9b825bc05530e61b8d
+event-producer-consumer.md: 52a8003beb55c178f2ae7513f2b21d3a6c686b2b
+event-producer-consumer.zh.md: c66e07657ec3be4bd4ce1c461782a04f1b1edf8f

+ 2 - 2
docs/event-producer-consumer.md

@@ -59,7 +59,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -72,7 +72,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | Event string | Dispatchers | Listeners |
 | --- | --- | --- |
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
-| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `webserver` |
+| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 

+ 2 - 2
docs/event-producer-consumer.zh.md

@@ -61,7 +61,7 @@
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
-| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | - |
+| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -74,7 +74,7 @@
 | 事件字符串 | 派发方 | 监听方 |
 | --- | --- | --- |
 | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workflow`](../packages/workflow/workflow) |
-| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `webserver` |
+| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` |
 | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` |
 | `internal/status` | - | [`agent`](../packages/core/agent) |
 

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 531def00118795d3d8c6812baa215e71ca499bc0
-module-graph.zh.md: 6d6794da2f1545c7d420f4c4f161c2711143b16c
+module-graph.md: edc3adfa3badf075abbd1167de6acc4a6f1d0037
+module-graph.zh.md: fc2d6e0d9a77012c38b2b3bc6a1434ac8700fa59

+ 14 - 1
docs/module-graph.md

@@ -15,6 +15,7 @@ flowchart TD
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
     pkg_timeout["timeout"]
+    pkg_util_crypto["util-crypto"]
   end
   subgraph group_llm["packages/llm"]
     pkg_llm["llm"]
@@ -196,6 +197,8 @@ flowchart TD
   subgraph group_experimental["packages/experimental"]
     pkg_experimental_agent_team["experimental-agent-team"]
     pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
+    pkg_experimental_webworker_packer["experimental-webworker-packer"]
+    pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
   end
   subgraph group_extensions["packages/extensions"]
     pkg_client_ui_cordis["client-ui-cordis"]
@@ -341,6 +344,7 @@ flowchart TD
   pkg_native_command --> pkg_invariants
   pkg_output_retention --> pkg_invariants
   pkg_timeout --> pkg_invariants
+  pkg_util_crypto --> pkg_invariants
   pkg_scope --> pkg_invariants
   pkg_cmdline --> pkg_invariants
   pkg_base --> pkg_invariants
@@ -351,6 +355,7 @@ flowchart TD
   pkg_code_runtime_python --> pkg_invariants
   pkg_e2b --> pkg_invariants
   pkg_sdk_jsonrpc_demo --> pkg_invariants
+  pkg_experimental_webworker_packer --> pkg_invariants
   pkg_host_directory_picker --> pkg_invariants
   pkg_host_directory_picker_browse --> pkg_invariants
   pkg_host_directory_picker_native --> pkg_invariants
@@ -1151,6 +1156,10 @@ flowchart TD
   pkg_experimental_tool_agent_team --> pkg_session
   pkg_experimental_tool_agent_team --> pkg_system_prompt
   pkg_experimental_tool_agent_team --> pkg_tools
+  pkg_experimental_webworker_runtime --> pkg_client_modules
+  pkg_experimental_webworker_runtime --> pkg_host_apiproxy
+  pkg_experimental_webworker_runtime --> pkg_host_webserver
+  pkg_experimental_webworker_runtime --> pkg_invariants
   pkg_sdk_client --> pkg_invariants
   pkg_sdk_client --> pkg_llm
   pkg_sdk_client --> pkg_sdk_protocol
@@ -1301,6 +1310,7 @@ flowchart TD
   pkg_client_ui_conversation --> pkg_token_meter
   pkg_client_ui_conversation --> pkg_tool_todo
   pkg_client_ui_conversation --> pkg_tools
+  pkg_client_ui_conversation --> pkg_util_crypto
   pkg_client_ui_sidebar --> pkg_client_locale
   pkg_client_ui_sidebar --> pkg_client_runtime
   pkg_client_ui_sidebar --> pkg_client_ui_layout
@@ -1470,6 +1480,7 @@ flowchart TD
 | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1480,6 +1491,7 @@ flowchart TD
 | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1644,6 +1656,7 @@ flowchart TD
 | [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1663,7 +1676,7 @@ flowchart TD
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
 | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
-| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) |
+| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 14 - 1
docs/module-graph.zh.md

@@ -17,6 +17,7 @@ flowchart TD
     pkg_native_command["native-command"]
     pkg_output_retention["output-retention"]
     pkg_timeout["timeout"]
+    pkg_util_crypto["util-crypto"]
   end
   subgraph group_llm["packages/llm"]
     pkg_llm["llm"]
@@ -198,6 +199,8 @@ flowchart TD
   subgraph group_experimental["packages/experimental"]
     pkg_experimental_agent_team["experimental-agent-team"]
     pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
+    pkg_experimental_webworker_packer["experimental-webworker-packer"]
+    pkg_experimental_webworker_runtime["experimental-webworker-runtime"]
   end
   subgraph group_extensions["packages/extensions"]
     pkg_client_ui_cordis["client-ui-cordis"]
@@ -343,6 +346,7 @@ flowchart TD
   pkg_native_command --> pkg_invariants
   pkg_output_retention --> pkg_invariants
   pkg_timeout --> pkg_invariants
+  pkg_util_crypto --> pkg_invariants
   pkg_scope --> pkg_invariants
   pkg_cmdline --> pkg_invariants
   pkg_base --> pkg_invariants
@@ -353,6 +357,7 @@ flowchart TD
   pkg_code_runtime_python --> pkg_invariants
   pkg_e2b --> pkg_invariants
   pkg_sdk_jsonrpc_demo --> pkg_invariants
+  pkg_experimental_webworker_packer --> pkg_invariants
   pkg_host_directory_picker --> pkg_invariants
   pkg_host_directory_picker_browse --> pkg_invariants
   pkg_host_directory_picker_native --> pkg_invariants
@@ -1153,6 +1158,10 @@ flowchart TD
   pkg_experimental_tool_agent_team --> pkg_session
   pkg_experimental_tool_agent_team --> pkg_system_prompt
   pkg_experimental_tool_agent_team --> pkg_tools
+  pkg_experimental_webworker_runtime --> pkg_client_modules
+  pkg_experimental_webworker_runtime --> pkg_host_apiproxy
+  pkg_experimental_webworker_runtime --> pkg_host_webserver
+  pkg_experimental_webworker_runtime --> pkg_invariants
   pkg_sdk_client --> pkg_invariants
   pkg_sdk_client --> pkg_llm
   pkg_sdk_client --> pkg_sdk_protocol
@@ -1303,6 +1312,7 @@ flowchart TD
   pkg_client_ui_conversation --> pkg_token_meter
   pkg_client_ui_conversation --> pkg_tool_todo
   pkg_client_ui_conversation --> pkg_tools
+  pkg_client_ui_conversation --> pkg_util_crypto
   pkg_client_ui_sidebar --> pkg_client_locale
   pkg_client_ui_sidebar --> pkg_client_runtime
   pkg_client_ui_sidebar --> pkg_client_ui_layout
@@ -1472,6 +1482,7 @@ flowchart TD
 | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`util-crypto`](../packages/util/crypto) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1482,6 +1493,7 @@ flowchart TD
 | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) |
@@ -1646,6 +1658,7 @@ flowchart TD
 | [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
 | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) |
 | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
+| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
 | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
@@ -1665,7 +1678,7 @@ flowchart TD
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) |
 | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-runtime`](../packages/client/runtime), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) |
-| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools) |
+| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-settings`](../packages/client/ui-settings), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-layout`](../packages/client/ui-layout), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`invariants`](../packages/runtime-diagnostics/invariants) |

+ 16 - 1
knip.json

@@ -211,6 +211,20 @@
         "tests/**/*.ts"
       ]
     },
+    "packages/experimental/webworker-runtime": {
+      "entry": [
+        "tests/**/*.spec.ts",
+        "tests/compile/transform-corpus-check.ts"
+      ],
+      "project": [
+        "src/**/*.ts",
+        "tests/**/*.ts"
+      ],
+      "ignoreDependencies": [
+        "buffer",
+        "@deepseek-ai/dsh-client-modules"
+      ]
+    },
     "packages/typert/generator": {
       "entry": [
         "tests/**/*.spec.ts",
@@ -611,7 +625,8 @@
         "tests/**/*.perf.ts",
         "tests/**/*.snapshot.ts",
         "tests/support.ts",
-        "src/node-module-stub.ts"
+        "src/node-module-stub.ts",
+        "src/preview.ts"
       ],
       "project": [
         "src/**/*.ts",

+ 11 - 4
packages/bundle/web-app/src/index.ts

@@ -13,6 +13,7 @@
 
 import { spawn, type ChildProcess } from 'node:child_process'
 import { createRequire } from 'node:module'
+import { dirname, join } from 'node:path'
 import { networkInterfaces } from 'node:os'
 import { fileURLToPath } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
@@ -159,14 +160,20 @@ function localWebUrl(ctx: Context): string {
   return `http://${LOOPBACK_HOST}:${String(port)}`
 }
 
-/** Dist location is workspace knowledge of this bundle: resolved through the frontend package exports, not configured. */
+/**
+ * Dist location is workspace knowledge of this bundle: anchored on the
+ * frontend package manifest, not configured. Existence is a request-time
+ * concern — the fallback owner reads files per request, so a composition
+ * whose page never reaches the fallback seat (the static worker preview
+ * ships its own page and carries no dist) boots without one.
+ */
 function resolveDistIndex(): string {
   const require = createRequire(import.meta.url)
   try {
-    return require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html')
+    return join(dirname(require.resolve('@deepseek-ai/dsh-web-frontend/package.json')), 'dist', 'index.html')
   } catch {
-    /* v8 ignore next 2 -- reachable only on a checkout without a built dist; the test tree builds it */
-    throw new Error('web-app: frontend dist not built; run pnpm run build from the repository root first')
+    /* v8 ignore next 2 -- reachable only when the frontend package is absent from the checkout */
+    throw new Error('web-app: @deepseek-ai/dsh-web-frontend is not resolvable from this composition')
   }
 }
 

+ 6 - 10
packages/bundle/web-app/tests/web-app.spec.ts

@@ -286,16 +286,12 @@ describe('web-app runtime glue', () => {
     await ctx.fiber.dispose()
   })
 
-  it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => {
-    // The production resolver (not the test hook). A built checkout resolves
-    // the frontend package's index.html; a dist-less one (the CI coverage
-    // lane runs before any build) must fail with the build hint, never a
-    // silent fallback.
-    try {
-      expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
-    } catch (error) {
-      expect((error as Error).message).toContain('frontend dist not built')
-    }
+  it('anchors the dist index on the frontend package manifest without requiring a built dist', () => {
+    // The production resolver (not the test hook): the anchor resolves on any
+    // checkout, built or not — dist existence is the fallback owner's
+    // request-time concern, so a dist-less composition (the static worker
+    // preview ships its own page) still boots.
+    expect(originalResolve()).toMatch(/dist[/\\]index\.html$/)
   })
 
   it.each([

+ 15 - 2
packages/client/connection/src/client/index.ts

@@ -70,6 +70,15 @@ export interface ClientTransportHooks {
    * bundles load over HTTP.
    */
   loadBundle?(url: string): Promise<void>
+  /**
+   * 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
+   * the loopback stand-in for "the operator's own machine" is vacuous.
+   * `ctx.connection.isLoopback` then reports the privileged surface reachable
+   * regardless of the page authority. Only a shell that assembles its own
+   * transport can set this; served pages never carry the global at all.
+   */
+  ownsHost?: boolean
 }
 
 /** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */
@@ -85,7 +94,11 @@ interface ClientTransportGlobal {
 export interface ConnectionHandle {
   /** Shared api client (fixture or real, decided at boot from the page URL). */
   readonly api: IApiClient
-  /** Whether the current page authority is loopback; non-browser contexts default to true. */
+  /**
+   * Whether the privileged surface is reachable: the page authority is
+   * loopback, the transport declares the page owns the Host
+   * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser.
+   */
   readonly isLoopback: boolean
   /** Generation-scoped Host facts, including the account home and native path-open capability. */
   readonly hostDescription: HostDescriptionSource
@@ -129,7 +142,7 @@ export function apply(ctx: Context): void {
   }
   const handle: ConnectionHandle = {
     api,
-    isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
+    isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
     hostDescription: {
       getSnapshot: () => description,
       subscribe: (listener) => {

+ 1 - 1
packages/client/tsdown.client.ts

@@ -58,7 +58,7 @@ function styleInjectionModule(
  * Everything else under @deepseek-ai/* is either a module-table entry
  * (external) or a leak the purity gate rejects.
  */
-export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand)(\/|$)/
+export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|file-reference|session|llm|tools|brand|util-crypto)(\/|$)/
 
 /**
  * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below

+ 14 - 12
packages/client/ui-conversation/package.json

@@ -48,8 +48,8 @@
   },
   "license": "MIT",
   "dependencies": {
-    "clsx": "^2.0.0",
-    "@deepseek-ai/schemastery": "workspace:^"
+    "@deepseek-ai/schemastery": "workspace:^",
+    "clsx": "^2.0.0"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -60,21 +60,22 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
+    "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
+    "@deepseek-ai/dsh-permission-presets": "workspace:^",
+    "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
+    "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
-    "@deepseek-ai/dsh-goal": "workspace:^",
-    "@deepseek-ai/dsh-permission-presets": "workspace:^",
-    "@deepseek-ai/dsh-plan-mode": "workspace:^",
-    "@deepseek-ai/dsh-tool-todo": "workspace:^"
+    "@deepseek-ai/dsh-util-crypto": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
@@ -86,10 +87,10 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
@@ -100,12 +101,13 @@
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@types/react": "~18.3.1",
-    "react": "^18.2.0",
-    "@deepseek-ai/dsh-settings": "workspace:^"
+    "react": "^18.2.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 1
packages/client/ui-conversation/src/client/service.ts

@@ -9,6 +9,7 @@
  */
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
 // Type-only imports: a plugin-to-plugin value import is a bundle purity
 // error, so scope resolution goes through the sessions service (scopeOf
 // method) instead of the standalone helper.
@@ -63,7 +64,7 @@ export interface IConversation {
 function browserDraftAttachment(file: File): ComposerAttachment {
   return {
     kind: 'image',
-    id: crypto.randomUUID() as DraftAttachmentId,
+    id: randomUUID() as DraftAttachmentId,
     previewUrl: URL.createObjectURL(file),
     file,
   }

+ 3 - 0
packages/client/ui-conversation/tsconfig.json

@@ -76,6 +76,9 @@
     },
     {
       "path": "../ui-settings"
+    },
+    {
+      "path": "../../util/crypto"
     }
   ],
   "exclude": [

+ 2 - 1
packages/client/ui-renderer/package.json

@@ -52,13 +52,14 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",
-    "@deepseek-ai/cordis": "workspace:^",
+    "@types/use-sync-external-store": "^1.5.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0"
   },

+ 4 - 1
packages/client/ui-renderer/src/client/bind.ts

@@ -4,7 +4,10 @@
  * This is the ONE hook constructor in the client stack — engines and hosts
  * traffic in bare sources; binding happens on the React side.
  */
-import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector.js'
+// Extensionless on purpose: the runtime package has no exports map, so both
+// bundler and NodeNext resolution accept this form, while `@types/…` exposes
+// only the extensionless subpath under its exports.
+import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
 import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 
 /**

+ 7 - 0
packages/client/web/src/boot.ts

@@ -45,6 +45,13 @@ export class AppWebEntry {
    */
   async run(): Promise<void> {
     try {
+      // Boot-readiness gate: whichever bootstrap applies the injection table
+      // settles this deferred once every row has taken effect — the served
+      // index resolves it in the rendered tail, so the await returns on the
+      // next microtask; an asynchronous bootstrap resolves it after its last
+      // row, or rejects it into the failure rendering below. An absent global
+      // means no bootstrap owns the document and there is nothing to wait for.
+      await (globalThis as { __DSH_BOOT_READY__?: { promise: Promise<void> } }).__DSH_BOOT_READY__?.promise
       const win = globalThis as DshWindow
       const moduleLoader = win.__ModuleLoader__
       if (moduleLoader === undefined) {

+ 2 - 2
packages/experimental/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/experimental/README.md
-README.md: 0e92ebd2bd959ac807830400dd57807b87b1cbe2
-README.zh.md: a1751c39f53bf8f6c0a9c623aba57355f35c2681
+README.md: 43d96d1c539b2ec35d7a818f60270e6d17db54e9
+README.zh.md: 27bb4d73b0d8baa4614e79abbf20a1f988f8652c

+ 2 - 0
packages/experimental/README.md

@@ -8,5 +8,7 @@ This group contains prototypes and internal-only Cordis plugins that use the rep
 |---|---|---|
 | `agent-team/` | Implicit-root Agent Teams roster, durable peer mailbox, shared task DAG, and runtime coordination | `ctx.agentTeams` |
 | `tool-agent-team/` | Scoped model-facing Agent Teams tools and collaboration guidance | — |
+| `webworker-runtime/` | Browser-only host runtime: in-memory VFS, module loader, postMessage tunnel, and the dedicated Web Worker assembly | — |
+| `webworker-packer/` | Build-time packer that materializes a profile's package closure into the VFS image the worker mounts | — |
 
 The [subtree rules](AGENTS.md) define dependency isolation, release exclusion, and promotion.

+ 2 - 0
packages/experimental/README.zh.md

@@ -8,5 +8,7 @@
 |---|---|---|
 | `agent-team/` | 隐式 root Agent Teams roster、持久 peer mailbox、共享任务 DAG 与运行时协调 | `ctx.agentTeams` |
 | `tool-agent-team/` | 按 Agent 作用域提供的 Agent Teams 模型工具与协作指引 | — |
+| `webworker-runtime/` | 纯浏览器 host 运行时:内存 VFS、模块装载器、postMessage 隧道与 dedicated Web Worker 装配 | — |
+| `webworker-packer/` | 构建期打包器:把 profile 的包闭包物化成 worker 挂载的 VFS 镜像 | — |
 
 [子树规则](AGENTS.md)规定依赖隔离、发布排除与 promotion。

+ 6 - 0
packages/experimental/webworker-packer/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/experimental/webworker-packer/README.md
+README.md: 15313f7b75169cc7a8749670900a0635605401e7
+README.zh.md: 2e2daba4c006e4d15db619e138447d5e239df4f6

+ 27 - 0
packages/experimental/webworker-packer/README.md

@@ -0,0 +1,27 @@
+# `@deepseek-ai/dsh-experimental-webworker-packer`
+
+English | [中文](README.zh.md)
+
+The VFS image packer: turns one composed profile into the single gzip-compressed tar the browser worker inflates and mounts as its filesystem ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). Nothing is compiled from source — the image carries the repository's real build products, so a preview deployment debugs exactly what the served deployment ships.
+
+The pack is a three-layer standard stack:
+
+1. **Roster** — the composed profile's plugin rows (standard YAML parse under Include's dialect, `!!js` intact), plus the rows of every config tree the CLI declares in its `package.json` `dsh.configTrees` (agent presets), materialized as a Node-style dependency closure. External peer edges never bind the worker; workspace peers stay on the chain.
+2. **Publish view** — each workspace package contributes the slice npm would publish (`files` through picomatch) minus the rule tables in `src/rules.ts` (no sources, no workspace `dist/`; external packages keep their trees minus the same exclude globs).
+3. **Reachability sweep** — the runtime loader's own resolution walks from every workspace export face plus the worker assembly's seeds (`IMAGE_ENTRY_SEEDS`), lowering each reached module to the wrapper contract at pack time. Page assets (`lib/client.js` behind `./client` exports) ship verbatim; an unresolvable request from our own code fails the pack, third-party ones are tolerated to fail loud at require time.
+
+`repository.ts` owns the repo-shaped inputs (workspace scan of `vendor/`, `packages/`, `apps/`; profile composition through the real CLI dump path); `pack.ts` owns none of them, so the same library packs a different tree by being called differently. The CLI is `dsh-pack-vfs-image --out <file> [--profile web]`; `apps/web`'s `build:preview` runs it after the preview shell build.
+
+## Model Experience
+
+None, as this package runs at build time and writes an image file; nothing it produces reaches a model request on its own.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **The rule tables are judgement calls** (`rules.ts`: exclude globs, page-asset patterns, entry seeds) pinned by `tests/`; a new asset class the worker must reach needs a table row, not a scanner change.
+- **Vendored package sources (`src/*.ts`) no longer pack** — nothing resolves them at runtime; a future in-worker source-inspection feature would need a dedicated include rule.
+- **The packer assumes built `lib/` artifacts are current**: it never compiles, so a stale workspace build packs stale bytes. Run the repository build first.

+ 27 - 0
packages/experimental/webworker-packer/README.zh.md

@@ -0,0 +1,27 @@
+# `@deepseek-ai/dsh-experimental-webworker-packer`
+
+[English](README.md) | 中文
+
+VFS 镜像打包器:把一份合成 profile 变成浏览器 worker 解压后当文件系统挂载的单个 gzip 压缩 tar([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。不做任何源码编译——镜像携带仓库真实构建产物,预览部署调试的正是 served 部署交付的字节。
+
+打包是三层标准栈:
+
+1. **Roster**——合成 profile 的插件行(标准 YAML 解析、Include 方言、`!!js` 原样保留),加上 CLI 在 `package.json` `dsh.configTrees` 里声明的每棵配置树(agent presets)的行,按 Node 式依赖闭包物化。外部包的 peer 边不追,workspace peer 保留在链上。
+2. **发布视图**——每个 workspace 包贡献 npm 会发布的切片(`files` 走 picomatch),再减去 `src/rules.ts` 的规则表(无源码、无 workspace `dist/`;外部包保留整棵减同一套 exclude glob)。
+3. **可达性 sweep**——用运行时加载器自己的解析,从全部 workspace 导出面加 worker 装配种子(`IMAGE_ENTRY_SEEDS`)出发,pack 时把每个可达模块降低到包装契约。页面资产(`./client` 导出背后的 `lib/client.js`)原样直发;自家代码的不可解析请求打包即失败,第三方的容忍到 require 时 fail loud。
+
+`repository.ts` 拥有仓库形态输入(`vendor/`、`packages/`、`apps/` 的 workspace 扫描;经真 CLI dump 路径合成 profile);`pack.ts` 一概不拥有,同一库换参即可打另一棵树。CLI 为 `dsh-pack-vfs-image --out <file> [--profile web]`;`apps/web` 的 `build:preview` 在预览壳构建后运行它。
+
+## 模型体验
+
+无:本包在构建期运行并写出镜像文件,其产物本身不进入任何模型请求。
+
+#### KV Cache 影响
+
+无:本包既不组装也不发送 provider 请求。
+
+## Known Limitations and Deferred Work
+
+- **规则表是判断题**(`rules.ts`:exclude glob、页面资产模式、入口种子),由 `tests/` 钉住;worker 需要触达的新资产类别应加表行,而不是改扫描器。
+- **vendored 包源码(`src/*.ts`)不再打包**——运行时无人解析它们;未来若有 worker 内源码巡检功能需要专门的 include 规则。
+- **打包器假定构建产物 `lib/` 是新鲜的**:它从不编译,工作区构建过期就打包过期字节。先跑仓库构建。

+ 23 - 0
packages/experimental/webworker-packer/bin.js

@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+/**
+ * Stable link target for the `dsh-pack-vfs-image` bin, forwarding to the build
+ * product.
+ *
+ * pnpm creates a workspace package's bin link only when the link target exists
+ * at install time. Pointing the bin straight at `lib/bin.js` — a build product —
+ * left the link uncreated on every clean checkout, so the command was missing
+ * from `node_modules/.bin` even after a build produced the file, and only an
+ * install that happened to follow a build brought it back. This file is
+ * committed, so the link is always created; the build product is resolved when
+ * the command actually runs.
+ * @module @deepseek-ai/dsh-experimental-webworker-packer/bin
+ */
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+const entry = new URL('./lib/bin.js', import.meta.url)
+if (!existsSync(fileURLToPath(entry))) {
+  process.stderr.write('dsh-pack-vfs-image: lib/bin.js is missing — run `pnpm run build` before packing an image\n')
+  process.exit(1)
+}
+await import(entry.href)

+ 55 - 0
packages/experimental/webworker-packer/package.json

@@ -0,0 +1,55 @@
+{
+  "name": "@deepseek-ai/dsh-experimental-webworker-packer",
+  "description": "Build-time packer for the browser runtime's VFS image: materializes a profile's package closure into one gzip-compressed tar the worker mounts, with every module body pre-transformed",
+  "version": "0.1.1-rc.2",
+  "private": true,
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/experimental/webworker-packer"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "bin": {
+    "dsh-pack-vfs-image": "./bin.js"
+  },
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/bin.js",
+    "bin.js",
+    "lib/repository-*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/cordis-plugin-include": "workspace:^",
+    "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^",
+    "@deepseek-ai/dsh-home-paths": "workspace:^",
+    "js-yaml": "^4.2.0",
+    "picomatch": "^4.0.4"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/js-yaml": "^4.0.9",
+    "@types/picomatch": "^3.0.2"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^"
+  }
+}

+ 57 - 0
packages/experimental/webworker-packer/src/bin.ts

@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+/**
+ * Pack a VFS image from this repository: compose the profile, materialize the
+ * closure, lower every module body, write the gzip-compressed tar.
+ *
+ * Usage: dsh-pack-vfs-image --out <file> [--profile web] [--root /dsh]
+ *        node --import tsx/esm src/bin.ts --out ../../apps/web/dist/preview/vfs-image.tar.gz
+ * @module @deepseek-ai/dsh-experimental-webworker-packer/src/bin
+ */
+import { mkdirSync, writeFileSync } from 'node:fs'
+import { dirname, isAbsolute, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { packVfsImage } from './pack.ts'
+import { composeProfile, configTrees, describePack, indexWorkspacePackages } from './repository.ts'
+
+/**
+ * Read one `--flag value` pair.
+ * @param name - Flag name without dashes.
+ * @param fallback - Value when the flag is absent.
+ * @returns The value.
+ * @throws When the flag is present with no value, because silently packing the
+ * default profile is worse than stopping.
+ */
+function flag(name: string, fallback?: string): string {
+  const index = process.argv.indexOf(`--${name}`)
+  if (index === -1) {
+    if (fallback !== undefined) return fallback
+    throw new Error(`dsh-pack-vfs-image: --${name} is required`)
+  }
+  const value = process.argv[index + 1]
+  if (value === undefined || value.startsWith('--')) {
+    throw new Error(`dsh-pack-vfs-image: --${name} needs a value`)
+  }
+  return value
+}
+
+const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
+const profile = flag('profile', 'web')
+const out = flag('out')
+const outputFile = isAbsolute(out) ? out : resolve(process.cwd(), out)
+
+const result = packVfsImage({
+  config: composeProfile(repoRoot, profile),
+  profile,
+  root: flag('root', '/dsh'),
+  workspaces: indexWorkspacePackages(repoRoot),
+  resolveFrom: repoRoot,
+  configTrees: configTrees(repoRoot),
+})
+
+if (result.missing.length > 0) {
+  throw new Error(`vfs image: ${String(result.missing.length)} dependencies did not resolve; the image would be incomplete`)
+}
+
+mkdirSync(dirname(outputFile), { recursive: true })
+writeFileSync(outputFile, result.image)
+process.stdout.write(describePack(result, repoRoot, outputFile).join('\n'))

+ 15 - 0
packages/experimental/webworker-packer/src/index.ts

@@ -0,0 +1,15 @@
+/**
+ * Build-time packer for the browser runtime's VFS image.
+ * @module @deepseek-ai/dsh-experimental-webworker-packer
+ */
+export {
+  WRAPPER_CONTRACT,
+  type ImageFiles, type TransformOutcome,
+} from './transform-image.ts'
+export {
+  CONFIG_PATH, DEFAULT_ROOT, MANIFEST_PATH, packVfsImage,
+  type ConfigTree, type PackOptions, type PackResult,
+} from './pack.ts'
+export {
+  composeProfile, configTrees, describePack, indexWorkspacePackages,
+} from './repository.ts'

+ 31 - 0
packages/experimental/webworker-packer/src/invariant.ts

@@ -0,0 +1,31 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-experimental-webworker-packer`.
+ * @module @deepseek-ai/dsh-experimental-webworker-packer/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-webworker-packer'
+
+/** Cordis companion plugin name. */
+export const name = 'webworker-packer-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: this package is a build-time pass with no
+ * production event stream or mutable data; the pack's own gates (unresolvable
+ * own requests, the all-or-nothing wrapper contract) fail the pack instead.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 623 - 0
packages/experimental/webworker-packer/src/pack.ts

@@ -0,0 +1,623 @@
+/**
+ * VFS image packer: turns one composed profile plus a package index into the single
+ * gzip-compressed tar the browser runtime inflates and mounts as its filesystem.
+ *
+ * Nothing is compiled here. The image carries the repository's real build products,
+ * so a preview deployment debugs exactly what the served deployment ships. What the
+ * pass does add is the pack-time module transform and the manifest that records the
+ * wrapper contract it was transformed against.
+ *
+ * This module holds no repository knowledge: paths, globs, and the composition come
+ * in as parameters, so the same library packs a different tree by being called
+ * differently. Locating those inputs is the CLI's job.
+ * @module @deepseek-ai/dsh-experimental-webworker-packer/src/pack
+ */
+import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
+import { dirname, join, relative } from 'node:path'
+import { gzipSync } from 'node:zlib'
+
+import {
+  lowerModuleSource, MemoryVfs, packTar, WorkerModuleLoader,
+  DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_MANIFEST_PATH,
+} from '@deepseek-ai/dsh-experimental-webworker-runtime'
+import picomatch from 'picomatch'
+import yaml from 'js-yaml'
+import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
+import { REPLACED_EXTERNAL_PACKAGES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/external_packages/replaced-externals.ts'
+import { MODULE_PROXIES, MODULE_PROXY_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-proxies.ts'
+import { WRAPPER_CONTRACT, type ImageFiles, type TransformOutcome } from './transform-image.ts'
+import { EXCLUDE, EXCLUDE_WORKSPACE, IMAGE_ENTRY_SEEDS, PAGE_ASSETS } from './rules.ts'
+
+export { DEFAULT_ROOT } from '@deepseek-ai/dsh-experimental-webworker-runtime'
+
+/** Image path of the manifest; the layout contract's name, re-exported for callers. */
+export const MANIFEST_PATH: string = IMAGE_MANIFEST_PATH
+
+/** Image path of the composed profile; the layout contract's name, re-exported for callers. */
+export const CONFIG_PATH: string = IMAGE_CONFIG_PATH
+
+/**
+ * Manifest field the runtime judges the image by: the wrapper contract every packed
+ * body was emitted against. The runtime refuses an image whose value is not its own
+ * contract, because those bodies assume different wrapper semantics.
+ */
+const CONTRACT_FIELD = 'lowered'
+
+/** Exclude matcher over tree-root-relative paths ({@link EXCLUDE}). */
+const excluded = picomatch([...EXCLUDE], { dot: true })
+
+/** Workspace exclude matcher: {@link EXCLUDE} plus {@link EXCLUDE_WORKSPACE}. */
+const workspaceExcluded = picomatch([...EXCLUDE, ...EXCLUDE_WORKSPACE], { dot: true })
+
+/** Page-asset matcher over image paths ({@link PAGE_ASSETS}). */
+const pageAsset = picomatch([...PAGE_ASSETS], { dot: true })
+
+/** One directory tree to copy in verbatim beside the composition. */
+export interface ConfigTree {
+  /** Image path to mount it at, relative to the virtual root. */
+  readonly mount: string
+  /** Absolute source directory. */
+  readonly directory: string
+  /**
+   * Whether plugin names inside its `.yml` files join the materialization closure.
+   * An agent preset mounts plugins the base composition never lists, and creating a
+   * session fails if any of them is missing from the image.
+   */
+  readonly scanRoster?: boolean
+}
+
+/** Everything the packer needs that it cannot know by itself. */
+export interface PackOptions {
+  /** Composed profile, `!!js` intact, as the CLI's `--dump-default-config` produced it. */
+  readonly config: string
+  /** Profile name, recorded in the manifest. */
+  readonly profile: string
+  /** Virtual root the image mounts under; defaults to {@link DEFAULT_ROOT}. */
+  readonly root?: string
+  /** Package name to absolute directory, for workspace and vendored packages. */
+  readonly workspaces: ReadonlyMap<string, string>
+  /** Directory Node-style dependency resolution walks up from for the roster. */
+  readonly resolveFrom: string
+  /** Config trees to copy in beside the composition. */
+  readonly configTrees?: readonly ConfigTree[]
+  /** Empty directories to create; defaults to `home/`, `workspace/`, `tmp/`. */
+  readonly emptyDirectories?: readonly string[]
+  /**
+   * Extra sweep roots: image specifiers requested by code outside the image.
+   * Defaults to the worker assembly's own entries.
+   */
+  readonly entries?: readonly string[]
+}
+
+/** What one pack produced, for the caller to report or assert on. */
+export interface PackResult {
+  /** The gzip-compressed tar archive to write; the runtime inflates it at mount. */
+  readonly image: Uint8Array
+  /** Every entry, before zipping; the manifest is already among them. */
+  readonly files: ImageFiles
+  /** Package name to how many files it contributed, in materialization order. */
+  readonly packages: ReadonlyMap<string, number>
+  /** How many of them came from the workspace rather than from `node_modules`. */
+  readonly workspacePackages: number
+  /** Roster package names the closure started from. */
+  readonly roster: readonly string[]
+  /** Dependencies that did not resolve; a non-empty list means an incomplete image. */
+  readonly missing: readonly string[]
+  /** Executable scripts dropped from the image. */
+  readonly executables: readonly string[]
+  /** Page bundles left out of the transform; like every JavaScript entry they carry the trailing debugger name. */
+  readonly pageBundles: readonly string[]
+  /** JavaScript entries the image carries. */
+  readonly javascriptEntries: number
+  /** JavaScript candidates no root reaches, dropped from the image. */
+  readonly droppedJavascriptEntries: number
+  /** Third-party requests that resolve nowhere; loud at require time if hit. */
+  readonly unresolvedExternalRequests: readonly string[]
+  /** What the pack-time transform did. */
+  readonly transform: TransformOutcome
+  /** Wrapper contract recorded in the manifest; every packed body meets it. */
+  readonly contract: string
+}
+
+const readJson = (file: string): Record<string, unknown> =>
+  JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
+
+/**
+ * Package name of a module specifier.
+ * @param specifier - Module specifier, possibly with a subpath.
+ * @returns The package name (`@scope/pkg/sub` → `@scope/pkg`).
+ */
+function packageNameOf(specifier: string): string {
+  const [first = specifier, second = ''] = specifier.split('/')
+  return first.startsWith('@') ? `${first}/${second}` : first
+}
+
+/**
+ * Collect module-specifier `name` fields from parsed entry rows, recursively
+ * through nested `config` row lists (groups). Builtin rows (`cordis:group`)
+ * and preset metadata documents carry names that are not module specifiers;
+ * only names with a scope or a path separator count.
+ * @param rows - Parsed YAML value; anything but an entry array is ignored.
+ * @param names - Package names collected so far.
+ */
+function moduleNamesOf(rows: unknown, names: Set<string>): void {
+  if (!Array.isArray(rows)) return
+  for (const row of rows) {
+    if (typeof row !== 'object' || row === null) continue
+    const { name, config } = row as { name?: unknown; config?: unknown }
+    if (typeof name === 'string' && (name.startsWith('@') || name.includes('/'))) {
+      names.add(packageNameOf(name))
+    }
+    moduleNamesOf(config, names)
+  }
+}
+
+/**
+ * Package names the composition names.
+ * @param config - Composed profile; `!!js` scalars parse under Include's dialect.
+ * @returns Package names, deduplicated.
+ */
+function rosterOf(config: string): string[] {
+  const names = new Set<string>()
+  moduleNamesOf(yaml.load(config, { schema: entryListSchema }), names)
+  return [...names]
+}
+
+/**
+ * Package names the compositions under one config tree name.
+ * @param root - Directory to walk.
+ * @returns Package names, deduplicated.
+ */
+function treeRosterOf(root: string): string[] {
+  const names = new Set<string>()
+  const walk = (directory: string): void => {
+    for (const entry of readdirSync(directory, { withFileTypes: true })) {
+      const absolute = join(directory, entry.name)
+      if (entry.isDirectory()) {
+        walk(absolute)
+        continue
+      }
+      if (!entry.name.endsWith('.yml') && !entry.name.endsWith('.yaml')) continue
+      moduleNamesOf(yaml.load(readFileSync(absolute, 'utf8'), { schema: entryListSchema }), names)
+    }
+  }
+  walk(root)
+  return [...names]
+}
+
+/**
+ * Resolve one dependency the way Node does: walk up from the importer.
+ * @param fromDirectory - Directory to start at.
+ * @param name - Package name.
+ * @returns The real path of the package directory, or undefined.
+ */
+function resolveDependency(fromDirectory: string, name: string): string | undefined {
+  let directory = fromDirectory
+  for (;;) {
+    const candidate = join(directory, 'node_modules', name)
+    if (existsSync(join(candidate, 'package.json'))) return realpathSync(candidate)
+    const parent = dirname(directory)
+    if (parent === directory) return undefined
+    directory = parent
+  }
+}
+
+/**
+ * Collect files under one directory. Traversal mechanics live here — nested
+ * `node_modules` never mounts (the image is flat) and dot directories are
+ * tooling residue at any depth — while every judgement call comes in through
+ * `keep` (the {@link EXCLUDE} tables and the npm publish view).
+ * @param root - Source directory.
+ * @param into - Image entries to add to.
+ * @param prefix - Image path prefix.
+ * @param keep - Filter over root-relative paths.
+ */
+function collectTree(root: string, into: ImageFiles, prefix: string, keep: (relativePath: string) => boolean): void {
+  const walk = (directory: string): void => {
+    for (const entry of readdirSync(directory, { withFileTypes: true })) {
+      if (entry.isDirectory()) {
+        if (entry.name === 'node_modules') continue
+        if (entry.name.startsWith('.')) continue
+        walk(join(directory, entry.name))
+        continue
+      }
+      if (!entry.isFile()) continue
+      const absolute = join(directory, entry.name)
+      const relativePath = relative(root, absolute).replaceAll('\\', '/')
+      if (!keep(relativePath)) continue
+      into[`${prefix}/${relativePath}`] = readFileSync(absolute)
+    }
+  }
+  walk(root)
+}
+
+/**
+ * Predicate for npm's `files` allowlist, with standard glob semantics
+ * (picomatch). A pattern admits the path itself and everything under it, so a
+ * bare directory name publishes its whole tree; `!` patterns subtract from the
+ * admitted set; package.json is always published.
+ * @param patterns - The package.json `files` array.
+ * @returns Predicate over package-root-relative paths.
+ */
+function publishedFilter(patterns: readonly unknown[]): (path: string) => boolean {
+  const strings = patterns.filter((pattern): pattern is string => typeof pattern === 'string')
+  const normalize = (pattern: string): string => pattern.replace(/^\.\//, '').replace(/\/+$/, '')
+  const widen = (pattern: string): string[] => [pattern, `${pattern}/**`]
+  const positive = strings.filter(pattern => !pattern.startsWith('!')).map(normalize).flatMap(widen)
+  const negative = strings.filter(pattern => pattern.startsWith('!')).map(pattern => normalize(pattern.slice(1))).flatMap(widen)
+  const admits = picomatch(positive, { dot: true })
+  const denies = negative.length > 0 ? picomatch(negative, { dot: true }) : (): boolean => false
+  return path => path === 'package.json' || (admits(path) && !denies(path))
+}
+
+/** What the reachability sweep kept, transformed, and dropped. */
+interface SweepOutcome {
+  readonly swept: ImageFiles
+  readonly transform: TransformOutcome
+  readonly javascriptEntries: number
+  readonly droppedJavascriptEntries: number
+  /** Third-party requests that resolve nowhere; loud at require time if hit. */
+  readonly unresolvedExternalRequests: readonly string[]
+}
+
+/**
+ * Keep only the JavaScript the worker can reach, transforming it on the way.
+ *
+ * Roots are the export faces of every materialized workspace and vendored
+ * package — the harness addresses them by constructed name at runtime (Loader
+ * rows, typert faces, delegating providers such as `-auto` pickers), so the
+ * sweep prunes files only inside third-party packages — plus the worker
+ * assembly's own image entries. Resolution runs the runtime loader's own
+ * algorithm over the candidate set, so pack-time reachability and boot-time
+ * resolution cannot drift, and a request that resolves nowhere — an undeclared
+ * or missing dependency — fails the pack rather than the boot.
+ *
+ * Two entry classes stay out of the walk by rule: page assets
+ * ({@link PAGE_ASSETS}) are evaluated by the page's module system, and
+ * non-JavaScript entries always stay because data reads go through fs paths
+ * this pass cannot see.
+ * @param files - Candidate entries after the publish-view filter.
+ * @param options - Pack options carrying the sweep roots.
+ * @param rootPackages - Roster package names from the workspace.
+ * @param root - Virtual root the candidates mount under.
+ * @returns The final entries plus the sweep's counts.
+ */
+/** Trailing `sourceMappingURL` comment; the image carries no `.map` files. */
+const DANGLING_SOURCE_MAP = /\n\/\/# sourceMappingURL=\S+\s*$/
+
+/**
+ * Name one JavaScript entry for the debugger: append the `sourceURL` magic
+ * comment V8 stacks and DevTools read, so the entry shows under its
+ * repository path instead of as an anonymous VM script (worker `new Function`
+ * bodies) or blob entry (page bundles). A trailing `sourceMappingURL` comment
+ * is stripped first — its `.map` never ships, and once the script has a name
+ * the debugger would resolve the reference against it and report a load
+ * failure per script. Only the final line is touched, so every other line
+ * keeps its number; evaluation cost stays at pack time, where the names are
+ * already deterministic.
+ * @param bytes - Entry body as the image would otherwise hold it.
+ * @param name - Debugger name for the entry.
+ * @param decoder - Shared UTF-8 decoder.
+ * @param encoder - Shared UTF-8 encoder.
+ * @returns The named body.
+ */
+function nameForDebugger(bytes: Uint8Array, name: string, decoder: TextDecoder, encoder: TextEncoder): Uint8Array {
+  const source = decoder.decode(bytes).replace(DANGLING_SOURCE_MAP, '\n')
+  return encoder.encode(`${source}\n//# sourceURL=${name}`)
+}
+
+/**
+ * Debugger names for image entries: a workspace or vendored package file is
+ * named by its repository path (`packages/<group>/<pkg>/lib/index.js`), the
+ * shape a reader navigates; an external package file keeps its image key —
+ * it has no repository path, and its pnpm store path would name a hash.
+ * @param workspaces - Package name → absolute repository directory.
+ * @param resolveFrom - Repository root the names are relative to.
+ * @returns Mapper from an image key to the entry's debugger name.
+ */
+function debuggerNamer(workspaces: ReadonlyMap<string, string>, resolveFrom: string): (key: string) => string {
+  const repoDirs = new Map(
+    [...workspaces].map(([name, directory]) => [name, relative(resolveFrom, directory).replaceAll('\\', '/')]),
+  )
+  return (key: string): string => {
+    if (!key.startsWith('node_modules/')) return key
+    const rest = key.slice('node_modules/'.length)
+    const segments = rest.split('/')
+    const packageName = segments[0]?.startsWith('@') === true ? segments.slice(0, 2).join('/') : segments[0] ?? ''
+    const directory = repoDirs.get(packageName)
+    return directory === undefined ? key : `${directory}${rest.slice(packageName.length)}`
+  }
+}
+
+function sweepImage(
+  files: ImageFiles,
+  options: PackOptions,
+  rootPackages: readonly string[],
+  root: string,
+): SweepOutcome {
+  const decoder = new TextDecoder()
+  const encoder = new TextEncoder()
+  const vfs = new MemoryVfs()
+  for (const [name, bytes] of Object.entries(files)) {
+    if (name.endsWith('/')) vfs.seedDirectory(`${root}/${name}`)
+    else vfs.seed(`${root}/${name}`, bytes)
+  }
+  // The walk resolves static specifiers and never loads them, so one shared
+  // factory stands for every replaced module.
+  const stub = (): unknown => ({})
+  const loader = new WorkerModuleLoader({
+    vfs,
+    root,
+    staticModules: Object.fromEntries(Object.keys(MODULE_PROXIES).map(name => [name, stub])),
+    staticModulePrefixes: Object.fromEntries(Object.keys(MODULE_PROXY_PREFIXES).map(name => [name, stub])),
+  })
+
+  const queue: { specifier: string; from: string; importer: string; meta?: boolean }[] = (options.entries ?? IMAGE_ENTRY_SEEDS)
+    .map(specifier => ({ specifier, from: root, importer: 'worker assembly entry' }))
+  for (const name of rootPackages) {
+    const manifestBytes = files[`node_modules/${name}/package.json`]
+    if (manifestBytes === undefined) continue // materialize already reported it under `missing`
+    let manifest: { exports?: Record<string, unknown> }
+    try {
+      manifest = JSON.parse(decoder.decode(manifestBytes)) as typeof manifest
+    } catch {
+      continue
+    }
+    // Every non-wildcard face is a root; a face resolving onto a page asset is
+    // kept untransformed below rather than excluded here.
+    const subpaths = manifest.exports === undefined
+      ? ['.']
+      : Object.keys(manifest.exports).filter(key => key.startsWith('.') && !key.includes('*'))
+    for (const subpath of subpaths) {
+      queue.push({ specifier: subpath === '.' ? name : `${name}/${subpath.slice(2)}`, from: root, importer: `workspace face ${name}` })
+    }
+  }
+
+  const reached = new Map<string, Uint8Array>()
+  const seen = new Set<string>()
+  const failures: string[] = []
+  const tolerated = new Set<string>()
+  let visited = 0
+  let rewritten = 0
+  for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) {
+    const { specifier, from, importer } = entry
+    let resolution
+    try {
+      resolution = loader.resolve(specifier, from)
+    } catch (reason) {
+      // Our own packages must declare what they request: an unresolvable
+      // request from a workspace or vendored file, a roster face, or the
+      // assembly entries is a pack defect. Third-party files keep the runtime
+      // philosophy instead — platform-dispatch branches the worker never
+      // evaluates may request node-only modules, and such a request fails loud
+      // at require time if it ever runs.
+      const external = importer.startsWith('node_modules/') && !importer.startsWith('node_modules/@deepseek-ai/')
+      // A meta-resolve request is a URL mapping, not a load: a missing target
+      // is tolerable from any importer — the call throws if it ever runs.
+      if (external || entry.meta === true) tolerated.add(`${importer}: "${specifier}"`)
+      else failures.push(`${importer}: "${specifier}" — ${(reason as Error).message}`)
+      continue
+    }
+    if (resolution.kind === 'static') continue
+    const path = resolution.path
+    if (seen.has(path)) continue
+    seen.add(path)
+    const key = path.slice(root.length + 1)
+    const bytes = files[key]
+    if (bytes === undefined) continue
+    if (!/\.[cm]?js$/.test(key) || pageAsset(key)) {
+      reached.set(key, bytes)
+      continue
+    }
+    visited += 1
+    const { code, lowered, moduleRequests, metaResolveRequests } = lowerModuleSource({ filename: `/${key}`, source: decoder.decode(bytes) })
+    if (lowered) rewritten += 1
+    reached.set(key, lowered ? encoder.encode(code) : bytes)
+    const directory = path.slice(0, path.lastIndexOf('/'))
+    for (const request of moduleRequests) queue.push({ specifier: request, from: directory, importer: key })
+    for (const request of metaResolveRequests) queue.push({ specifier: request, from: directory, importer: key, meta: true })
+  }
+  if (failures.length > 0) {
+    throw new Error(
+      `vfs image: ${String(failures.length)} unresolvable module request(s); `
+      + 'an undeclared or missing dependency fails the pack rather than the boot:\n  '
+      + failures.join('\n  '),
+    )
+  }
+
+  const swept: ImageFiles = {}
+  const debuggerName = debuggerNamer(options.workspaces, options.resolveFrom)
+  let javascriptEntries = 0
+  let dropped = 0
+  for (const [name, bytes] of Object.entries(files)) {
+    const isJs = /\.[cm]?js$/.test(name)
+    if (!isJs || pageAsset(name)) {
+      swept[name] = isJs ? nameForDebugger(bytes, debuggerName(name), decoder, encoder) : bytes
+      if (isJs) javascriptEntries += 1
+      continue
+    }
+    const kept = reached.get(name)
+    if (kept === undefined) {
+      dropped += 1
+      continue
+    }
+    swept[name] = nameForDebugger(kept, debuggerName(name), decoder, encoder)
+    javascriptEntries += 1
+  }
+  return {
+    swept,
+    transform: { visited, rewritten },
+    javascriptEntries,
+    droppedJavascriptEntries: dropped,
+    unresolvedExternalRequests: [...tolerated],
+  }
+}
+
+/**
+ * Drop executable scripts from the image.
+ *
+ * A shebang says "program", not "module": nothing in a browser can spawn one and no
+ * consumer reads their bytes (the packages that expose a launcher path are replaced
+ * by stubs that answer with a string). They are also the one place top-level `await`
+ * appears in the closure, which a CommonJS body cannot express.
+ * @param files - Image entries, mutated.
+ * @returns The dropped entry names.
+ */
+function dropExecutables(files: ImageFiles): string[] {
+  const decoder = new TextDecoder()
+  const dropped: string[] = []
+  for (const [name, bytes] of Object.entries(files)) {
+    if (!/\.[cm]?js$/.test(name)) continue
+    if (decoder.decode(bytes.subarray(0, 2)) !== '#!') continue
+    dropped.push(name)
+    // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- the image is a plain path map
+    delete files[name]
+  }
+  return dropped
+}
+
+/**
+ * Materialize the dependency closure of every roster package into the image.
+ * @param roster - Package names to start from.
+ * @param options - Pack options carrying the workspace index and resolution root.
+ * @returns Image entries, per-package file counts, and unresolved dependencies.
+ */
+function materialize(
+  roster: readonly string[],
+  options: PackOptions,
+): { files: ImageFiles; packages: Map<string, number>; missing: string[] } {
+  const files: ImageFiles = {}
+  const packages = new Map<string, number>()
+  const missing: string[] = []
+  const replaced = new Set(REPLACED_EXTERNAL_PACKAGES)
+  const queue: { name: string; from: string }[] = roster.map(name => ({ name, from: options.resolveFrom }))
+
+  for (let entry = queue.shift(); entry !== undefined; entry = queue.shift()) {
+    const { name, from } = entry
+    if (packages.has(name) || replaced.has(name)) continue
+    const directory = options.workspaces.get(name) ?? resolveDependency(from, name)
+    if (directory === undefined) {
+      missing.push(`${name} (from ${relative(options.resolveFrom, from) || '.'})`)
+      continue
+    }
+    const manifest = readJson(join(directory, 'package.json'))
+    const prefix = `node_modules/${name}`
+    const before = Object.keys(files).length
+    if (options.workspaces.has(name)) {
+      // A workspace package ships the slice npm would publish — `files`
+      // filters out build residue like the tsc mirror under lib/types/ —
+      // minus the workspace exclude table (no sources, no dist: the page
+      // serves its own assets).
+      const published = Array.isArray(manifest.files) ? publishedFilter(manifest.files) : undefined
+      collectTree(directory, files, prefix, relativePath =>
+        !workspaceExcluded(relativePath) && (published === undefined || published(relativePath)))
+    } else {
+      collectTree(directory, files, prefix, relativePath => !excluded(relativePath))
+    }
+    packages.set(name, Object.keys(files).length - before)
+    for (const field of ['dependencies', 'peerDependencies'] as const) {
+      // npm semantics: a peer is provided by the consumer. For an external
+      // package the consumer is the page (react behind the prebuilt client
+      // bundles), so its peer edges never bind the worker. Workspace and
+      // vendored packages declare real runtime seams as peers
+      // (@deepseek-ai/cordis is a peerDependency of every harness package),
+      // so their peer edges stay on the chain.
+      if (field === 'peerDependencies' && !options.workspaces.has(name)) continue
+      const dependencies = manifest[field]
+      if (typeof dependencies !== 'object' || dependencies === null) continue
+      for (const dependency of Object.keys(dependencies)) queue.push({ name: dependency, from: directory })
+    }
+  }
+  return { files, packages, missing }
+}
+
+/** Gzip header byte that records the packing platform; RFC 1952 §2.3.1 spells 255 "unknown". */
+const GZIP_OS_UNKNOWN = 255
+
+/** Offset of that byte in the gzip member header. */
+const GZIP_OS_OFFSET = 9
+
+/**
+ * Compress the archive into one gzip member the same tree always produces
+ * byte for byte.
+ *
+ * Two header fields would otherwise carry build facts: zlib writes no
+ * modification time and no original file name for a buffer (`gzipSync` is handed
+ * neither), and it fills the operating-system byte from the platform it was built
+ * for, which would make the same tree pack differently on Linux and macOS. That
+ * byte is overwritten with "unknown" — every gzip reader ignores it, and the
+ * artifact stops depending on where it was packed.
+ * @param archive - the ustar archive.
+ * @returns the compressed image bytes.
+ */
+function compressImage(archive: Uint8Array): Uint8Array {
+  const compressed = gzipSync(archive, { level: 9 })
+  compressed[GZIP_OS_OFFSET] = GZIP_OS_UNKNOWN
+  return compressed
+}
+
+/**
+ * Pack one VFS image.
+ *
+ * The manifest's claim is all-or-nothing: it names the one contract every packed body
+ * was emitted against. A module the transform cannot express therefore fails the pack
+ * rather than downgrading the image, because a mostly-transformed image boots into
+ * errors far from their cause.
+ * @param options - Composition, package index, and paths.
+ * @returns The compressed image plus what went into it.
+ * @throws When a config tree or workspace directory named in the options is missing,
+ * because a silently thinner image fails much later and much less clearly.
+ */
+export function packVfsImage(options: PackOptions): PackResult {
+  const root = options.root ?? DEFAULT_ROOT
+  const encoder = new TextEncoder()
+  const configTrees = options.configTrees ?? []
+  for (const tree of configTrees) {
+    if (!existsSync(tree.directory)) {
+      throw new Error(`vfs image: config tree ${tree.mount} is missing at ${tree.directory}`)
+    }
+  }
+
+  const roster = [...new Set([
+    ...rosterOf(options.config),
+    ...configTrees.filter(tree => tree.scanRoster === true).flatMap(tree => treeRosterOf(tree.directory)),
+  ])]
+  const { files, packages, missing } = materialize(roster, options)
+
+  files[CONFIG_PATH] = encoder.encode(options.config)
+  for (const tree of configTrees) collectTree(tree.directory, files, tree.mount, relativePath => !excluded(relativePath))
+
+  const executables = dropExecutables(files)
+  const rootPackages = [...packages.keys()].filter(name => options.workspaces.has(name))
+  const { swept, transform, javascriptEntries, droppedJavascriptEntries, unresolvedExternalRequests } =
+    sweepImage(files, options, rootPackages, root)
+
+  swept[MANIFEST_PATH] = encoder.encode(`${JSON.stringify({
+    root,
+    profile: options.profile,
+    [CONTRACT_FIELD]: WRAPPER_CONTRACT,
+    javascriptEntries,
+    visitedEntries: transform.visited,
+    rewrittenEntries: transform.rewritten,
+  }, null, 2)}\n`)
+
+  for (const directory of options.emptyDirectories ?? IMAGE_EMPTY_DIRECTORIES) {
+    swept[directory] = new Uint8Array(0)
+  }
+
+  return {
+    image: compressImage(packTar(swept)),
+    files: swept,
+    packages,
+    workspacePackages: [...packages.keys()].filter(name => options.workspaces.has(name)).length,
+    roster,
+    missing,
+    executables,
+    pageBundles: Object.keys(swept).filter(name => pageAsset(name)),
+    javascriptEntries,
+    droppedJavascriptEntries,
+    unresolvedExternalRequests,
+    transform,
+    contract: WRAPPER_CONTRACT,
+  }
+}

+ 173 - 0
packages/experimental/webworker-packer/src/repository.ts

@@ -0,0 +1,173 @@
+/**
+ * Repository knowledge for the packer: where this tree's workspaces, profile
+ * composition, and config trees are, and how to report a pack.
+ *
+ * The library half takes all of this as parameters. Keeping the lookup here is what
+ * lets the same library pack a different tree, and what keeps `pack.ts` free of
+ * assumptions about pnpm workspaces or the `dsh` CLI.
+ * @module @deepseek-ai/dsh-experimental-webworker-packer/src/repository
+ */
+import { execFileSync } from 'node:child_process'
+import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join, relative } from 'node:path'
+import { DSH_HOME_ENV } from '@deepseek-ai/dsh-home-paths'
+import type { ConfigTree, PackResult } from './pack.ts'
+
+/**
+ * Repository directories scanned for workspace and vendored packages. The
+ * image only ever materializes runtime packages, which all live here;
+ * examples, python, and native are never on a roster's dependency chain (the
+ * native addon is a replaced external).
+ */
+const WORKSPACE_SCAN_ROOTS = ['vendor', 'packages', 'apps']
+
+/** Composition entry point package: the `dsh` CLI, run from source. */
+const CLI_PACKAGE = 'apps/cli'
+
+/** Composition entry point: the `dsh` CLI, run from source. */
+const CLI_ENTRY = `${CLI_PACKAGE}/src/bin.ts`
+
+/**
+ * Index every workspace and vendored package by name.
+ * @param repoRoot - Absolute repository root.
+ * @returns Package name to absolute directory.
+ */
+export function indexWorkspacePackages(repoRoot: string): Map<string, string> {
+  const index = new Map<string, string>()
+  const visit = (directory: string): void => {
+    const manifest = join(directory, 'package.json')
+    if (existsSync(manifest)) {
+      const name = (JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown }).name
+      if (typeof name === 'string') index.set(name, directory)
+      // A package root owns its subtree; anything below (test fixtures,
+      // nested manifests) is not a separate workspace package.
+      return
+    }
+    for (const entry of readdirSync(directory, { withFileTypes: true })) {
+      if (!entry.isDirectory()) continue
+      if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
+      visit(join(directory, entry.name))
+    }
+  }
+  for (const scanRoot of WORKSPACE_SCAN_ROOTS) {
+    const absolute = join(repoRoot, scanRoot)
+    if (existsSync(absolute)) visit(absolute)
+  }
+  return index
+}
+
+/**
+ * Compose one profile through the real CLI dump path, leaving `!!js`
+ * unevaluated. The dump runs against a throwaway Harness home and default
+ * layers only, so the image is the shipped profile: the machine's `$DSH_HOME`
+ * — its profile manifest with locally installed bundles, and its patch files —
+ * would otherwise leak this machine's plugins into the image and break the
+ * same-tree-same-bytes guarantee.
+ * @param repoRoot - Absolute repository root.
+ * @param profile - Profile name to compose.
+ * @returns The composed YAML.
+ */
+export function composeProfile(repoRoot: string, profile: string): string {
+  const home = mkdtempSync(join(tmpdir(), 'dsh-pack-home-'))
+  try {
+    return execFileSync(
+      process.execPath,
+      ['--import', 'tsx/esm', join(repoRoot, CLI_ENTRY), '--profile', profile, '--dump-default-config'],
+      { cwd: repoRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, env: { ...process.env, [DSH_HOME_ENV]: home } },
+    )
+  } finally {
+    rmSync(home, { recursive: true, force: true })
+  }
+}
+
+/** One `dsh.configTrees` declaration entry, validated field by field. */
+interface ConfigTreeDeclaration {
+  mount: string
+  path: string
+  scanRoster?: boolean
+}
+
+/**
+ * Config trees the CLI package declares for deployment images
+ * (`dsh.configTrees` in its package.json): `path` is relative to the CLI
+ * package root, `mount` is the image path, `scanRoster` feeds the tree's yml
+ * plugin rows into the pack roster. The CLI owns its config layout; this
+ * reader follows the declaration instead of naming directories. A malformed
+ * declaration refuses the pack.
+ * @param repoRoot - Absolute repository root.
+ * @returns Trees with absolute source directories.
+ */
+export function configTrees(repoRoot: string): ConfigTree[] {
+  const packageDir = join(repoRoot, CLI_PACKAGE)
+  const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
+    dsh?: { configTrees?: unknown }
+  }
+  const declared = manifest.dsh?.configTrees
+  if (declared === undefined) return []
+  if (!Array.isArray(declared)) {
+    throw new Error(`vfs image: ${CLI_PACKAGE} dsh.configTrees must be an array`)
+  }
+  const mounts = new Set<string>()
+  return declared.map((entry, index) => {
+    const tree = entry as Partial<ConfigTreeDeclaration> | null
+    const at = `${CLI_PACKAGE} dsh.configTrees[${String(index)}]`
+    if (tree === null || typeof tree !== 'object'
+      || typeof tree.mount !== 'string' || tree.mount === ''
+      || typeof tree.path !== 'string' || tree.path === ''
+      || (tree.scanRoster !== undefined && typeof tree.scanRoster !== 'boolean')) {
+      throw new Error(`vfs image: ${at} must declare a string mount, a string path, and an optional boolean scanRoster`)
+    }
+    if (mounts.has(tree.mount)) {
+      throw new Error(`vfs image: ${at} repeats mount ${JSON.stringify(tree.mount)}`)
+    }
+    mounts.add(tree.mount)
+    return {
+      mount: tree.mount,
+      directory: join(packageDir, tree.path),
+      ...tree.scanRoster === undefined ? {} : { scanRoster: tree.scanRoster },
+    }
+  })
+}
+
+/**
+ * Render one pack as the lines a build log should carry.
+ *
+ * Refusals and unresolved dependencies are the two states a reader must not miss, so
+ * they are spelled out rather than counted.
+ * @param result - What the pack produced.
+ * @param repoRoot - Absolute repository root, for relative paths.
+ * @param outputFile - Where the image was written.
+ * @returns Lines to print.
+ */
+export function describePack(result: PackResult, repoRoot: string, outputFile: string): string[] {
+  const sizeOf = (prefix: string): number => Object.entries(result.files)
+    .filter(([name]) => name.startsWith(prefix))
+    .reduce((sum, [, bytes]) => sum + bytes.byteLength, 0)
+  const megabytes = (bytes: number): string => `${(bytes / 1024 / 1024).toFixed(2)} MB`
+  const workspaceCount = result.workspacePackages
+  const heaviest = [...result.packages.entries()]
+    .map(([name, count]) => ({ name, count, bytes: sizeOf(`node_modules/${name}/`) }))
+    .sort((left, right) => right.bytes - left.bytes)
+    .slice(0, 12)
+
+  return [
+    `vfs image: ${relative(repoRoot, outputFile)}`,
+    `  roster entries      ${String(result.roster.length)}`,
+    `  packages            ${String(result.packages.size)} (${String(workspaceCount)} workspace)`,
+    `  files               ${String(Object.keys(result.files).length)}`,
+    `  raw                 ${megabytes(Object.values(result.files).reduce((sum, bytes) => sum + bytes.byteLength, 0))}`,
+    `  compressed          ${megabytes(result.image.byteLength)}`,
+    `  config + presets    ${megabytes(sizeOf('config/'))}`,
+    `  javascript entries  ${String(result.javascriptEntries)} (dropped ${String(result.executables.length)} executable scripts, ${String(result.pageBundles.length)} page bundles verbatim)`,
+    `  wrapper contract    ${result.contract}`,
+    `  transform           ${String(result.transform.rewritten)} of ${String(result.transform.visited)} reached entries rewritten, ${String(result.droppedJavascriptEntries)} unreachable dropped`,
+    `  unresolved          ${String(result.unresolvedExternalRequests.length)} third-party request(s) left to fail loud at require time`,
+    '  heaviest packages:',
+    ...heaviest.map(entry => `    ${entry.bytes.toString().padStart(9)} B  ${entry.name} (${String(entry.count)} files)`),
+    ...result.missing.length === 0
+      ? []
+      : ['  unresolved dependencies:', ...result.missing.map(entry => `    ${entry}`)],
+    '',
+  ]
+}

+ 71 - 0
packages/experimental/webworker-packer/src/rules.ts

@@ -0,0 +1,71 @@
+/**
+ * Pack rule tables: the one place the image's include/exclude decisions live.
+ * Patterns are picomatch globs. Exclude patterns match tree-root-relative
+ * paths (so `src/**` drops only a root-level source tree), page-asset
+ * patterns match image paths. Traversal mechanics — nested `node_modules`
+ * flattening and dot-directory pruning — stay in the collector; these tables
+ * hold the judgement calls.
+ */
+
+/**
+ * Paths dropped from every collected tree. Source and test trees never
+ * resolve at runtime (the artifact plane ships `lib/`), and sourcemaps,
+ * declarations, and archives never resolve either while dominating the byte
+ * count.
+ */
+export const EXCLUDE: readonly string[] = [
+  'src/**',
+  'tests/**',
+  'test/**',
+  '__tests__/**',
+  'coverage/**',
+  '**/*.map',
+  '**/*.tsbuildinfo',
+  '**/*.tgz',
+  '**/*.tar',
+  '**/*.tar.gz',
+  '**/*.d.ts',
+  '**/*.d.mts',
+  '**/*.d.cts',
+]
+
+/**
+ * Additional paths dropped from workspace packages only. A workspace `dist/`
+ * is a page-asset tree the static deployment serves itself; external packages
+ * legitimately ship runtime code under `dist/`.
+ */
+export const EXCLUDE_WORKSPACE: readonly string[] = [
+  'dist/**',
+]
+
+/**
+ * Image paths that belong to the PAGE, not to the worker's loader.
+ *
+ * A package's `lib/client.js` is its browser bundle behind the `./client`
+ * export: the page's own module system evaluates it with its own wrapper,
+ * which has no ambient-store parameter. Transforming those bodies would
+ * inject calls the page cannot resolve, so they ship untransformed — their
+ * only change is the trailing debugger-name line every JavaScript entry
+ * gains — and the manifest's all-or-nothing claim stays true, because the
+ * worker loader never evaluates them (the tunnel serves them as bytes).
+ */
+export const PAGE_ASSETS: readonly string[] = [
+  'node_modules/*/lib/client.js',
+  'node_modules/@*/*/lib/client.js',
+]
+
+/**
+ * Image specifiers the worker assembly requires directly, beyond the composed
+ * roster: they are requested by worker-bundle code, so no image file
+ * references them and the reachability sweep must seed them as roots. Keep in
+ * step with the literal `require`/`resolve` calls in the runtime's
+ * `worker-host.ts`.
+ */
+export const IMAGE_ENTRY_SEEDS: readonly string[] = [
+  '@deepseek-ai/dsh-app-boot',
+  '@deepseek-ai/dsh-cmdline',
+  '@deepseek-ai/dsh-host-apiproxy',
+  '@deepseek-ai/cordis',
+  '@deepseek-ai/cordis-plugin-include',
+  'js-yaml',
+]

+ 26 - 0
packages/experimental/webworker-packer/src/transform-image.ts

@@ -0,0 +1,26 @@
+/**
+ * The wrapper contract packed bodies are emitted against, and the image-entry
+ * types the pack pass consumes.
+ *
+ * One transform serves both sides — the pack pass lowers with the runtime's
+ * own `lowerModuleSource`, never a reimplementation — and the image records
+ * the contract version it was lowered against. Bodies emitted against a
+ * different wrapper contract are refused at mount time rather than
+ * half-working at run time.
+ * @module @deepseek-ai/dsh-experimental-webworker-packer/src/transform-image
+ */
+import { LOWERING_VERSION } from '@deepseek-ai/dsh-experimental-webworker-runtime'
+
+/** Image entries, keyed by their path relative to the virtual root. */
+export type ImageFiles = Record<string, Uint8Array>
+
+/** Wrapper contract the packed bodies are emitted against. */
+export const WRAPPER_CONTRACT: string = LOWERING_VERSION
+
+/** What one pack-time transform pass did. */
+export interface TransformOutcome {
+  /** JavaScript entries visited. */
+  readonly visited: number
+  /** How many changed; the rest were already in final form. */
+  readonly rewritten: number
+}

+ 159 - 0
packages/experimental/webworker-packer/tests/image-loadable.spec.ts

@@ -0,0 +1,159 @@
+/**
+ * End-to-end spec of the packer's actual product: an image this package builds must
+ * mount in the runtime's VFS and be `require`-able by the runtime's module loader,
+ * which holds no transform of its own.
+ *
+ * That last part is the point. "It boots" only proves nothing crashed; the loader
+ * wraps module bodies exactly as the image holds them, so the pack-time pass is the
+ * only thing that can make them wrappable. The refusal case is the positive
+ * evidence: restore one un-lowered body and the same setup fails loud.
+ *
+ * A small synthetic composition rather than the real profile: packing the full
+ * closure takes tens of seconds. The path under test — compose, materialize,
+ * transform, tar, compress, inflate, mount, require — is the same one.
+ *
+ * ONE module instance: every runtime import here goes through `src/`, because the VFS
+ * and the active loader are module-level slots. The "starts with nothing loaded"
+ * case asserts the instance the spec holds is the one that did the work.
+ */
+import { existsSync } from 'node:fs'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+import { createNodeBuiltins, REPLACED_PREFIXES } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtins.ts'
+import { WorkerModuleLoader } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader.ts'
+import { inflateImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/image-gzip.ts'
+import { loadVfsImage } from '@deepseek-ai/dsh-experimental-webworker-runtime/src/storage/memory.ts'
+import { indexWorkspacePackages } from '../src/repository.ts'
+import { DEFAULT_ROOT, MANIFEST_PATH, packVfsImage } from '../src/pack.ts'
+
+const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
+
+/** A leaf workspace package: real build output, no dependencies to drag in. */
+const SUBJECT = '@deepseek-ai/dsh-timeout'
+
+const workspaces = indexWorkspacePackages(repoRoot)
+
+/**
+ * The pack consumes built `lib/` output. An unbuilt checkout (the unit
+ * coverage lane runs before any build) self-skips; the built lanes and every
+ * preview build exercise this same path against real artifacts.
+ */
+const subjectBuilt = existsSync(join(repoRoot, 'packages/util/timeout/lib/index.js'))
+
+let memo: ReturnType<typeof packVfsImage> | undefined
+const packed = (): ReturnType<typeof packVfsImage> => memo ??= packVfsImage({
+  // The composition's own shape: one entry per plugin, `name:` on its own line.
+  config: `- id: subject\n  name: '${SUBJECT}'\n`,
+  profile: 'image-loadable-check',
+  workspaces,
+  resolveFrom: repoRoot,
+  // Synthetic composition: nothing boots the worker assembly, so its default
+  // image entries must not be demanded of this one-package closure.
+  entries: [],
+})
+
+/** The image's archive, inflated once: mounting reads the tar, not the gzip member. */
+let archiveMemo: Uint8Array | undefined
+const archive = async (): Promise<Uint8Array> =>
+  archiveMemo ??= await inflateImage(packed().image, 'the image this spec packed')
+
+;(subjectBuilt ? describe : describe.skip)('packed image', () => {
+  it('materializes the roster with every dependency resolved', () => {
+    const result = packed()
+    expect(workspaces.has(SUBJECT)).toBe(true)
+    expect(result.roster).toEqual([SUBJECT])
+    expect(result.packages.has(SUBJECT)).toBe(true)
+    expect(result.missing).toEqual([])
+  })
+
+  it('records the wrapper contract in the manifest and rewrote what it visited', () => {
+    const result = packed()
+    expect(Object.hasOwn(result.files, MANIFEST_PATH)).toBe(true)
+    const manifest = JSON.parse(new TextDecoder().decode(result.files[MANIFEST_PATH])) as { lowered: string }
+    expect(manifest.lowered).toBe(result.contract)
+    expect(result.transform.rewritten).toBeGreaterThan(0)
+  })
+
+  it('names every JavaScript entry for the debugger, workspace files by repository path', () => {
+    const result = packed()
+    const decoder = new TextDecoder()
+    const entries = Object.keys(result.files).filter(name => /\.[cm]?js$/.test(name))
+    expect(entries.length).toBeGreaterThan(0)
+    for (const name of entries) {
+      const lines = decoder.decode(result.files[name]).split('\n')
+      // V8 stacks and DevTools read the trailing comment, so worker
+      // `new Function` bodies and page blobs alike show under a stable name
+      // instead of as anonymous VM or blob entries.
+      expect(lines.at(-1)).toMatch(/^\/\/# sourceURL=\S+$/)
+      // A dangling map reference would make the debugger report one load
+      // failure per named script; the packer ships no `.map` files.
+      expect(lines.at(-2) ?? '').not.toContain('sourceMappingURL')
+    }
+    // A workspace entry is named by the path a reader navigates in this
+    // repository, not by its image mount.
+    const subject = decoder.decode(result.files[`node_modules/${SUBJECT}/lib/index.js`])
+    expect(subject.endsWith('\n//# sourceURL=packages/util/timeout/lib/index.js')).toBe(true)
+  })
+
+  it('writes one gzip member whose header records no build facts', () => {
+    const image = packed().image
+    // RFC 1952 §2.3: magic, deflate, then the flag byte — no FNAME (0x08) or
+    // FCOMMENT, a zero modification time, and "unknown" for the packing system.
+    expect([...image.slice(0, 4)]).toEqual([0x1f, 0x8b, 0x08, 0x00])
+    expect([...image.slice(4, 8)]).toEqual([0, 0, 0, 0])
+    expect(image[9]).toBe(255)
+  })
+
+  it('packs the same tree to the same bytes', () => {
+    // The preview build compares a freshly packed image against the shipped one,
+    // so anything the compressor takes from its environment would read as a
+    // changed tree.
+    const again = packVfsImage({
+      config: `- id: subject\n  name: '${SUBJECT}'\n`,
+      profile: 'image-loadable-check',
+      workspaces,
+      resolveFrom: repoRoot,
+      entries: [],
+    })
+    expect(Buffer.from(again.image).equals(Buffer.from(packed().image))).toBe(true)
+  })
+
+  it('mounts and requires through the real loader, which carries no transform', async () => {
+    const vfs = loadVfsImage(await archive(), DEFAULT_ROOT)
+    expect(vfs.existsSync(`${DEFAULT_ROOT}/node_modules/${SUBJECT}/lib/index.js`)).toBe(true)
+
+    const loader = new WorkerModuleLoader({
+      vfs,
+      root: DEFAULT_ROOT,
+      staticModules: createNodeBuiltins(),
+      staticModulePrefixes: REPLACED_PREFIXES,
+    })
+    // The loader this spec reads counters from must be the one that did the
+    // requiring; a second instance would report an empty cache trivially.
+    expect(loader.usage().modules).toBe(0)
+
+    const required = loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(SUBJECT) as Record<string, unknown>
+    expect(typeof required.timeoutOf).toBe('function')
+    expect(loader.usage().modules).toBeGreaterThan(0)
+  })
+
+  it('refuses a body the packer did not lower, naming the image', async () => {
+    // The case above only proves the packed bytes are wrappable. This is the
+    // other half: the loader has no transform to fall back on, so an entry the
+    // collector missed must fail loud against the image rather than boot.
+    const vfs = loadVfsImage(await archive(), DEFAULT_ROOT)
+    vfs.seed(
+      `${DEFAULT_ROOT}/node_modules/${SUBJECT}/lib/index.js`,
+      new TextEncoder().encode('export const timeoutOf = () => 0\n'),
+    )
+    const loader = new WorkerModuleLoader({
+      vfs,
+      root: DEFAULT_ROOT,
+      staticModules: createNodeBuiltins(),
+      staticModulePrefixes: REPLACED_PREFIXES,
+    })
+    expect(() => loader.requireFrom(`${DEFAULT_ROOT}/workspace`)(SUBJECT))
+      .toThrow(/still carries module syntax, so the image was not lowered by the packer/)
+  })
+})

+ 24 - 0
packages/experimental/webworker-packer/tsconfig.json

@@ -0,0 +1,24 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "types": [
+      "node"
+    ]
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../webworker-runtime"
+    },
+    {
+      "path": "../../util/home-paths"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    }
+  ]
+}

+ 18 - 0
packages/experimental/webworker-packer/tsdown.config.ts

@@ -0,0 +1,18 @@
+import { defineConfig } from 'tsdown'
+
+/**
+ * The packer ships TWO entries: the library (`index`) and the `dsh-pack-vfs-image`
+ * CLI (`bin`), the latter referenced by package.json `bin`. The root tsdown
+ * builds only `lib/types/index.js`, so this override adds `lib/types/bin.js`.
+ * Declarations come from `tsc -b` (dts: false), matching every package.
+ */
+export default defineConfig({
+  entry: ['lib/types/index.js', 'lib/types/bin.js', 'lib/types/invariant.js'],
+  outDir: 'lib',
+  format: ['esm'],
+  platform: 'node',
+  target: 'es2024',
+  fixedExtension: false,
+  dts: false,
+  clean: false,
+})

+ 6 - 0
packages/experimental/webworker-runtime/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/experimental/webworker-runtime/README.md
+README.md: b82c65b981be6a9405ae72e3a24a42c68b52696e
+README.zh.md: 97160641a38095026d5103f2e423846bfb41d5a6

+ 33 - 0
packages/experimental/webworker-runtime/README.md

@@ -0,0 +1,33 @@
+# `@deepseek-ai/dsh-experimental-webworker-runtime`
+
+English | [中文](README.zh.md)
+
+The browser worker host: the whole harness plugin tree runs inside one dedicated Web Worker, for preview deployments and packaging regressions ([experimental stance](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)). The worker inflates a packed VFS image off its download and mounts it in memory, loads its modules through a CommonJS wrapper loader, and serves the page over a postMessage tunnel that speaks plain HTTP.
+
+Three artifacts from one tsdown pipeline:
+
+- **`lib/index.js` (assembly library)** — `createWorkerHost`/`startWorkerHost` mount the image (`storage/`), install the module loader (`module-system/`) and the `process` shim, boot the tree through the image's own `dsh-app-boot`, and hand the tunnel its serving seams. The image layout contract (`image-layout.ts`: virtual root, config/manifest paths, empty directories, the `lowered` wrapper-contract gate) is shared with the packer. Boot patches force the deployment-shaped rows: frontend serving off, JSONL session logs on the plaintext path, preset roots onto the image's `config/agent-presets`.
+- **`lib/worker.js` (worker bundle)** — the assembly plus this package's Node-compatibility layer as one self-contained ES module. The module proxy table (`module-proxies.ts`) is the only platform fork: `node:*` builtins over VFS/tunnel/browser primitives, structural stubs that fail loud on the console for what a browser cannot do, and replaced externals. AsyncLocalStorage carries sync-stack causality across `await` through the snapshot/restore faces the pack-time lowering injects. The worker holds no compiler: an image the packer did not lower is refused at mount ([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.md)).
+- **`src/shell/` (the worker's own process layer)** — a browser worker cannot fork, so `node:child_process` is not a stub but an implementation: `spawn` starts the command in its own Web Worker — this same bundle, told by its first frame to be a shell process — and reports it through the `ChildProcess` surface the subprocess service consumes. The command runs off the host's thread, `SIGKILL` terminates it whatever it is doing, and it reaches the VFS only by message (the host serves those frames). The grammar is `@yarnpkg/parsers`' `parseShell`; this package owns the evaluator (pipelines, `&&`/`||`, subshells, redirections, expansion, globs) and the command table, which is the only `/bin` that exists — a name it does not hold reports `command not found`, and `execSync`/`fork` still refuse, because they need a real process.
+- **`lib/client.js` (page half)** — `connectWorkerHost(worker, { image? })` completes the pre-Cordis handshake: the opening `init` frame carries the image URL (the one deployment-shaped input), the boot payload delivers the structured index-injection table, and `applyIndexInjections` executes it before the shell entry runs. The tunnel exposes fetch-shaped transport, the API client, and `loadBundle` for the shell's boot seam.
+
+Acceptance lives in `apps/web/tests/preview-boot.e2e.ts`, which serves the real built pages and drives the worker boot in headless Chromium.
+
+## Model Experience
+
+None, as this package only hosts the tree in a browser worker and answers its `node:*` calls; every model-facing registration belongs to the plugins it boots.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`.
+- **The skill catalog is never cached in the worker** — `skill-filesystem` watches its roots through `node:fs.watchFile`, which this package refuses, so every discovery pass returns an incomplete observation and re-scans. Discovery itself stays correct; the cost is a re-scan on every pass.
+- **`node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing a real process or realm isolation cannot run here.
+- **The bash tool runs only under `danger-full-access`**: a browser has no kernel to confine a command with, so `ctx.sandbox.confine` fails loud in every other permission preset and the command never starts. The mode is the deployment's own user-facing switch, not a worker-specific composition.
+- **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there.
+- **The shell is not bash**: no loops, functions, `case`, job control, or process substitution — the grammar stops at pipelines, `&&`/`||`, subshells, groups, redirections, and expansion. `&` runs its command to completion in place, `sed` accepts only substitution scripts, patterns are JavaScript regular expressions, and the command table holds coreutils only (no `git`, no network tools).
+- **A shell process has no synchronous filesystem**: it reads and writes the host's VFS by message, because blocking on a reply would need `SharedArrayBuffer`, which requires a cross-origin isolation GitHub Pages cannot grant. Directory-walking commands therefore cost one round trip per entry, and two concurrent commands can interleave their writes.
+- **Transport, worker-host, and page-half coverage needs a browser-grade harness** — the per-file coverage gate is unmet for those modules; unit specs cover storage, ALS, the transform, and the stub contracts.

+ 33 - 0
packages/experimental/webworker-runtime/README.zh.md

@@ -0,0 +1,33 @@
+# `@deepseek-ai/dsh-experimental-webworker-runtime`
+
+[English](README.md) | 中文
+
+浏览器 worker 宿主:整棵 harness 插件树跑在一个 dedicated Web Worker 里,用于预览部署与打包回归([experimental 定位](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。worker 边下载边解压打包好的 VFS 镜像并挂载进内存,经 CommonJS 包装加载器装载模块,并通过一条讲纯 HTTP 的 postMessage 隧道服务页面。
+
+一条 tsdown 管线出三个产物:
+
+- **`lib/index.js`(装配库)**——`createWorkerHost`/`startWorkerHost` 挂载镜像(`storage/`)、安装模块加载器(`module-system/`)与 `process` shim、经镜像自带的 `dsh-app-boot` 启动插件树,并把服务缝隙交给隧道。镜像布局契约(`image-layout.ts`:虚拟根、config/manifest 路径、空目录、`lowered` 包装契约门)与 packer 共享。boot patch 强制部署形态行:关前端静态服务、JSONL 会话日志走明文、preset 根指向镜像内 `config/agent-presets`。
+- **`lib/worker.js`(worker 束)**——装配库加本包的 Node 兼容层,合成一个自含 ES module。模块代理表(`module-proxies.ts`)是唯一平台叉口:`node:*` 内建走 VFS/隧道/浏览器原语,浏览器做不到的走结构化 stub(调用即 console 报错并抛出),外部包整体替换。AsyncLocalStorage 经 pack 时降低注入的 snapshot/restore 面在 `await` 间携带同步栈因果。worker 不带编译器:packer 未降低的镜像在挂载时被拒([note](../../../.agents/notes/implemented/architecture/2026-08-20-webworker-pack-lowering-and-preview.zh.md))。
+- **`src/shell/`(worker 自己的进程层)**——浏览器 worker 无法 fork,所以 `node:child_process` 不是 stub 而是实现:`spawn` 把命令放进它自己的 Web Worker——就是这同一个束,由首帧告诉它「你是 shell 进程」——并以 subprocess 服务消费的 `ChildProcess` 面报告结果。命令不占宿主线程,`SIGKILL` 不管它在干什么都能终止它,而它只能靠消息触达 VFS(由宿主应答这些帧)。语法来自 `@yarnpkg/parsers` 的 `parseShell`;求值器(管道、`&&`/`||`、子 shell、重定向、展开、glob)与命令表由本包自持,而命令表就是这里唯一存在的 `/bin`——表里没有的名字报 `command not found`,`execSync`/`fork` 依然拒绝,因为它们需要真进程。
+- **`lib/client.js`(页面半)**——`connectWorkerHost(worker, { image? })` 完成 pre-Cordis 握手:开局 `init` 帧携带镜像 URL(唯一部署形态输入),boot 载荷送达结构化 index 注入表,`applyIndexInjections` 在壳入口运行前逐行执行。隧道暴露 fetch 形传输、API 客户端与壳启动缝隙用的 `loadBundle`。
+
+验收在 `apps/web/tests/preview-boot.e2e.ts`:静态服务真实构建页面,在 headless Chromium 里驱动 worker 启动。
+
+## 模型体验
+
+无:本包只在浏览器 worker 里承载插件树并应答它的 `node:*` 调用;所有面向模型的注册都属于它启动的那些插件。
+
+#### KV Cache 影响
+
+无:本包既不组装也不发送 provider 请求。
+
+## Known Limitations and Deferred Work
+
+- **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`。
+- **worker 里的技能目录从不缓存**——`skill-filesystem` 用 `node:fs.watchFile` 监听各个根,而本包拒绝该调用,于是每轮发现都返回不完整观测并重新扫描。发现本身仍然正确,代价是每轮都要重扫。
+- **`node:vm`、`node:net`、`node:sqlite`、`node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要真进程或真 realm 隔离的行在此无法运行。
+- **bash 工具只在 `danger-full-access` 下可用**:浏览器没有内核可以约束命令,因此在其余权限档位下 `ctx.sandbox.confine` 会响亮失败、命令根本不会启动。该档位是部署本身的用户面开关,不是 worker 特有的组合差异。
+- **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。
+- **这个 shell 不是 bash**:没有循环、函数、`case`、作业控制或进程替换——语法止步于管道、`&&`/`||`、子 shell、group、重定向与展开。`&` 会就地把命令跑完,`sed` 只接受替换脚本,模式是 JavaScript 正则,命令表只有 coreutils(没有 `git`,没有网络工具)。
+- **shell 进程没有同步文件面**:它靠消息读写宿主的 VFS,因为阻塞等待回帧需要 `SharedArrayBuffer`,而那要求 GitHub Pages 给不了的跨源隔离。因此目录遍历类命令每个条目一次往返,并发的两条命令写入可以交错。
+- **transport、worker-host、页面半的覆盖需要浏览器级 harness**——这些模块未达 per-file 覆盖门;单测覆盖 storage、ALS、transform 与 stub 契约。

+ 65 - 0
packages/experimental/webworker-runtime/package.json

@@ -0,0 +1,65 @@
+{
+  "name": "@deepseek-ai/dsh-experimental-webworker-runtime",
+  "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged",
+  "version": "0.1.1-rc.2",
+  "private": true,
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/experimental/webworker-runtime"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json",
+    "./worker": "./lib/worker.js",
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    }
+  },
+  "license": "MIT",
+  "dependencies": {
+    "@deepseek-ai/dsh-util-crypto": "workspace:^",
+    "@noble/hashes": "^2.3.0",
+    "@yarnpkg/parsers": "^3.1.0",
+    "acorn": "^8.17.0",
+    "buffer": "^6.0.3",
+    "picomatch": "^4.0.4"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/cordis-plugin-loader": "workspace:^",
+    "@deepseek-ai/dsh-client-modules": "workspace:^",
+    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-host-webserver": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-subprocess-local": "workspace:^",
+    "@types/picomatch": "^3.0.2"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/worker.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 33 - 0
packages/experimental/webworker-runtime/src/client/api-client.ts

@@ -0,0 +1,33 @@
+/**
+ * Page-side API carrier over the postMessage tunnel. Only `doFetch` is
+ * implemented: the streaming methods stay on `AbstractApiClient`'s default
+ * `readSse`, which is exactly what the worker answers on the two event-stream
+ * paths — so unary calls and downstream streams share one framing and neither
+ * side needs a WebSocket.
+ */
+import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
+import type { WorkerTunnel } from './client.ts'
+
+/** API client whose requests travel the worker tunnel instead of the network. */
+export class WorkerApiClient extends AbstractApiClient {
+  private readonly tunnel: WorkerTunnel
+
+  /**
+   * Bind the carrier to a tunnel.
+   * @param tunnel - page half of the worker tunnel.
+   */
+  constructor(tunnel: WorkerTunnel) {
+    super()
+    this.tunnel = tunnel
+  }
+
+  /**
+   * Send one request through the tunnel.
+   * @param input - request URL.
+   * @param init - fetch init; the tunnel honours method, headers, body, and signal.
+   * @returns the reconstructed response.
+   */
+  protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
+    return this.tunnel.fetch(input, init)
+  }
+}

+ 50 - 0
packages/experimental/webworker-runtime/src/client/apply-injections.ts

@@ -0,0 +1,50 @@
+/**
+ * Page-side interpreter for the structured index injection table. The served
+ * form renders the same rows into index.html text; a static worker page has
+ * no served HTML, so it executes the table directly. Rows execute strictly in
+ * table order, so a global row lands before the scripts that read it.
+ */
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+
+function assertNever(row: never): never {
+  throw new Error(`webworker-runtime: unknown index injection row ${JSON.stringify(row)}`)
+}
+
+/**
+ * Execute every row in table order.
+ * @param rows - Injection table from the boot payload.
+ * @param loadScript - Executes one script-src row; the tunnel's `loadBundle`,
+ * because the row URLs (`/plugins/...`) resolve only through the worker.
+ */
+export async function applyIndexInjections(
+  rows: readonly IndexInjection[],
+  loadScript: (src: string) => Promise<void>,
+): Promise<void> {
+  for (const row of rows) {
+    switch (row.kind) {
+      case 'global':
+        (globalThis as Record<string, unknown>)[row.name] = row.value
+        break
+      case 'script': {
+        const el = document.createElement('script')
+        el.textContent = row.text
+        ;(row.placement === 'head' ? document.head : document.body).append(el)
+        break
+      }
+      case 'script-src':
+        await loadScript(row.src)
+        break
+      case 'style': {
+        const el = document.createElement('style')
+        el.textContent = row.text
+        document.head.append(el)
+        break
+      }
+      case 'html':
+        (row.placement === 'head' ? document.head : document.body).insertAdjacentHTML('beforeend', row.html)
+        break
+      default:
+        assertNever(row)
+    }
+  }
+}

+ 345 - 0
packages/experimental/webworker-runtime/src/client/client.ts

@@ -0,0 +1,345 @@
+/**
+ * Page half of the postMessage tunnel. It
+ * turns fetch-shaped calls into `req` frames and rebuilds Responses from the
+ * worker's `res` / `res-head`+`res-chunk`+`res-end` frames, so every consumer
+ * (boot payload, bundle transport, ApiClient, Typert RPC) speaks plain HTTP.
+ */
+
+import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver'
+
+/** Frame sent to the worker. */
+interface RequestFrame {
+  t: 'req'
+  id: number
+  method: string
+  /** Absolute URL; the worker derives `req.url` (pathname + search) from it. */
+  url: string
+  headers: Record<string, string>
+  body?: ArrayBuffer | undefined
+}
+
+/** Cancellation of an in-flight request or stream. */
+interface AbortFrame {
+  t: 'abort'
+  id: number
+}
+
+/** Frames received from the worker. */
+type ResponseFrame =
+  | { t: 'res'; id: number; status: number; headers: Record<string, string>; body?: ArrayBuffer; message?: string }
+  | { t: 'res-head'; id: number; status: number; headers: Record<string, string> }
+  | { t: 'res-chunk'; id: number; chunk: ArrayBuffer }
+  | { t: 'res-end'; id: number }
+  | { t: 'res-err'; id: number; message: string }
+
+/** Boot payload of the tunnel bootstrap route. */
+export interface BootPayload {
+  /** Structured index injection table, executed by the page interpreter. */
+  injections: IndexInjection[]
+}
+
+/** Fetch-shaped transport the client tree consumes. */
+export type TunnelFetch = (input: URL | string, init?: RequestInit) => Promise<Response>
+
+interface PendingUnary {
+  resolve(response: Response): void
+  reject(reason: Error): void
+}
+
+/**
+ * Statuses the worker only produces when the host refused the exchange rather than
+ * answered it; a route's own 4xx is the tree talking and stays silent here.
+ */
+const REFUSAL_STATUS = 500
+
+const encoder = new TextEncoder()
+
+/** Normalize a RequestInit body to a transferable ArrayBuffer. */
+function toBodyBuffer(body: RequestInit['body']): ArrayBuffer | undefined {
+  if (body === undefined || body === null) return undefined
+  if (typeof body === 'string') return encoder.encode(body).buffer
+  if (body instanceof ArrayBuffer) return body
+  if (ArrayBuffer.isView(body)) {
+    return body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)
+  }
+  throw new Error(`web-preview tunnel: unsupported request body ${Object.prototype.toString.call(body)}`)
+}
+
+/** Statuses whose Response must carry a null body. */
+const NULL_BODY_STATUS = new Set([101, 204, 205, 304])
+
+/** The page half of the tunnel: one `fetch`-shaped face over `postMessage`. */
+export class WorkerTunnel {
+  private readonly worker: Worker
+  private nextId = 1
+  private readonly unary = new Map<number, PendingUnary>()
+  private readonly streams = new Map<number, ReadableStreamDefaultController<Uint8Array>>()
+  /**
+   * In-flight request descriptions, so a refusal names what was refused.
+   *
+   * A tunnel failure and a failure inside the host tree look identical from the
+   * page — both surface as one rejected fetch — and the acceptance run keeps the
+   * page console but not the frames. Warning here separates the two without
+   * recording anything on the normal path, where no refusal frame ever arrives.
+   */
+  private readonly inFlight = new Map<number, string>()
+
+  /** Body-phase abort listeners, released when their stream settles. */
+  private readonly releases = new Map<number, () => void>()
+
+  /**
+   * Attach to a spawned worker and start consuming response frames.
+   * @param worker - the host worker.
+   */
+  constructor(worker: Worker) {
+    this.worker = worker
+    worker.addEventListener('message', (event: MessageEvent<ResponseFrame>) => {
+      this.receive(event.data)
+    })
+    worker.addEventListener('error', (event) => {
+      const reason = new Error(`web-preview tunnel: worker failed: ${event.message}`)
+      for (const id of this.inFlight.keys()) this.warnRefusal(id, `worker failed: ${event.message}`)
+      this.inFlight.clear()
+      for (const pending of this.unary.values()) pending.reject(reason)
+      this.unary.clear()
+      for (const controller of this.streams.values()) controller.error(reason)
+      this.streams.clear()
+      for (const release of this.releases.values()) release()
+      this.releases.clear()
+    })
+  }
+
+  /**
+   * Open the tunnel: the worker assembles its host from this frame.
+   * @param image - VFS image URL the worker fetches.
+   */
+  init(image: string): void {
+    this.worker.postMessage({ t: 'init', image })
+  }
+
+  /** Fetch-shaped entry: one request frame, one Response (streamed when the worker streams). */
+  readonly fetch: TunnelFetch = async (input, init) => {
+    const signal = init?.signal
+    // Checked before any frame leaves: a request the caller already abandoned
+    // must not reach the worker, where a write-shaped route would still run.
+    if (signal?.aborted === true) throw new DOMException('The operation was aborted.', 'AbortError')
+    const id = this.nextId++
+    const frame: RequestFrame = {
+      t: 'req',
+      id,
+      method: init?.method ?? 'GET',
+      url: new URL(input, globalThis.location.origin).toString(),
+      headers: Object.fromEntries(new Headers(init?.headers).entries()),
+      ...(init?.body === undefined || init.body === null
+        ? {}
+        : { body: toBodyBuffer(init.body) }),
+    }
+    const response = new Promise<Response>((resolve, reject) => {
+      this.unary.set(id, { resolve, reject })
+    })
+    this.inFlight.set(id, `${frame.method} ${frame.url}`)
+    this.worker.postMessage(frame)
+    if (signal === undefined || signal === null) return await response
+    const raced = this.rejectOnAbort(id, signal)
+    try {
+      const settled = await Promise.race([response, raced.rejected])
+      // A streaming response outlives its head: hand the signal to the body
+      // phase, so a later stop still ends the stream and reaches the worker.
+      if (this.streams.has(id)) this.observeStreamAbort(id, signal)
+      return settled
+    } finally {
+      raced.release()
+    }
+  }
+
+  /**
+   * Read the pre-cordis boot payload (the injection table).
+   * @returns The payload the page applies before the client tree loads.
+   */
+  async bootPayload(): Promise<BootPayload> {
+    const response = await this.fetch('/__boot__')
+    if (!response.ok) {
+      throw new Error(`web-preview tunnel: boot payload failed with HTTP ${String(response.status)}: ${await response.text()}`)
+    }
+    return await response.json() as BootPayload
+  }
+
+  /**
+   * `loadBundle` seam: take one client bundle through the tunnel and execute it
+   * as a classic script, exactly like the shell's same-origin `<script src>`.
+   * The image packs each bundle with a trailing `sourceURL` naming its image
+   * path, so the blob shows under that name in the debugger instead of as an
+   * anonymous blob entry.
+   * @param url - graph row url (`/plugins/<id>/client.js?rev=...`).
+   */
+  async loadBundle(url: string): Promise<void> {
+    const response = await this.fetch(url)
+    if (!response.ok) {
+      throw new Error(`web-preview tunnel: bundle ${url} failed with HTTP ${String(response.status)}`)
+    }
+    const source = await response.text()
+    const blob = URL.createObjectURL(new Blob([source], { type: 'text/javascript' }))
+    try {
+      await new Promise<void>((resolve, reject) => {
+        const el = document.createElement('script')
+        el.src = blob
+        el.addEventListener('load', () => {
+          el.remove()
+          resolve()
+        }, { once: true })
+        el.addEventListener('error', () => {
+          el.remove()
+          reject(new Error(`web-preview tunnel: bundle ${url} failed to execute`))
+        }, { once: true })
+        document.head.append(el)
+      })
+    } finally {
+      URL.revokeObjectURL(blob)
+    }
+  }
+
+  private rejectOnAbort(id: number, signal: AbortSignal): { rejected: Promise<never>; release: () => void } {
+    let release = (): void => {}
+    const rejected = new Promise<never>((_resolve, reject) => {
+      const fail = (): void => { reject(this.abortRequest(id)) }
+      if (signal.aborted) {
+        fail()
+        return
+      }
+      signal.addEventListener('abort', fail, { once: true })
+      // A completed request must not leave its listener on a long-lived
+      // signal, where every further request would pile another one on.
+      release = () => { signal.removeEventListener('abort', fail) }
+    })
+    return { rejected, release }
+  }
+
+  /**
+   * Tear down one request the page abandoned: the maps forget it, the worker
+   * is told, and a live body stream errors for its reader.
+   * @param id - request id being abandoned.
+   * @returns The abort error the caller surfaces.
+   */
+  private abortRequest(id: number): DOMException {
+    this.unary.delete(id)
+    const controller = this.streams.get(id)
+    this.streams.delete(id)
+    this.inFlight.delete(id)
+    this.releases.delete(id)
+    const abort: AbortFrame = { t: 'abort', id }
+    this.worker.postMessage(abort)
+    const reason = new DOMException('The operation was aborted.', 'AbortError')
+    controller?.error(reason)
+    return reason
+  }
+
+  /**
+   * Hold the caller's signal over the body phase: the head settled, so
+   * {@link rejectOnAbort}'s listener is about to go, but a stop must still
+   * end the stream. Released when the stream settles.
+   * @param id - request id whose body is still crossing.
+   * @param signal - the caller's signal.
+   */
+  private observeStreamAbort(id: number, signal: AbortSignal): void {
+    const onAbort = (): void => { this.abortRequest(id) }
+    signal.addEventListener('abort', onAbort, { once: true })
+    this.releases.set(id, () => { signal.removeEventListener('abort', onAbort) })
+  }
+
+  /** Release a body-phase abort listener a settled stream no longer needs. */
+  private releaseSignal(id: number): void {
+    const release = this.releases.get(id)
+    this.releases.delete(id)
+    release?.()
+  }
+
+  /** Cancel a stream the consumer stopped reading (the head already resolved). */
+  private cancelStream(id: number): void {
+    this.releaseSignal(id)
+    this.streams.delete(id)
+    this.inFlight.delete(id)
+    const abort: AbortFrame = { t: 'abort', id }
+    this.worker.postMessage(abort)
+  }
+
+  /**
+   * Report a refusal on the page console, where the acceptance run already keeps it.
+   *
+   * The prefix names the reporter, not the culprit: a 5xx can equally come from a
+   * handler inside the host tree. The message text decides — the worker expands
+   * nested causes into it, and its deepest layer is where the failure was thrown.
+   * @param id - request id the frame answers.
+   * @param outcome - what came back instead of a reply.
+   */
+  private warnRefusal(id: number, outcome: string): void {
+    console.warn(`web-preview tunnel: request ${String(id)} ${this.inFlight.get(id) ?? '(unknown request)'} → ${outcome}`)
+  }
+
+  private receive(frame: ResponseFrame): void {
+    switch (frame.t) {
+      case 'res': {
+        const pending = this.unary.get(frame.id)
+        if (pending === undefined) return
+        if (frame.status >= REFUSAL_STATUS) {
+          this.warnRefusal(frame.id, `HTTP ${String(frame.status)}${frame.message === undefined ? '' : `: ${frame.message}`}`)
+        }
+        this.unary.delete(frame.id)
+        this.inFlight.delete(frame.id)
+        const body = NULL_BODY_STATUS.has(frame.status)
+          ? null
+          : frame.body ?? frame.message ?? null
+        pending.resolve(new Response(body, { status: frame.status, headers: frame.headers }))
+        return
+      }
+      case 'res-head': {
+        const pending = this.unary.get(frame.id)
+        if (pending === undefined) return
+        this.unary.delete(frame.id)
+        const stream = new ReadableStream<Uint8Array>({
+          start: (controller) => {
+            this.streams.set(frame.id, controller)
+          },
+          cancel: () => {
+            this.cancelStream(frame.id)
+          },
+        })
+        pending.resolve(new Response(stream, { status: frame.status, headers: frame.headers }))
+        return
+      }
+      case 'res-chunk': {
+        this.streams.get(frame.id)?.enqueue(new Uint8Array(frame.chunk))
+        return
+      }
+      case 'res-end': {
+        const controller = this.streams.get(frame.id)
+        if (controller === undefined) return
+        this.streams.delete(frame.id)
+        this.inFlight.delete(frame.id)
+        this.releaseSignal(frame.id)
+        controller.close()
+        return
+      }
+      case 'res-err': {
+        const reason = new Error(`web-preview tunnel: ${frame.message}`)
+        this.warnRefusal(frame.id, `res-err: ${frame.message}`)
+        const pending = this.unary.get(frame.id)
+        this.inFlight.delete(frame.id)
+        if (pending !== undefined) {
+          this.unary.delete(frame.id)
+          pending.reject(reason)
+          return
+        }
+        const controller = this.streams.get(frame.id)
+        if (controller === undefined) return
+        this.streams.delete(frame.id)
+        this.releaseSignal(frame.id)
+        controller.error(reason)
+        return
+      }
+      default: {
+        const unknown: never = frame
+        throw new Error(`web-preview tunnel: unknown frame ${JSON.stringify(unknown)}`)
+      }
+    }
+  }
+}

+ 99 - 0
packages/experimental/webworker-runtime/src/client/index.ts

@@ -0,0 +1,99 @@
+/**
+ * Page half: everything a deployment needs to reach a worker-hosted harness.
+ *
+ * This is **pre-Cordis glue, not a client plugin**: it installs the transport
+ * global and executes the boot injection table that the client plugin graph
+ * is later loaded through, so it cannot itself be a graph row. A page imports
+ * it directly and decides where the worker bundle and image live; nothing
+ * here mounts into a shipped roster.
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime/client
+ */
+import { IMAGE_FILE_NAME } from '../image-layout.ts'
+import { WorkerApiClient } from './api-client.ts'
+import { WorkerTunnel, type TunnelFetch } from './client.ts'
+import { applyIndexInjections } from './apply-injections.ts'
+
+export { WorkerApiClient } from './api-client.ts'
+export { WorkerTunnel, type TunnelFetch } from './client.ts'
+export { applyIndexInjections } from './apply-injections.ts'
+export { IMAGE_FILE_NAME } from '../image-layout.ts'
+
+/** Transport global the connection plugin reads instead of building an HTTP carrier. */
+interface ClientTransportGlobal {
+  __DSH_TRANSPORT__?: {
+    createApiClient: () => WorkerApiClient
+    fetch: TunnelFetch
+    loadBundle: (url: string) => Promise<void>
+    /** The page spawned the worker the Host runs in, so the page owns it. */
+    ownsHost: boolean
+  }
+}
+
+/** Inputs for {@link connectWorkerHost}. */
+export interface WorkerHostConnectOptions {
+  /**
+   * VFS image URL, the one deployment-shaped input. Defaults to
+   * {@link IMAGE_FILE_NAME} beside the page; a deployment that packs the
+   * image elsewhere passes its own URL.
+   */
+  readonly image?: string | URL
+}
+
+/** A page connected to a worker-hosted harness, ready to run a shell entry. */
+export interface WorkerHostConnection {
+  readonly worker: Worker
+  readonly tunnel: WorkerTunnel
+  /** Bundle transport for the shell's boot seam. */
+  loadBundle(url: string): Promise<void>
+}
+
+/** Boot-readiness deferred shared with the client entry's pre-boot await. */
+interface BootReadyGlobal {
+  __DSH_BOOT_READY__?: PromiseWithResolvers<void>
+}
+
+/**
+ * Connect a spawned host worker and complete the pre-Cordis handshake.
+ *
+ * The caller constructs the Worker so its bundler resolves the bundle URL
+ * statically; the opening `init` frame then carries the image location, the
+ * only input the worker takes from outside.
+ *
+ * Order is fixed by the web boot protocol: the transport global must exist
+ * before any bundle executes; the injection table then reproduces the served
+ * boot rows — the `__ModuleLoader__` registration queue, the parser-preload
+ * bundles, `__DSH_BOOT__`, the theme bootstrap — in table order. The
+ * boot-readiness deferred (`__DSH_BOOT_READY__`) is installed before the
+ * first await and settles with the handshake, so a client entry evaluating
+ * concurrently in the same document holds at its pre-boot await until every
+ * row has taken effect, and surfaces a failed handshake instead of
+ * proceeding on missing globals.
+ * @param worker - The host worker.
+ * @param options - Image location override.
+ * @returns The connection; hand `loadBundle` to the shell entry's boot seam.
+ */
+export async function connectWorkerHost(worker: Worker, options?: WorkerHostConnectOptions): Promise<WorkerHostConnection> {
+  const ready = (globalThis as BootReadyGlobal).__DSH_BOOT_READY__ ??= Promise.withResolvers<void>()
+  // The handshake may fail before any entry awaits the promise; this no-op
+  // subscription keeps that from surfacing as an unhandled rejection.
+  void ready.promise.catch(() => {})
+  try {
+    const tunnel = new WorkerTunnel(worker)
+    tunnel.init(new URL(options?.image ?? IMAGE_FILE_NAME, document.baseURI).href)
+    const payload = await tunnel.bootPayload()
+    ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = {
+      createApiClient: () => new WorkerApiClient(tunnel),
+      fetch: (input, init) => tunnel.fetch(input, init),
+      loadBundle: (url: string) => tunnel.loadBundle(url),
+      // The host lives in a worker this page spawned: the page owns it, so
+      // the privileged surface stays reachable off loopback authorities.
+      ownsHost: true,
+    }
+    await applyIndexInjections(payload.injections, src => tunnel.loadBundle(src))
+    ready.resolve()
+    return { worker, tunnel, loadBundle: (url: string) => tunnel.loadBundle(url) }
+  } catch (reason) {
+    ready.reject(reason)
+    throw reason
+  }
+}

+ 571 - 0
packages/experimental/webworker-runtime/src/compile/transform.ts

@@ -0,0 +1,571 @@
+/**
+ * The worker's module transform: one acorn parse turns an ES module into a
+ * CommonJS body **and** routes every suspension point through the ambient-store
+ * protocol.
+ *
+ * Both jobs live in one pass because they are two edits over one syntax tree;
+ * running a lexer first and a parser second meant two scanners, two sets of
+ * blind spots, and a second pass reading the first pass's output. Editing is
+ * interval-based — the original text is sliced and spliced, never reprinted —
+ * so **line numbers survive**: a stack frame in a transformed module points at
+ * the same line as the built artifact it came from.
+ *
+ * The image packer is this transform's only caller: it lowers every JavaScript
+ * entry it packs and records `LOWERING_VERSION` in the image manifest, so the
+ * worker wraps those bodies without carrying a compiler of its own.
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/compile/transform
+ */
+import { parse } from 'acorn'
+
+const HELPER_SOURCE: Record<string, string> = {
+  def: 'const __dsh$def=(t,k,get)=>Object.defineProperty(t,k,{enumerable:true,configurable:true,get});',
+  default: 'const __dsh$default=(m)=>(m&&m.__esModule?m.default:m);',
+  ns: 'const __dsh$ns=(m)=>(m&&m.__esModule?m:Object.assign({},m,{default:m}));',
+  exportAll: 'const __dsh$exportAll=(t,m)=>{for(const k of Object.keys(m))if(k!=="default"&&!(k in t))__dsh$def(t,k,()=>m[k]);};',
+  dynImport: 'const __dsh$dynImport=(s)=>Promise.resolve().then(()=>__dsh$ns(require(s)));',
+}
+
+const HELPER_DEPENDENCIES: Record<string, readonly string[]> = {
+  exportAll: ['def'],
+  dynImport: ['ns'],
+}
+
+/** Runtime identifier the suspension protocol reaches. */
+const ALS = '__als'
+
+interface Node {
+  readonly type: string
+  readonly start: number
+  readonly end: number
+  readonly [key: string]: unknown
+}
+
+/** @returns Number of line breaks in a slice. */
+function countNewlines(text: string): number {
+  let count = 0
+  for (let index = text.indexOf('\n'); index >= 0; index = text.indexOf('\n', index + 1)) count += 1
+  return count
+}
+
+interface Edit {
+  readonly start: number
+  readonly end: number
+  /** Rendered lazily so edits inside a replaced range still apply. */
+  readonly render: (inner: (from: number, to: number) => string) => string
+}
+
+/** One binding to publish on `exports`. */
+interface Binding {
+  readonly exported: string
+  readonly local: string
+}
+
+class Transformer {
+  private readonly edits: Edit[] = []
+  private readonly source: string
+  private readonly helpers = new Set<string>()
+  private readonly bindings: Binding[] = []
+  private modules = 0
+  private temporaries = 0
+  private moduleSyntax = false
+  private readonly moduleRequests = new Set<string>()
+  private readonly metaResolveRequests = new Set<string>()
+
+  constructor(source: string, private readonly path: string) {
+    // A `#!` line is only legal at offset zero, and the prologue takes that spot;
+    // commenting it out in place keeps every offset and the line count intact.
+    this.source = source.startsWith('#!') ? `//${source.slice(2)}` : source
+  }
+
+  private fail(detail: string, index: number): never {
+    const line = this.source.slice(0, index).split('\n').length
+    throw new Error(`webworker transform: ${detail} (${this.path}:${line})`)
+  }
+
+  private helper(name: string): string {
+    for (const dependency of HELPER_DEPENDENCIES[name] ?? []) this.helper(dependency)
+    this.helpers.add(name)
+    return `__dsh$${name}`
+  }
+
+  private moduleTemp(): string {
+    this.modules += 1
+    return `__dsh$m${this.modules}`
+  }
+
+  private alsTemp(): string {
+    this.temporaries += 1
+    return `__als$${this.temporaries}`
+  }
+
+  /**
+   * Replace a range, keeping the module's line count.
+   *
+   * The padding is the newlines the original range held **minus** the ones the
+   * replacement re-emits: a rewrite that splices the original body back in
+   * (a desugared loop) already carries that body's newlines, and padding by the
+   * whole range again would push every later line down.
+   */
+  private edit(start: number, end: number, build: (inner: (from: number, to: number) => string) => string): void {
+    const original = countNewlines(this.source.slice(start, end))
+    this.edits.push({
+      start,
+      end,
+      render: (inner) => {
+        const text = build(inner)
+        return text + '\n'.repeat(Math.max(0, original - countNewlines(text)))
+      },
+    })
+  }
+
+  private replace(start: number, end: number, text: string): void {
+    this.edit(start, end, () => text)
+  }
+
+  private insert(at: number, text: string): void {
+    this.edits.push({ start: at, end: at, render: () => text })
+  }
+
+  private structural(start: number, end: number, render: Edit['render']): void {
+    this.edit(start, end, render)
+  }
+
+  private literal(node: Node): string {
+    const value = node.value
+    if (typeof value !== 'string') this.fail('a module specifier must be a string literal', node.start)
+    this.moduleRequests.add(value)
+    return JSON.stringify(value)
+  }
+
+  /** @returns Static module requests the body makes, in first-appearance order. */
+  requests(): readonly string[] {
+    return [...this.moduleRequests]
+  }
+
+  /** @returns Literal `import.meta.resolve()` requests, in first-appearance order. */
+  metaRequests(): readonly string[] {
+    return [...this.metaResolveRequests]
+  }
+
+  // --- module syntax --------------------------------------------------------
+
+  private importDeclaration(node: Node): void {
+    this.moduleSyntax = true
+    if (Array.isArray(node.attributes) && node.attributes.length > 0) {
+      this.fail('import attributes are not supported', node.start)
+    }
+    const request = `require(${this.literal(node.source as Node)})`
+    const specifiers = node.specifiers as Node[]
+    if (specifiers.length === 0) {
+      this.replace(node.start, node.end, `${request};`)
+      return
+    }
+    const held = this.moduleTemp()
+    const lines = [`const ${held}=${request};`]
+    for (const specifier of specifiers) {
+      const local = (specifier.local as Node).name as string
+      if (specifier.type === 'ImportDefaultSpecifier') {
+        lines.push(`const ${local}=${this.helper('default')}(${held});`)
+        continue
+      }
+      if (specifier.type === 'ImportNamespaceSpecifier') {
+        lines.push(`const ${local}=${this.helper('ns')}(${held});`)
+        continue
+      }
+      const imported = specifier.imported as Node
+      const name = imported.type === 'Identifier' ? imported.name as string : imported.value as string
+      lines.push(`const ${local}=${held}[${JSON.stringify(name)}];`)
+    }
+    this.replace(node.start, node.end, lines.join(''))
+  }
+
+  private exportNamed(node: Node): void {
+    this.moduleSyntax = true
+    const declaration = node.declaration as Node | null
+    const source = node.source as Node | null
+    const specifiers = node.specifiers as Node[]
+
+    if (declaration !== null) {
+      // `export const x = 1` keeps its declaration; only the keyword goes.
+      this.replace(node.start, declaration.start, '')
+      for (const { exported, local } of declaredBindings(declaration, detail => this.fail(detail, declaration.start))) {
+        this.bindings.push({ exported, local })
+      }
+      return
+    }
+    if (source !== null) {
+      const held = this.moduleTemp()
+      const define = this.helper('def')
+      const lines = [`const ${held}=require(${this.literal(source)});`]
+      for (const specifier of specifiers) {
+        const local = nameOf(specifier.local as Node)
+        const exported = nameOf(specifier.exported as Node)
+        lines.push(`${define}(exports,${JSON.stringify(exported)},()=>${held}[${JSON.stringify(local)}]);`)
+      }
+      this.replace(node.start, node.end, lines.join(''))
+      return
+    }
+    // A bare `export {}` is a module marker with nothing to publish.
+    for (const specifier of specifiers) {
+      this.bindings.push({ exported: nameOf(specifier.exported as Node), local: nameOf(specifier.local as Node) })
+    }
+    this.replace(node.start, node.end, '')
+  }
+
+  private exportDefault(node: Node): void {
+    this.moduleSyntax = true
+    const declaration = node.declaration as Node
+    this.replace(node.start, declaration.start, 'exports.default = ')
+  }
+
+  private exportAll(node: Node): void {
+    this.moduleSyntax = true
+    const request = `require(${this.literal(node.source as Node)})`
+    const exported = node.exported as Node | null
+    if (exported === null) {
+      this.replace(node.start, node.end, `${this.helper('exportAll')}(exports,${request});`)
+      return
+    }
+    const held = this.moduleTemp()
+    const define = this.helper('def')
+    this.replace(
+      node.start,
+      node.end,
+      `const ${held}=${this.helper('ns')}(${request});${define}(exports,${JSON.stringify(nameOf(exported))},()=>${held});`,
+    )
+  }
+
+  // --- suspension points ----------------------------------------------------
+
+  private awaitExpression(node: Node): void {
+    const keywordEnd = node.start + 'await'.length
+    if (this.source.slice(node.start, keywordEnd) !== 'await') this.fail('unexpected await layout', node.start)
+    this.replace(node.start, keywordEnd, `${ALS}.resume(await ${ALS}.pause(`)
+    this.insert(node.end, '))')
+  }
+
+  /**
+   * `for await (L of R) B` becomes an explicit loop over the same protocol.
+   * `iterator.return` runs only on abrupt completion, as the language says, and
+   * is awaited so teardown still orders before the loop exits.
+   */
+  private forAwait(node: Node): void {
+    const left = node.left as Node
+    const right = node.right as Node
+    const body = node.body as Node
+    const iterator = this.alsTemp()
+    const step = this.alsTemp()
+    const exhausted = this.alsTemp()
+    const binding = (inner: (from: number, to: number) => string): string => {
+      if (left.type !== 'VariableDeclaration') return `(${inner(left.start, left.end)})=${step}.value;`
+      const declarations = left.declarations as Node[]
+      const pattern = declarations[0]?.id as Node | undefined
+      if (declarations.length !== 1 || pattern === undefined) {
+        this.fail('for-await must declare exactly one binding', left.start)
+      }
+      return `${String(left.kind)} ${inner(pattern.start, pattern.end)}=${step}.value;`
+    }
+    this.structural(node.start, node.end, inner => [
+      `{const ${iterator}=${ALS}.iterator(${inner(right.start, right.end)});`,
+      `let ${step};let ${exhausted}=false;`,
+      `try{for(;;){${step}=${ALS}.resume(await ${ALS}.pause(${iterator}.next()));`,
+      `if(${step}.done){${exhausted}=true;break}`,
+      `{${binding(inner)}${body.type === 'BlockStatement' ? inner(body.start, body.end) : `{${inner(body.start, body.end)}}`}}}}`,
+      `finally{if(!${exhausted})${ALS}.resume(await ${ALS}.pause(${ALS}.close(${iterator})))}}`,
+    ].join(''))
+  }
+
+  /**
+   * `yield` resumes with whatever the consumer sent, so the snapshot is taken
+   * before suspending and restored when the call completes. `yield*` delegates,
+   * which has no expression form here: it is desugared as a statement, and a
+   * consumer's `throw()` is not forwarded into the inner iterator (`next` and
+   * `return` are).
+   */
+  private yieldExpression(node: Node, statement: Node | undefined): void {
+    if (node.delegate !== true) {
+      this.insert(node.start, `${ALS}.afterYield(${ALS}.snapshot(),`)
+      this.insert(node.end, ')')
+      return
+    }
+    const argument = node.argument as Node | null
+    if (argument === null) this.fail('yield* without an operand', node.start)
+    if (statement === undefined) this.fail('yield* is only supported as a statement', node.start)
+    if ((statement.expression as Node) !== node) {
+      // Anything around the delegation (`x = yield* g()`, `f(yield* g())`)
+      // would be silently dropped by the statement-wide rewrite below; the
+      // all-or-nothing lowering contract demands a loud refusal instead.
+      this.fail('yield* is only supported as the whole statement expression', node.start)
+    }
+    const iterator = this.alsTemp()
+    const step = this.alsTemp()
+    const sent = this.alsTemp()
+    const exhausted = this.alsTemp()
+    this.structural(statement.start, statement.end, inner => [
+      `{const ${iterator}=${ALS}.iterator(${inner(argument.start, argument.end)});`,
+      `let ${sent};let ${exhausted}=false;`,
+      `try{for(;;){const ${step}=${ALS}.resume(await ${ALS}.pause(${iterator}.next(${sent})));`,
+      `if(${step}.done){${exhausted}=true;break}`,
+      `${sent}=${ALS}.afterYield(${ALS}.snapshot(),yield ${step}.value)}}`,
+      `finally{if(!${exhausted})${ALS}.resume(await ${ALS}.pause(${ALS}.close(${iterator})))}}`,
+    ].join(''))
+  }
+
+  // --- traversal ------------------------------------------------------------
+
+  private visit(node: unknown, context: { asyncGenerator: boolean; functionDepth: number; statement?: Node }): void {
+    if (node === null || typeof node !== 'object') return
+    if (Array.isArray(node)) {
+      for (const child of node) this.visit(child, context)
+      return
+    }
+    const record = node as Node
+    if (typeof record.type !== 'string') return
+    let next = context
+    switch (record.type) {
+      case 'ImportDeclaration': this.importDeclaration(record); break
+      case 'ExportNamedDeclaration': this.exportNamed(record); break
+      case 'ExportDefaultDeclaration': this.exportDefault(record); break
+      case 'ExportAllDeclaration': this.exportAll(record); break
+      case 'ImportExpression': {
+        this.moduleSyntax = true
+        if (!this.source.startsWith('import', record.start)) this.fail('unexpected dynamic import layout', record.start)
+        this.replace(record.start, record.start + 'import'.length, this.helper('dynImport'))
+        // A computed dynamic import stays out of the request list; resolution
+        // then happens (and fails loud) at runtime, never silently at pack time.
+        const argument = record.source as Node | undefined
+        if (argument !== undefined && typeof argument.value === 'string') this.moduleRequests.add(argument.value)
+        break
+      }
+      case 'CallExpression': {
+        // CommonJS bodies pass through untransformed, but their literal
+        // `require()` calls are module requests all the same.
+        const callee = record.callee as Node
+        const callArguments = record.arguments as Node[]
+        if (callee.type === 'Identifier' && callee.name === 'require' && callArguments.length === 1
+          && typeof callArguments[0]?.value === 'string') {
+          this.moduleRequests.add(callArguments[0].value)
+        }
+        // `import.meta.resolve('lit')` is the third static request face: the
+        // loader answers it from the image, so the pack sweep must keep the
+        // target. A computed argument stays out, same as dynamic import —
+        // resolution then fails loud at runtime, never silently at pack time.
+        if (callee.type === 'MemberExpression') {
+          const object = callee.object as Node
+          const property = callee.property as Node
+          if (object.type === 'MetaProperty' && (object.meta as Node).name === 'import'
+            && property.type === 'Identifier' && property.name === 'resolve'
+            && typeof callArguments[0]?.value === 'string') {
+            this.metaResolveRequests.add(callArguments[0].value)
+          }
+        }
+        break
+      }
+      case 'MetaProperty': {
+        // `new.target` is a MetaProperty too, and it must survive untouched:
+        // the abstract-seam guards in the roster read it (`new.target === X`).
+        const meta = record.meta as Node
+        if (meta.name === 'import') {
+          this.moduleSyntax = true
+          this.replace(record.start, record.end, '__dsh$meta')
+        }
+        break
+      }
+      case 'AwaitExpression':
+        if (context.functionDepth === 0) {
+          this.fail('top-level await cannot run as CommonJS in the worker', record.start)
+        }
+        this.awaitExpression(record)
+        break
+      case 'ForOfStatement':
+        if (record.await === true) {
+          if (context.functionDepth === 0) this.fail('a top-level for-await loop cannot run as CommonJS', record.start)
+          this.forAwait(record)
+        }
+        break
+      case 'LabeledStatement': {
+        const body = record.body as Node
+        if (body.type === 'ForOfStatement' && body.await === true) {
+          this.fail('a labeled for-await loop is not supported', record.start)
+        }
+        break
+      }
+      case 'YieldExpression':
+        if (context.asyncGenerator) this.yieldExpression(record, context.statement)
+        break
+      case 'FunctionDeclaration':
+      case 'FunctionExpression':
+      case 'ArrowFunctionExpression':
+        next = {
+          asyncGenerator: record.async === true && record.generator === true,
+          functionDepth: context.functionDepth + 1,
+        }
+        break
+      default: break
+    }
+    if (record.type === 'ExpressionStatement') next = { ...next, statement: record }
+    for (const [key, value] of Object.entries(record)) {
+      if (key === 'type' || key === 'start' || key === 'end') continue
+      this.visit(value, next)
+    }
+  }
+
+  run(): string {
+    // Transforming a lowered body again would nest the protocol inside itself:
+    // it still runs, only slower and unreadable, so a mis-wired manifest must
+    // surface here rather than as a silent tax on every load.
+    if (this.source.includes(`${ALS}.pause(`) || this.source.includes('__als$')) {
+      this.fail('the module is already lowered; check the image manifest wiring', 0)
+    }
+    let program: Node
+    try {
+      program = parse(this.source, {
+        ecmaVersion: 'latest',
+        sourceType: 'module',
+        allowAwaitOutsideFunction: true,
+      }) as unknown as Node
+    } catch (reason) {
+      this.fail(`parse failed: ${(reason as Error).message}`, 0)
+    }
+    this.visit(program, { asyncGenerator: false, functionDepth: 0 })
+    if (this.edits.length === 0 && !this.moduleSyntax) return this.source
+
+    const prologue: string[] = []
+    if (this.moduleSyntax) prologue.push('"use strict";Object.defineProperty(exports,"__esModule",{value:true});')
+    if (this.bindings.length > 0) this.helper('def')
+    for (const [name, source] of Object.entries(HELPER_SOURCE)) {
+      if (this.helpers.has(name)) prologue.push(source)
+    }
+    for (const { exported, local } of this.bindings) {
+      prologue.push(`__dsh$def(exports,${JSON.stringify(exported)},()=>${local});`)
+    }
+
+    const sorted = [...this.edits].sort((left, right) => left.start - right.start || left.end - right.end)
+    const render = (from: number, to: number): string => {
+      let cursor = from
+      let out = ''
+      for (const edit of sorted) {
+        if (edit.start < cursor || edit.end > to) continue
+        out += this.source.slice(cursor, edit.start) + edit.render(render)
+        cursor = edit.end
+      }
+      return out + this.source.slice(cursor, to)
+    }
+    const code = prologue.join('') + render(0, this.source.length)
+    // Proof that the emitted body is CommonJS a wrapper can compile: any leftover
+    // module syntax, or any mis-spliced interval, fails here rather than at load.
+    try {
+      parse(code, { ecmaVersion: 'latest', sourceType: 'script', allowAwaitOutsideFunction: false })
+    } catch (reason) {
+      this.fail(`the transform produced code that does not parse: ${(reason as Error).message}`, 0)
+    }
+    return code
+  }
+}
+
+/** @returns The name a specifier or identifier node carries. */
+function nameOf(node: Node): string {
+  return node.type === 'Identifier' ? node.name as string : String(node.value)
+}
+
+/** Every binding an exported declaration introduces, including patterns. */
+function declaredBindings(declaration: Node, fail: (detail: string) => never): Binding[] {
+  if (declaration.type === 'FunctionDeclaration' || declaration.type === 'ClassDeclaration') {
+    const id = declaration.id as Node | null
+    if (id === null) fail('an exported declaration must be named')
+    const name = id.name as string
+    return [{ exported: name, local: name }]
+  }
+  if (declaration.type !== 'VariableDeclaration') fail(`unsupported exported declaration ${declaration.type}`)
+  const bindings: Binding[] = []
+  const collect = (pattern: Node): void => {
+    switch (pattern.type) {
+      case 'Identifier':
+        bindings.push({ exported: pattern.name as string, local: pattern.name as string })
+        return
+      case 'ObjectPattern':
+        for (const property of pattern.properties as Node[]) {
+          collect((property.type === 'RestElement' ? property.argument : property.value) as Node)
+        }
+        return
+      case 'ArrayPattern':
+        for (const element of pattern.elements as Array<Node | null>) if (element !== null) collect(element)
+        return
+      case 'AssignmentPattern':
+        collect(pattern.left as Node)
+        return
+      case 'RestElement':
+        collect(pattern.argument as Node)
+        return
+      default:
+        fail(`unsupported binding pattern ${pattern.type}`)
+    }
+  }
+  for (const declarator of declaration.declarations as Node[]) collect(declarator.id as Node)
+  return bindings
+}
+
+interface TransformedModule {
+  readonly code: string
+  readonly moduleRequests: readonly string[]
+  readonly metaResolveRequests: readonly string[]
+}
+
+const cache = new Map<string, TransformedModule>()
+
+/**
+ * Transform one module into a body for the worker wrapper.
+ *
+ * Results are cached by source text, so a module reached through two paths, or
+ * a repeated build, parses once.
+ * @param source - Module source, ESM or CommonJS.
+ * @param path - Path used in diagnostics.
+ * @returns The lowered body and the module requests found in it.
+ */
+function transformDetailed(source: string, path: string): TransformedModule {
+  const cached = cache.get(source)
+  if (cached !== undefined) return cached
+  const transformer = new Transformer(source, path)
+  const transformed = { code: transformer.run(), moduleRequests: transformer.requests(), metaResolveRequests: transformer.metaRequests() }
+  cache.set(source, transformed)
+  return transformed
+}
+
+/** One module the collector considered. */
+export interface LoweredModule {
+  /** Transformed body, or the input unchanged when nothing needed lowering. */
+  readonly code: string
+  /** False means the entry may be packed as it is. */
+  readonly lowered: boolean
+  /**
+   * Static module requests the body makes: import and re-export sources,
+   * literal dynamic imports, and literal `require()` calls. Computed requests
+   * are absent — they resolve (and fail loud) at runtime only.
+   */
+  readonly moduleRequests: readonly string[]
+  /**
+   * Literal `import.meta.resolve()` requests. These are URL mappings, not
+   * loads: the pack sweep keeps a resolvable target and tolerates a missing
+   * one, and the loader answers or throws at the call site.
+   */
+  readonly metaResolveRequests: readonly string[]
+}
+
+/**
+ * Lower one module at image-pack time.
+ *
+ * The collector calls this for every JavaScript entry it packs and records
+ * `LOWERING_VERSION` in the image manifest; the loader then wraps those entries
+ * without parsing them. `lowered: false` reports that the transform would have
+ * returned the input verbatim (already CommonJS, no suspension point), so the
+ * entry may be packed as it is.
+ *
+ * Throwing is the intended failure mode: a module this transform cannot express
+ * must fail the build rather than ship an image that breaks at load.
+ * @param options - Virtual path inside the image and the module source.
+ * @returns The code to pack and whether it changed.
+ */
+export function lowerModuleSource(options: { readonly filename: string; readonly source: string }): LoweredModule {
+  const { code, moduleRequests, metaResolveRequests } = transformDetailed(options.source, options.filename)
+  return { code, lowered: code !== options.source, moduleRequests, metaResolveRequests }
+}

+ 47 - 0
packages/experimental/webworker-runtime/src/image-layout.ts

@@ -0,0 +1,47 @@
+/**
+ * Image layout contract shared by the packer and the worker host: the virtual
+ * root, where the composed config and the manifest sit inside the image, and
+ * the working directories every image carries empty. One definition, two
+ * consumers — the packer writes this layout, the worker host mounts it.
+ */
+
+/** Default virtual root; the runtime mounts the image here unless told otherwise. */
+export const DEFAULT_ROOT = '/dsh'
+
+/**
+ * Leaf name of the packed image: one gzip member holding the ustar archive. The
+ * app build writes it beside the page and the page's boot fetches it from there,
+ * so the extension is part of what a deployment serves.
+ */
+export const IMAGE_FILE_NAME = 'vfs-image.tar.gz'
+
+/** Image path the composed profile is written to; the runtime's Loader reads it. */
+export const IMAGE_CONFIG_PATH = 'config/cordis.yml'
+
+/** Image path of the manifest the runtime reads before it wraps a single module. */
+export const IMAGE_MANIFEST_PATH = 'config/vfs-manifest.json'
+
+/** Home directory under the root; the process shim's `DSH_HOME`/`HOME` default. */
+export const IMAGE_HOME_DIRECTORY = 'home'
+
+/** Working directories the host tree expects to exist, empty. */
+export const IMAGE_EMPTY_DIRECTORIES: readonly string[] = ['home/', 'workspace/', 'tmp/']
+
+/**
+ * Identity of the lowered code shape, recorded in the image manifest by the
+ * packer and required by the worker host: an image lowered by an older transform
+ * would otherwise run against newer wrapper semantics. Bump on any change to
+ * emitted code or to {@link WRAPPER_PARAMS}.
+ */
+export const LOWERING_VERSION = 'dsh-worker-transform/1'
+
+/**
+ * Free variables a lowered body expects from its wrapper, in order.
+ *
+ * Part of the image layout rather than of the transform, because the loader
+ * wraps bodies it never parses: the packer emits against these names and the
+ * worker binds them, with no compiler in the worker bundle to agree with.
+ */
+export const WRAPPER_PARAMS = [
+  'exports', 'require', 'module', '__filename', '__dirname', '__dsh$meta', '__als',
+] as const

+ 44 - 0
packages/experimental/webworker-runtime/src/index.ts

@@ -0,0 +1,44 @@
+/**
+ * Browser-only host runtime: the harness Cordis tree inside a dedicated Web Worker.
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime
+ */
+export {
+  createAlsRuntime,
+  type AlsCausality, type AlsRuntime, type AlsSnapshot, type AlsToken,
+} from './polyfill/async-context/als-runtime.ts'
+export {
+  parseInboundFrame,
+  type TunnelAbortFrame, type TunnelInboundFrame, type TunnelOutboundFrame, type TunnelRequestFrame,
+  type TunnelRequestId, type TunnelResponseChunkFrame, type TunnelResponseEndFrame,
+  type TunnelResponseErrorFrame, type TunnelResponseFrame, type TunnelResponseHeadFrame,
+} from './transport/frames.ts'
+export {
+  DEFAULT_CONDITIONS, requireActiveModuleLoader, setActiveModuleLoader, WorkerModuleLoader,
+  type Resolution, type StaticModuleFactory, type WorkerModuleLoaderOptions, type WorkerRequire,
+} from './module-system/module-loader.ts'
+export * as posixPath from './module-system/posix-path.ts'
+export {
+  createSyntheticExchange,
+  type RequestListener, type ResponseSink, type SyntheticExchange,
+} from './transport/synthetic-http.ts'
+export { lowerModuleSource, type LoweredModule } from './compile/transform.ts'
+export {
+  API_PREFIX, STREAM_PATHS, SYNTHETIC_HOST, TunnelServer,
+  type TunnelPort, type TunnelSeams, type TunnelServerOptions,
+} from './transport/tunnel.ts'
+export { installProcessGlobal, type ProcessShim, type ProcessShimOptions } from './node/globals/process.ts'
+export {
+  createWorkerHost, type WorkerHost, type WorkerHostOptions,
+} from './worker-host.ts'
+export {
+  DEFAULT_ROOT, IMAGE_CONFIG_PATH, IMAGE_EMPTY_DIRECTORIES, IMAGE_FILE_NAME, IMAGE_HOME_DIRECTORY,
+  IMAGE_MANIFEST_PATH, LOWERING_VERSION, WRAPPER_PARAMS,
+} from './image-layout.ts'
+export { loadVfsImage, MemoryVfs } from './storage/memory.ts'
+export { inflateImage, inflateImageStream } from './storage/image-gzip.ts'
+export { packTar, parseTar, type TarEntry } from './storage/tar.ts'
+export { requireActiveVfs, setActiveVfs } from './storage/active.ts'
+export {
+  type VfsDir, type VfsDirent, type VfsEncoding, type VfsError, type VfsFileHandle,
+  type VfsReadOptions, type VfsStats, type VfsWriteOptions,
+} from './storage/types.ts'

+ 32 - 0
packages/experimental/webworker-runtime/src/invariant.ts

@@ -0,0 +1,32 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-experimental-webworker-runtime`.
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-webworker-runtime'
+
+/** Cordis companion plugin name. */
+export const name = 'webworker-runtime-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: this package is pre-Cordis platform glue —
+ * the tree it boots runs the product packages' own invariants, and the
+ * assembly's contracts (image contract gate, tunnel refusals) fail loud at
+ * boot rather than drifting at run time.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 79 - 0
packages/experimental/webworker-runtime/src/module-proxies.ts

@@ -0,0 +1,79 @@
+/**
+ * The worker bundle's module proxy table: the ONLY platform fork of the host
+ * tree. Every entry replaces a Node builtin or an external npm package;
+ * workspace and vendored modules are always mounted as they ship.
+ *
+ * The build turns these into bundler aliases, and `node/builtins.ts` turns the
+ * same modules into the loader's static table — one list, two consumers.
+ *
+ * The replacement path states the classification. `./node/builtin_modules/implemented/<module>.ts`
+ * carries the module's real semantics over a worker-side data source (VFS, the
+ * tunnel, a wasm codec, a browser primitive); `./node/builtin_modules/mock/<module>.ts` is a
+ * structural placeholder that mounts silently and reports the missing capability
+ * when a call finally reaches it. External npm replacements live in
+ * `./externals/`, named after the package they stand in for.
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-proxies
+ */
+
+/**
+ * Module proxy table — the ONLY platform fork of the worker host. Every entry
+ * replaces a Node builtin or an external npm package; workspace and vendored
+ * modules are always mounted as-is. Keys are exact module specifiers.
+ */
+export const MODULE_PROXIES: Record<string, string> = {
+  // VFS-backed real implementations.
+  'node:fs': './node/builtin_modules/implemented/fs.ts',
+  'fs': './node/builtin_modules/implemented/fs.ts',
+  'node:fs/promises': './node/builtin_modules/implemented/fs/promises.ts',
+  'fs/promises': './node/builtin_modules/implemented/fs/promises.ts',
+  'node:path': './node/builtin_modules/implemented/path.ts',
+  'path': './node/builtin_modules/implemented/path.ts',
+  'node:path/posix': './node/builtin_modules/implemented/path.ts',
+  'node:os': './node/builtin_modules/implemented/os.ts',
+  'os': './node/builtin_modules/implemented/os.ts',
+  'node:url': './node/builtin_modules/implemented/url.ts',
+  'node:module': './node/builtin_modules/implemented/module.ts',
+  'node:crypto': './node/builtin_modules/implemented/crypto.ts',
+  'crypto': './node/builtin_modules/implemented/crypto.ts',
+  // `buffer` itself stays unaliased: the shim is backed by that npm package.
+  'node:buffer': './node/builtin_modules/implemented/buffer.ts',
+  // Tunnel request source: fake bind, real route face. `node:process` and
+  // `process` are absent on purpose — the worker host installs that global
+  // (`./globals/process.ts`).
+  'node:http': './node/builtin_modules/implemented/http.ts',
+  // Sync-stack AsyncLocalStorage semantics.
+  'node:async_hooks': './node/builtin_modules/implemented/async_hooks.ts',
+  // Real implementations over browser primitives.
+  'node:util': './node/builtin_modules/implemented/util.ts',
+  'node:util/types': './node/builtin_modules/implemented/util/types.ts',
+  'node:events': './node/builtin_modules/implemented/events.ts',
+  'node:timers/promises': './node/builtin_modules/implemented/timers/promises.ts',
+  'node:perf_hooks': './node/builtin_modules/implemented/perf_hooks.ts',
+  // Real zstd codec: session-log appends compress on every write.
+  'node:zlib': './node/builtin_modules/implemented/zlib.ts',
+  // The worker's own process layer: `bash -c` and the command table run against
+  // the VFS, because a browser worker has no processes to fork.
+  'node:child_process': './node/builtin_modules/implemented/child_process.ts',
+  // Structural mocks: every symbol exists, every call throws.
+  'node:net': './node/builtin_modules/mock/net.ts',
+  'node:stream': './node/builtin_modules/mock/stream.ts',
+  'node:vm': './node/builtin_modules/mock/vm.ts',
+  'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts',
+  'node:sqlite': './node/builtin_modules/mock/sqlite.ts',
+  // External npm replacements, named after the package each stands in for.
+  'koffi': './node/external_packages/koffi.ts',
+  'sharp': './node/external_packages/sharp.ts',
+  'node-pty': './node/external_packages/node-pty.ts',
+  '@vscode/ripgrep': './node/external_packages/ripgrep.ts',
+  '@earendil-works/pi-ai': './node/external_packages/pi-ai.ts',
+  '@deepseek-ai/node-addon-landlock-run': './node/external_packages/node-addon-landlock-run.ts',
+  // Constructible fakes whose methods are never reached.
+  'ws': './node/external_packages/ws.ts',
+  'chokidar': './node/external_packages/chokidar.ts',
+}
+
+
+/** pi-ai subpaths (`/providers/all`, `/api/*.lazy`) share the one structural stub. */
+export const MODULE_PROXY_PREFIXES: Record<string, string> = {
+  '@earendil-works/pi-ai/': './node/external_packages/pi-ai.ts',
+}

+ 407 - 0
packages/experimental/webworker-runtime/src/module-system/module-loader.ts

@@ -0,0 +1,407 @@
+/**
+ * CommonJS module loader over the worker VFS. It fills the `loader.internal`
+ * seam Cordis uses for every entry import, and backs the `node:module`
+ * `createRequire` proxy that `typert-loader` and `client-modules` resolve
+ * package metadata through.
+ *
+ * Resolution is a narrowed Node `require` algorithm: `exports` walk with a
+ * fixed condition order, extension probing, and one cache keyed by resolved
+ * absolute path. Module bodies are wrapped as the image holds them: lowering is
+ * the packer's job, so nothing here parses JavaScript.
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/module-loader
+ */
+import { createAlsRuntime, type AlsCausality, type AlsRuntime } from '../polyfill/async-context/als-runtime.ts'
+import { dirname, fileUrlToPath, isAbsolute, join, pathToFileUrl, resolve as resolvePath } from './posix-path.ts'
+import { WRAPPER_PARAMS } from '../image-layout.ts'
+import type { MemoryVfs } from '../storage/memory.ts'
+
+/** Condition keys honoured in `exports`, in order; `node` is deliberately absent. */
+export const DEFAULT_CONDITIONS = ['browser', 'require', 'import', 'default'] as const
+
+/** Extensions probed when a specifier has no usable one. */
+const EXTENSIONS = ['.js', '.json', '.mjs', '.cjs'] as const
+
+type ExportsField = string | null | readonly ExportsField[] | { readonly [key: string]: ExportsField }
+
+interface PackageManifest {
+  readonly name?: string
+  readonly main?: string
+  readonly exports?: ExportsField
+}
+
+/**
+ * One entry of the static-module table. The loader calls it when a `require`
+ * names that specifier and never before, so resolution alone — `require.resolve`
+ * or `import.meta.resolve` — evaluates nothing. Repeated requires of one
+ * specifier must answer the same module instance: callers depend on class
+ * identity across requires (`instanceof EventEmitter`, `Buffer.isBuffer`), so a
+ * factory that builds its value has to memoize it.
+ * @returns The module object served for that specifier.
+ */
+export type StaticModuleFactory = () => unknown
+
+/** Where a specifier resolved to. */
+export type Resolution =
+  | { readonly kind: 'static'; readonly specifier: string; readonly factory: StaticModuleFactory }
+  | { readonly kind: 'file'; readonly path: string }
+
+interface ModuleRecord {
+  readonly module: { exports: unknown }
+}
+
+/** The `require` function shape the roster consumes through `createRequire`. */
+export interface WorkerRequire {
+  (specifier: string): unknown
+  resolve(specifier: string): string
+}
+
+/** Construction inputs for {@link WorkerModuleLoader}. */
+export interface WorkerModuleLoaderOptions {
+  /** Filesystem holding package metadata and module sources. */
+  readonly vfs: MemoryVfs
+  /** Virtual root whose `node_modules` bare specifiers resolve against. */
+  readonly root?: string
+  /**
+   * Modules served from the worker bundle instead of the VFS: `node:*` proxies
+   * and the loud stubs for excluded npm packages, each behind a
+   * {@link StaticModuleFactory}.
+   */
+  readonly staticModules: Readonly<Record<string, StaticModuleFactory>>
+  /**
+   * Prefix-matched proxies for packages whose subpaths are open-ended: a
+   * specifier starting with the key resolves to its module. Exact keys win, and
+   * the longest matching prefix wins among prefixes.
+   */
+  readonly staticModulePrefixes?: Readonly<Record<string, StaticModuleFactory>>
+  /** Overrides {@link DEFAULT_CONDITIONS}. */
+  readonly conditions?: readonly string[]
+  /**
+   * Ambient-store snapshot face for the suspended `rewrite-await` route; it is
+   * read only when that route is the configured {@link lowering}.
+   */
+  readonly alsCausality?: AlsCausality
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Loader for one VFS mount; construct once per worker. */
+export class WorkerModuleLoader {
+  private readonly vfs: MemoryVfs
+  private readonly root: string
+  private readonly staticModules: ReadonlyMap<string, StaticModuleFactory>
+  private readonly staticPrefixes: ReadonlyArray<readonly [string, StaticModuleFactory]>
+  private readonly conditions: ReadonlySet<string>
+  private readonly als: AlsRuntime
+  private readonly modules = new Map<string, ModuleRecord>()
+  private readonly manifests = new Map<string, PackageManifest>()
+  private readonly stack: string[] = []
+
+  /**
+   * The Cordis module seam. `parentURL` positions relative specifiers;
+   * import attributes are ignored, as the client implementation does.
+   */
+  readonly internal: {
+    readonly version: 'worker'
+    import(specifier: string, parentURL?: string, attributes?: unknown): Promise<unknown>
+  }
+
+  constructor(options: WorkerModuleLoaderOptions) {
+    this.vfs = options.vfs
+    this.root = options.root ?? '/dsh'
+    // A Map, not the record itself: a specifier that names an Object prototype
+    // member must miss the table the way any other unregistered name does.
+    this.staticModules = new Map(Object.entries(options.staticModules))
+    this.staticPrefixes = Object.entries(options.staticModulePrefixes ?? {})
+      .sort(([left], [right]) => right.length - left.length)
+    this.conditions = new Set(options.conditions ?? DEFAULT_CONDITIONS)
+    this.als = createAlsRuntime(options.alsCausality)
+    this.internal = {
+      version: 'worker',
+      import: async (specifier: string, parentURL?: string): Promise<unknown> => {
+        const from = parentURL === undefined ? this.root : this.baseDirectoryOf(parentURL)
+        return this.load(this.resolve(specifier, from))
+      },
+    }
+  }
+
+  private fail(detail: string): never {
+    const chain = this.stack.length === 0 ? '' : ` (importer chain: ${this.stack.join(' -> ')})`
+    throw new Error(`webworker modules: ${detail}${chain}`)
+  }
+
+  /** @returns Directory a base path or URL resolves specifiers from. */
+  private baseDirectoryOf(base: string | URL): string {
+    const text = typeof base === 'string' ? base : base.href
+    const path = text.startsWith('file://') ? fileUrlToPath(text) : text
+    if (path.endsWith('/')) return resolvePath(path)
+    return this.vfs.existsSync(path) && this.vfs.statSync(path).isDirectory() ? resolvePath(path) : dirname(path)
+  }
+
+  private manifestOf(directory: string): PackageManifest {
+    const cached = this.manifests.get(directory)
+    if (cached !== undefined) return cached
+    const path = join(directory, 'package.json')
+    const text = this.vfs.readFileSync(path, 'utf8') as string
+    let parsed: unknown
+    try {
+      parsed = JSON.parse(text)
+    } catch (reason) {
+      this.fail(`${path} is not valid JSON: ${(reason as Error).message}`)
+    }
+    if (!isRecord(parsed)) this.fail(`${path} does not hold an object`)
+    const manifest = parsed as PackageManifest
+    this.manifests.set(directory, manifest)
+    return manifest
+  }
+
+  /** Walk one `exports` value against the condition set and requested subpath. */
+  private selectExport(field: ExportsField, subpath: string, packageName: string): string | undefined {
+    if (field === null) return undefined
+    if (typeof field === 'string') return subpath === '.' ? field : undefined
+    if (Array.isArray(field)) {
+      for (const candidate of field as readonly ExportsField[]) {
+        const picked = this.selectExport(candidate, subpath, packageName)
+        if (picked !== undefined) return picked
+      }
+      return undefined
+    }
+    const entries = Object.entries(field as { [key: string]: ExportsField })
+    const isSubpathMap = entries.some(([key]) => key === '.' || key.startsWith('./'))
+    if (!isSubpathMap) {
+      if (subpath !== '.') return undefined
+      return this.selectCondition(field, packageName)
+    }
+    for (const [key, value] of entries) {
+      if (key === subpath) {
+        return typeof value === 'string' ? value : this.selectCondition(value, packageName, subpath)
+      }
+    }
+    for (const [key, value] of entries) {
+      const star = key.indexOf('*')
+      if (star < 0) continue
+      const prefix = key.slice(0, star)
+      const suffix = key.slice(star + 1)
+      if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue
+      const captured = subpath.slice(prefix.length, subpath.length - suffix.length)
+      const target = typeof value === 'string' ? value : this.selectCondition(value, packageName, subpath)
+      if (target !== undefined) return target.replaceAll('*', captured)
+    }
+    return undefined
+  }
+
+  /** Pick the first condition branch this runtime satisfies. */
+  private selectCondition(field: ExportsField, packageName: string, subpath = '.'): string | undefined {
+    if (field === null) return undefined
+    if (typeof field === 'string') return field
+    if (Array.isArray(field)) {
+      for (const candidate of field as readonly ExportsField[]) {
+        const picked = this.selectCondition(candidate, packageName, subpath)
+        if (picked !== undefined) return picked
+      }
+      return undefined
+    }
+    for (const [key, value] of Object.entries(field as { [key: string]: ExportsField })) {
+      if (!this.conditions.has(key)) continue
+      const picked = this.selectCondition(value, packageName, subpath)
+      if (picked !== undefined) return picked
+    }
+    return undefined
+  }
+
+  /** Extension and directory probing for a concrete path. */
+  private probe(path: string, specifier: string): string {
+    const candidates: string[] = [path, ...EXTENSIONS.map(extension => path + extension)]
+    for (const candidate of candidates) {
+      if (this.vfs.existsSync(candidate) && this.vfs.statSync(candidate).isFile()) return candidate
+    }
+    if (this.vfs.existsSync(path) && this.vfs.statSync(path).isDirectory()) {
+      if (this.vfs.existsSync(join(path, 'package.json'))) {
+        const main = this.manifestOf(path).main
+        if (main !== undefined) return this.probe(join(path, main), specifier)
+      }
+      return this.probe(join(path, 'index'), specifier)
+    }
+    return this.fail(`cannot resolve "${specifier}": no file at ${candidates.join(', ')}`)
+  }
+
+  /**
+   * Resolve a specifier the way the module that requested it would.
+   * @param specifier - Bare name, relative path, absolute path, or file URL.
+   * @param fromDirectory - Directory of the requesting module.
+   * @returns Static module or the resolved VFS path.
+   */
+  resolve(specifier: string, fromDirectory: string): Resolution {
+    const exact = this.staticModules.get(specifier)
+    if (exact !== undefined) return { kind: 'static', specifier, factory: exact }
+    for (const [prefix, factory] of this.staticPrefixes) {
+      if (specifier.startsWith(prefix)) return { kind: 'static', specifier, factory }
+    }
+    if (specifier.startsWith('cordis:') || specifier.startsWith('node:')) {
+      return this.fail(`no static module is registered for "${specifier}"`)
+    }
+    if (specifier.startsWith('file://')) {
+      return { kind: 'file', path: this.probe(fileUrlToPath(specifier), specifier) }
+    }
+    if (specifier.startsWith('.')) {
+      return { kind: 'file', path: this.probe(join(fromDirectory, specifier), specifier) }
+    }
+    if (isAbsolute(specifier)) {
+      return { kind: 'file', path: this.probe(specifier, specifier) }
+    }
+    // Node resolves `fs` and `node:fs` to the same builtin; the proxy table may register either.
+    const prefixed = this.staticModules.get(`node:${specifier}`)
+    if (prefixed !== undefined) return { kind: 'static', specifier, factory: prefixed }
+    const segments = specifier.split('/')
+    const packageName = specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0] ?? specifier
+    const rest = specifier.slice(packageName.length).replace(/^\//, '')
+    const packageDirectory = join(this.root, 'node_modules', packageName)
+    if (!this.vfs.existsSync(join(packageDirectory, 'package.json'))) {
+      return this.fail(`cannot resolve "${specifier}": ${packageDirectory}/package.json is not in the image`)
+    }
+    const manifest = this.manifestOf(packageDirectory)
+    const subpath = rest === '' ? '.' : `./${rest}`
+    if (manifest.exports !== undefined) {
+      const target = this.selectExport(manifest.exports, subpath, packageName)
+      if (target === undefined) {
+        return this.fail(`"${packageName}" does not export "${subpath}" under conditions [${[...this.conditions].join(', ')}]`)
+      }
+      return { kind: 'file', path: this.probe(join(packageDirectory, target), specifier) }
+    }
+    const legacy = subpath === '.' ? manifest.main ?? 'index.js' : rest
+    return { kind: 'file', path: this.probe(join(packageDirectory, legacy), specifier) }
+  }
+
+  /**
+   * Load a resolved module, reusing the cache and tolerating cycles with
+   * CommonJS partial-export semantics.
+   * @param resolution - Result of {@link resolve}.
+   * @returns The module's exports.
+   */
+  load(resolution: Resolution): unknown {
+    if (resolution.kind === 'static') return resolution.factory()
+    const path = resolution.path
+    const cached = this.modules.get(path)
+    if (cached !== undefined) return cached.module.exports
+    if (path.endsWith('.json')) {
+      const parsed: unknown = JSON.parse(this.vfs.readFileSync(path, 'utf8') as string)
+      this.modules.set(path, { module: { exports: parsed } })
+      return parsed
+    }
+    const exports: Record<string, unknown> = {}
+    const record: ModuleRecord = { module: { exports } }
+    this.modules.set(path, record)
+    this.stack.push(path)
+    try {
+      const source = this.vfs.readFileSync(path, 'utf8') as string
+      const factory = this.compile(source, path)
+      const directory = dirname(path)
+      factory(
+        record.module.exports,
+        this.requireFrom(directory),
+        record.module,
+        path,
+        directory,
+        {
+          url: pathToFileUrl(path),
+          // Node parity for the lowered `import.meta` face: a path resolution
+          // answers a file URL; a static (built-in or proxied) module answers
+          // its own specifier, the way Node echoes `node:*` back.
+          resolve: (specifier: string): string => {
+            const resolution = this.resolve(specifier, directory)
+            return resolution.kind === 'static' ? resolution.specifier : pathToFileUrl(resolution.path)
+          },
+        },
+        this.als,
+      )
+      return record.module.exports
+    } catch (reason) {
+      this.modules.delete(path)
+      throw reason
+    } finally {
+      this.stack.pop()
+    }
+  }
+
+  /**
+   * Compile a body the image already lowered.
+   *
+   * Module syntax reaching here means the image was packed by something other
+   * than the packer, or its collector missed the entry. The worker carries no
+   * transform to recover with, so it names the image as the thing to rebuild.
+   * @param code - Module body as the image holds it.
+   * @param path - Resolved VFS path.
+   * @returns The wrapper factory.
+   */
+  private compile(code: string, path: string): (...args: unknown[]) => void {
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-implied-eval -- wrapping an image body is this loader's job
+      return new Function(...WRAPPER_PARAMS, code) as (...args: unknown[]) => void
+    } catch (reason) {
+      if (reason instanceof SyntaxError && /await/i.test(reason.message)) {
+        this.fail(`${path} uses top-level await, which cannot run as CommonJS in the worker: ${reason.message}`)
+      }
+      if (reason instanceof SyntaxError && /import|export/i.test(reason.message)) {
+        this.fail(`${path} still carries module syntax, so the image was not lowered by the packer `
+          + `(${reason.message}); rebuild the image`)
+      }
+      this.fail(`${path} failed to compile: ${(reason as Error).message}`)
+    }
+  }
+
+  /**
+   * Build a `require` bound to a directory.
+   * @param fromDirectory - Directory relative specifiers resolve against.
+   * @returns Callable require with `resolve`.
+   */
+  requireFrom(fromDirectory: string): WorkerRequire {
+    const require = ((specifier: string): unknown => this.load(this.resolve(specifier, fromDirectory))) as WorkerRequire
+    require.resolve = (specifier: string): string => {
+      const resolution = this.resolve(specifier, fromDirectory)
+      if (resolution.kind === 'static') {
+        return this.fail(`"${specifier}" is a worker-provided module and has no VFS path`)
+      }
+      return resolution.path
+    }
+    return require
+  }
+
+  /**
+   * `node:module` `createRequire` for the VFS.
+   * @param base - Module path, directory path, or `file:` URL.
+   * @returns Require bound to that base.
+   */
+  createRequire(base: string | URL): WorkerRequire {
+    return this.requireFrom(this.baseDirectoryOf(base))
+  }
+
+  /**
+   * Report what this loader has done, for the host's boot diagnostics.
+   * @returns How many module bodies it has run.
+   */
+  usage(): { modules: number } {
+    return { modules: this.modules.size }
+  }
+}
+
+let active: WorkerModuleLoader | undefined
+
+/**
+ * Publish the loader the `node:module` proxy resolves through.
+ * @param loader - Loader built by the worker entry.
+ */
+export function setActiveModuleLoader(loader: WorkerModuleLoader): void {
+  active = loader
+}
+
+/**
+ * Read the published loader.
+ * @returns The active loader.
+ */
+export function requireActiveModuleLoader(): WorkerModuleLoader {
+  if (active === undefined) {
+    throw new Error('webworker modules: no loader is mounted; the worker entry must call setActiveModuleLoader before any createRequire use')
+  }
+  return active
+}

+ 169 - 0
packages/experimental/webworker-runtime/src/module-system/posix-path.ts

@@ -0,0 +1,169 @@
+/**
+ * POSIX path helpers for the worker VFS: one absolute root, no drive letters,
+ * no symlinks.
+ *
+ * **Not a `node:path` substitute.** {@link dirname}, {@link basename}, and
+ * {@link parse} normalize first, because every caller here hands the result to
+ * the VFS, which keys files by normalized absolute path — `dirname('/a/b/..')`
+ * answers `/`, the directory that actually holds the entry. Node's three are
+ * purely lexical and answer `/a/b`. A `node:path` proxy owes callers Node's
+ * literal answers, so it needs its own port of Node's implementation rather than
+ * a facade over this module (`apps/web-preview` keeps one; the divergence covers
+ * 45 of ~200 cases, all in these three functions).
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/module-system/posix-path
+ */
+
+/** Path separator of the virtual filesystem. */
+export const SEP = '/'
+
+/**
+ * Collapse `.` and `..` segments.
+ * @param path - Path with any number of separators.
+ * @returns Normalized path; a relative input keeps leading `..` segments.
+ */
+export function normalize(path: string): string {
+  const absolute = path.startsWith(SEP)
+  const trailing = path.length > 1 && path.endsWith(SEP)
+  const out: string[] = []
+  for (const segment of path.split(SEP)) {
+    if (segment === '' || segment === '.') continue
+    if (segment === '..' && out.length > 0 && out[out.length - 1] !== '..') {
+      out.pop()
+      continue
+    }
+    if (segment === '..' && absolute) continue
+    out.push(segment)
+  }
+  const body = out.join(SEP)
+  if (absolute) return SEP + body + (trailing && body !== '' ? SEP : '')
+  if (body === '') return trailing ? './' : '.'
+  return body + (trailing ? SEP : '')
+}
+
+/**
+ * Join segments and normalize the result.
+ * @param segments - Path segments.
+ * @returns Joined path, `.` when nothing remains.
+ */
+export function join(...segments: string[]): string {
+  const joined = segments.filter(segment => segment !== '').join(SEP)
+  return joined === '' ? '.' : normalize(joined)
+}
+
+/**
+ * Resolve segments right to left against a base directory.
+ * @param segments - Path segments; the first absolute one wins.
+ * @returns Absolute normalized path.
+ */
+export function resolve(...segments: string[]): string {
+  let path = ''
+  for (const segment of [...segments].reverse()) {
+    if (segment === '') continue
+    path = path === '' ? segment : `${segment}${SEP}${path}`
+    if (segment.startsWith(SEP)) break
+  }
+  return normalize(path.startsWith(SEP) ? path : `${SEP}${path}`)
+}
+
+/**
+ * Directory part of a path, after normalization (see the module note).
+ * @param path - Path to inspect.
+ * @returns Parent path; `/` for root children and `.` for bare names.
+ */
+export function dirname(path: string): string {
+  const normalized = normalize(path).replace(/\/+$/, '')
+  const index = normalized.lastIndexOf(SEP)
+  if (index < 0) return '.'
+  if (index === 0) return SEP
+  return normalized.slice(0, index)
+}
+
+/**
+ * Last segment of a path, after normalization (see the module note).
+ * @param path - Path to inspect.
+ * @param suffix - Optional suffix to strip.
+ * @returns Final segment.
+ */
+export function basename(path: string, suffix?: string): string {
+  const normalized = normalize(path).replace(/\/+$/, '')
+  const name = normalized.slice(normalized.lastIndexOf(SEP) + 1)
+  if (suffix !== undefined && suffix !== name && name.endsWith(suffix)) return name.slice(0, -suffix.length)
+  return name
+}
+
+/**
+ * Extension of the last segment, dot included.
+ * @param path - Path to inspect.
+ * @returns Extension, or an empty string when there is none.
+ */
+export function extname(path: string): string {
+  const name = basename(path)
+  const index = name.lastIndexOf('.')
+  return index <= 0 ? '' : name.slice(index)
+}
+
+/**
+ * Report whether a path starts at the root.
+ * @param path - Path to inspect.
+ * @returns True for absolute paths.
+ */
+export function isAbsolute(path: string): boolean {
+  return path.startsWith(SEP)
+}
+
+/**
+ * Relative path from one absolute path to another.
+ * @param from - Source directory.
+ * @param to - Target path.
+ * @returns Relative path using `..` segments.
+ */
+export function relative(from: string, to: string): string {
+  const source = resolve(from).split(SEP).filter(segment => segment !== '')
+  const target = resolve(to).split(SEP).filter(segment => segment !== '')
+  let shared = 0
+  while (shared < source.length && shared < target.length && source[shared] === target[shared]) shared += 1
+  const up = new Array(source.length - shared).fill('..') as string[]
+  return [...up, ...target.slice(shared)].join(SEP)
+}
+
+/**
+ * Split a path into components, after normalization (see the module note).
+ * @param path - Path to split.
+ * @returns Root, directory, base name, extension, and stem.
+ */
+export function parse(path: string): { root: string; dir: string; base: string; ext: string; name: string } {
+  const root = isAbsolute(path) ? SEP : ''
+  const base = basename(path)
+  const ext = extname(path)
+  return { root, dir: dirname(path), base, ext, name: ext === '' ? base : base.slice(0, -ext.length) }
+}
+
+/**
+ * Node's Windows-only namespaced-path conversion.
+ * @param path - the path to convert.
+ * @returns The path unchanged; namespaced paths are a Windows concept.
+ */
+export function toNamespacedPath(path: string): string {
+  return path
+}
+
+/**
+ * Convert a VFS path into a `file:` URL string.
+ * @param path - Absolute VFS path.
+ * @returns URL text with each segment percent-encoded.
+ */
+export function pathToFileUrl(path: string): string {
+  const absolute = resolve(path)
+  return `file://${absolute.split(SEP).map(segment => encodeURIComponent(segment)).join(SEP)}`
+}
+
+/**
+ * Convert a `file:` URL back into a VFS path.
+ * @param url - URL text or URL instance.
+ * @returns Absolute VFS path.
+ */
+export function fileUrlToPath(url: string | URL): string {
+  const text = typeof url === 'string' ? url : url.href
+  if (!text.startsWith('file://')) throw new Error(`webworker vfs: not a file URL: ${text}`)
+  return decodeURIComponent(text.slice('file://'.length).replace(/[?#].*$/, '')) || SEP
+}

+ 406 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/async_hooks.ts

@@ -0,0 +1,406 @@
+/**
+ * `node:async_hooks` for the worker: `AsyncLocalStorage` over an EXPLICIT-SWITCH
+ * model with two fallbacks. A browser has no async-context tracking, so the store
+ * a read answers is decided by three slots, in this order:
+ *
+ * 1. HOOK OVERLAY — set for the duration of one callback by the hook layer
+ *    (`./async-context-hooks.ts`), which captures the context where a callback was
+ *    REGISTERED (`.then`, `queueMicrotask`, timers, `fetch`) and restores it where
+ *    the callback RUNS.
+ * 2. RESUMED CONTEXT — the explicit-switch slot. {@link __snapshotAll} copies every
+ *    live instance's effective store and {@link __restoreAll} publishes a copy; the
+ *    module loader's `await` rewriting pauses with the first and resumes with the
+ *    second, which is what makes attribution causally correct across an `await`
+ *    even while another chain interleaves. The rewriter's `restore` returns nothing,
+ *    so this slot holds ONE value per instance and a resume REPLACES it rather than
+ *    stacking: a frame that resumes again at its next await re-publishes its own
+ *    context anyway, and a new `run()` boundary shadows the slot for its extent.
+ *    (Callers that want scoping get a disposer back from {@link __restoreAll}.)
+ * 2b. BOUNDARY AMBIENT — `run()` also publishes its own store here, so rewritten and
+ *    un-rewritten code agree on what the innermost boundary is.
+ * 3. FOLDING STACK — the fallback for code the rewriter has not touched: `run()`
+ *    pushes an entry that is removed synchronously for a synchronous operation, or
+ *    when the returned promise settles for an asynchronous one, so a store stays
+ *    visible across `await` inside that operation.
+ *
+ * Every slot is removed by IDENTITY, never blindly: boundaries settle and frames
+ * resume out of order, so a blind pop would drop somebody else's context — and a
+ * slot that is released while shadowed must leave the chain without promoting
+ * itself back over whoever came after it. The three slots are separate for the same reason — a restored
+ * copy pushed onto the folding stack could unwind another boundary's entry.
+ *
+ * A snapshot with no stores at all is `undefined`, and the hook layer then wraps
+ * nothing: a callback registered outside every boundary keeps inheriting the
+ * enclosing boundary rather than being masked to `undefined`. `__snapshotAll` is
+ * the transformer-facing counterpart and always captures every instance, including
+ * the ones reading `undefined`, because a resumed frame must see exactly what it
+ * saw at its pause point.
+ *
+ * BOUNDARY (structural, documented rather than worked around): native
+ * `async`/`await` resumption inside code the rewriter has NOT transformed is
+ * invisible to user code. Such a frame falls back to the folding stack, which is
+ * ordered by nesting rather than by causal chain, so two boundaries overlapping
+ * there can attribute to the wrong one. Nothing crashes, the stacks still unwind by
+ * identity, and everything the hook layer or the rewriter covers is exact.
+ */
+import { notImplementedFail } from '../../notImplementedFail.ts'
+
+interface Entry<T> {
+  readonly store: T | undefined
+}
+
+interface Overlay<T> {
+  readonly store: T | undefined
+}
+
+/** Pristine `then`, so this module's own bookkeeping never re-enters the hook layer. */
+// eslint-disable-next-line @typescript-eslint/unbound-method -- taking `then` unbound is the point; it is `.call`ed on its own promise
+const nativeThen = Promise.prototype.then
+
+/** Every live instance, so one snapshot can capture all of their stores at once. */
+const instances = new Set<AsyncLocalStorage<unknown>>()
+
+function isThenable(value: unknown): value is PromiseLike<unknown> {
+  if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
+  return typeof (value as { then?: unknown }).then === 'function'
+}
+
+/** Node's AsyncLocalStorage face, restricted to the members the host tree uses. */
+export class AsyncLocalStorage<T> {
+  private readonly entries: Entry<T>[] = []
+  private overlay: Overlay<T> | undefined
+  private readonly ambients: Overlay<T>[] = []
+  private resumed: Overlay<T> | undefined
+
+  constructor() {
+    instances.add(this)
+  }
+
+  /**
+   * Run a callback with the store visible for the operation's whole lifetime:
+   * until it returns, or until the promise it returned settles.
+   * @param store - value {@link getStore} answers inside the boundary.
+   * @param callback - the operation.
+   * @param args - callback arguments.
+   * @returns the exact value the callback returned.
+   */
+  run<R>(store: T | undefined, callback: (...args: never[]) => R, ...args: never[]): R {
+    const entry: Entry<T> = { store }
+    this.entries.push(entry)
+    // Removal is by entry identity: overlapping boundaries settle out of order,
+    // and a blind pop would drop somebody else's entry.
+    const remove = (): void => {
+      const at = this.entries.lastIndexOf(entry)
+      if (at !== -1) this.entries.splice(at, 1)
+    }
+    // The boundary also publishes an ambient slot until its entry goes away.
+    // Removal is by identity here too: a shadowed slot must leave the chain
+    // without promoting itself back over whoever came after it.
+    const ambient: Overlay<T> = { store }
+    this.ambients.push(ambient)
+    const removeBoundary = (): void => {
+      const at = this.ambients.lastIndexOf(ambient)
+      if (at !== -1) this.ambients.splice(at, 1)
+      if (this.resumed === undefined) this.resumed = restoreResumed
+      remove()
+    }
+    // A boundary opened under an overlay or a resumed context (a hook-restored
+    // callback, or a rewritten frame, that starts a new run) must not keep reading
+    // them: its own entry is the truth.
+    const restoreOverlay = this.overlay
+    const restoreResumed = this.resumed
+    this.overlay = undefined
+    this.resumed = undefined
+    let result: R
+    try {
+      result = callback(...args)
+    } catch (error) {
+      this.overlay = restoreOverlay
+      removeBoundary()
+      throw error
+    }
+    this.overlay = restoreOverlay
+    if (!isThenable(result)) {
+      removeBoundary()
+      return result
+    }
+    try {
+      // `then.call` on the caller's own promise: no species construction, and the
+      // rejection stays the caller's to observe (both handlers are attached, so
+      // this observation never becomes an unhandled rejection itself).
+      void nativeThen.call(result, removeBoundary, removeBoundary)
+    } catch {
+      // A branded promise may expose a failing @@species; the boundary then ends
+      // here rather than leaking an entry that nothing would ever remove.
+      removeBoundary()
+    }
+    return result
+  }
+
+  /**
+   * Current store, resolved through the slot order this module documents: the
+   * hook-restored overlay, then the ambient context a resume installed (or a
+   * boundary owns), then the folding stack's innermost entry.
+   * @returns the store, or undefined outside every boundary.
+   */
+  getStore(): T | undefined {
+    if (this.overlay !== undefined) return this.overlay.store
+    if (this.resumed !== undefined) return this.resumed.store
+    const ambient = this.ambients.at(-1)
+    if (ambient !== undefined) return ambient.store
+    return this.entries.at(-1)?.store
+  }
+
+  /**
+   * Run a callback with no store, folding over its lifetime like {@link run}.
+   * @param callback - the operation.
+   * @param args - callback arguments.
+   * @returns the exact value the callback returned.
+   */
+  exit<R>(callback: (...args: never[]) => R, ...args: never[]): R {
+    return this.run(undefined, callback, ...args)
+  }
+
+  /**
+   * Enter a boundary that lasts until {@link disable}, as Node's `enterWith` does
+   * for the remainder of the current chain.
+   * @param store - value {@link getStore} answers from now on.
+   */
+  enterWith(store: T): void {
+    this.entries.push({ store })
+  }
+
+  /** Drop every slot; teardown calls this unconditionally. */
+  disable(): void {
+    this.entries.length = 0
+    this.overlay = undefined
+    this.ambients.length = 0
+    this.resumed = undefined
+  }
+
+  /**
+   * Copy every live instance's effective store, including the instances reading
+   * `undefined`: a resumed frame must see exactly what its pause point saw.
+   * @returns the ambient snapshot.
+   */
+  static snapshotAll(): AmbientSnapshot {
+    return [...instances].map(instance => ({ instance, store: instance.getStore() }))
+  }
+
+  /**
+   * Install a snapshot as the ambient context of every instance it names.
+   * @param snapshot - a copy from {@link snapshotAll}.
+   * @returns a disposer that restores the previous ambients, identity-checked.
+   */
+  static restoreAll(snapshot: AmbientSnapshot): () => void {
+    const installed = snapshot.map(({ instance, store }) => {
+      const slot = { store }
+      const before = instance.resumed
+      instance.resumed = slot
+      return { instance, slot, before }
+    })
+    return () => {
+      for (const { instance, slot, before } of installed) {
+        if (instance.resumed === slot) instance.resumed = before
+      }
+    }
+  }
+
+  /**
+   * Copy every live instance's current store. Not part of the Node face: this is
+   * the shim's own mechanism, kept in the class so the overlay stays private.
+   * @returns the snapshot, or undefined when no instance has a store.
+   */
+  static captureContext(): AsyncContextSnapshot | undefined {
+    let captured: CapturedStore[] | undefined
+    for (const instance of instances) {
+      const store = instance.getStore()
+      if (store === undefined) continue
+      captured ??= []
+      captured.push({ instance, store })
+    }
+    return captured
+  }
+
+  /**
+   * Run a callback with a captured context restored into the overlay slots.
+   * @param snapshot - context copy, or undefined to run unchanged.
+   * @param callback - the callback.
+   * @returns the callback's return value.
+   */
+  static runWithContext<R>(snapshot: AsyncContextSnapshot | undefined, callback: () => R): R {
+    if (snapshot === undefined) return callback()
+    const previous = snapshot.map(({ instance, store }) => {
+      const before = instance.overlay
+      instance.overlay = { store }
+      return { instance, before }
+    })
+    try {
+      return callback()
+    } finally {
+      for (const { instance, before } of previous) instance.overlay = before
+    }
+  }
+
+  /**
+   * Every live instance, for {@link runAtAsyncContextRoot}.
+   * @returns The stores a snapshot must capture.
+   */
+  static liveInstances(): readonly AsyncLocalStorage<unknown>[] {
+    return [...instances]
+  }
+
+  /**
+   * Bind a callback to the current context.
+   * @param callback - the callback to bind.
+   * @returns a callback that restores this context when invoked.
+   */
+  static bind<F extends (...args: never[]) => unknown>(callback: F): F {
+    return bindAsyncContext(callback)
+  }
+
+  /**
+   * Snapshot helper matching Node's static: run a callback in the context
+   * captured now.
+   * @returns a function that runs its argument in the captured context.
+   */
+  static snapshot(): <R>(callback: () => R) => R {
+    const snapshot = AsyncLocalStorage.captureContext()
+    return callback => AsyncLocalStorage.runWithContext(snapshot, callback)
+  }
+}
+
+/** One instance's captured store. */
+interface CapturedStore {
+  readonly instance: AsyncLocalStorage<unknown>
+  readonly store: unknown
+}
+
+/** Opaque context copy produced by {@link captureAsyncContext}. */
+export type AsyncContextSnapshot = readonly CapturedStore[]
+
+/** Opaque ambient copy produced by {@link __snapshotAll}; covers every live instance. */
+export type AmbientSnapshot = readonly CapturedStore[]
+
+/**
+ * Copy every live instance's current store.
+ * @returns the snapshot, or undefined when no instance has a store (the hook
+ * layer then wraps nothing and callbacks inherit the stack top).
+ */
+export function captureAsyncContext(): AsyncContextSnapshot | undefined {
+  return AsyncLocalStorage.captureContext()
+}
+
+/**
+ * Run a callback with a captured context restored into the overlay slots.
+ * @param snapshot - context copy, or undefined to run unchanged.
+ * @param callback - the callback.
+ * @returns the callback's return value.
+ */
+export function runWithAsyncContext<R>(snapshot: AsyncContextSnapshot | undefined, callback: () => R): R {
+  return AsyncLocalStorage.runWithContext(snapshot, callback)
+}
+
+/**
+ * Capture the current context now and restore it around every later invocation.
+ * @param callback - the callback to bind.
+ * @returns the bound callback, or the original when no context is active.
+ */
+export function bindAsyncContext<F extends (...args: never[]) => unknown>(callback: F): F {
+  const snapshot = captureAsyncContext()
+  if (snapshot === undefined) return callback
+  const bound = (...args: never[]): unknown => runWithAsyncContext(snapshot, () => callback(...args))
+  return bound as F
+}
+
+/**
+ * Run a callback at the root: every instance reads `undefined`, whatever was open
+ * before. The tunnel's message entry uses this so a queued request never inherits
+ * a boundary from unrelated work that happened to run first.
+ * @param callback - the callback.
+ * @returns the callback's return value.
+ */
+export function runAtAsyncContextRoot<R>(callback: () => R): R {
+  const root: CapturedStore[] = AsyncLocalStorage.liveInstances().map(instance => ({ instance, store: undefined }))
+  return runWithAsyncContext(root, callback)
+}
+
+/**
+ * Pause point of the loader's `await` rewriting: copy the context every live
+ * instance currently reads.
+ *
+ * The transformed module reaches this through the module proxy table
+ * (`require('node:async_hooks').__snapshotAll()`), so the rewriter needs no
+ * additional plumbing.
+ * @returns the ambient snapshot to hand to {@link __restoreAll} after the await.
+ */
+export function __snapshotAll(): AmbientSnapshot {
+  return AsyncLocalStorage.snapshotAll()
+}
+
+/**
+ * Resume point of the loader's `await` rewriting: publish a paused context as the
+ * ambient one, so reads after the await answer what the frame saw before it —
+ * even while another chain interleaves.
+ * @param snapshot - the copy {@link __snapshotAll} produced at the pause point.
+ * @returns a disposer that restores the previous ambient context, identity-checked;
+ * a rewriter that wraps a whole function body calls it in that body's `finally`.
+ */
+export function __restoreAll(snapshot: AmbientSnapshot): () => void {
+  return AsyncLocalStorage.restoreAll(snapshot)
+}
+
+/**
+ * Snapshot face the module loader's `await` rewriting consumes (its `AlsCausality`):
+ * the same pair as {@link __snapshotAll}/{@link __restoreAll}, with `restore`
+ * narrowed to void because the rewritten code has no place to keep a disposer.
+ */
+export const alsCausality = {
+  snapshot: (): AmbientSnapshot => __snapshotAll(),
+  restore: (snapshot: AmbientSnapshot): void => { __restoreAll(snapshot) },
+}
+
+/**
+ * Async ids are not tracked; a stable id keeps callers that log it working.
+ * @returns Always 1.
+ */
+export function executionAsyncId(): number {
+  return 1
+}
+
+/**
+ * Trigger ids are not tracked either.
+ * @returns Always 0.
+ */
+export function triggerAsyncId(): number {
+  return 0
+}
+
+/**
+ * Async hooks cannot be created: no async resource tracking exists in the worker.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function createHook(): never {
+  throw new Error('web-preview: node:async_hooks.createHook is not available in the worker host')
+}
+
+/** Resource construction is likewise unavailable. */
+export const AsyncResource: typeof import('node:async_hooks').AsyncResource
+  = notImplementedFail('node:async_hooks', 'AsyncResource')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:async_hooks` declarations this module stands in for.
+ * `AsyncLocalStorage` keeps this module's own class: it carries the store
+ * bookkeeping the rewrite route reads through statics Node does not declare, and
+ * its `run` is typed for the callback arguments the host tree passes.
+ */
+type NodeFace = Partial<Omit<typeof import('node:async_hooks'), 'AsyncLocalStorage'>>
+  & Record<'AsyncLocalStorage', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  AsyncLocalStorage, AsyncResource, executionAsyncId, triggerAsyncId, createHook,
+} satisfies NodeFace

+ 28 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/buffer.ts

@@ -0,0 +1,28 @@
+/**
+ * `node:buffer` for the worker, backed by the `buffer` npm package (feross), and
+ * the matching `globalThis.Buffer` install. Node code treats Buffer as ambient,
+ * so the global must exist before any host module evaluates.
+ */
+import { Buffer, kMaxLength } from 'buffer'
+
+Object.defineProperty(globalThis, 'Buffer', { value: Buffer, writable: true, configurable: true })
+
+export { Buffer, kMaxLength }
+
+/**
+ * Size limits, as `node:buffer` publishes them. The npm package exposes only
+ * `kMaxLength`, so the string bound is Node's own value for a 64-bit build.
+ */
+export const constants = {
+  MAX_LENGTH: kMaxLength,
+  MAX_STRING_LENGTH: 536_870_888,
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** The `node:buffer` declarations this module stands in for. */
+type NodeFace = Partial<typeof import('node:buffer')>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { Buffer, constants, kMaxLength } satisfies NodeFace

+ 398 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/child_process.ts

@@ -0,0 +1,398 @@
+/**
+ * `node:child_process` over the worker's own shell.
+ *
+ * A browser worker cannot fork, so this module IS the machine's process layer:
+ * `spawn` starts the argv as a shell process (`src/shell/process/`) — its own
+ * Web Worker, off this thread — and reports it through the `ChildProcess`
+ * surface the subprocess service consumes: pipes, `exit`/`close`, pid, and
+ * signals, with `SIGKILL` terminating the worker for real. The command table
+ * is the only `/bin` that exists, so a name it does not hold fails with
+ * `ENOENT`, exactly as a missing binary does on a real host.
+ *
+ * What stays impossible is what needs a real process: synchronous execution
+ * (`execSync`, and `spawnSync` for a known program) and `fork`.
+ * @module @deepseek-ai/dsh-experimental-webworker-runtime/src/node/builtin_modules/implemented/child_process
+ */
+
+import { Buffer } from 'buffer'
+import { EventEmitter } from './events.ts'
+import { notImplementedFail } from '../../notImplementedFail.ts'
+import { registerProcess, releaseProcess, signalProcess } from '../../process-table.ts'
+import { startProcess } from '../../../shell/process/host.ts'
+import { standardPrograms } from '../../../shell/programs/index.ts'
+import { DSH_ROOT } from '../../../storage/paths.ts'
+
+const MODULE = 'node:child_process'
+
+/** Per-stream disposition, as Node's `stdio` array spells it. */
+type StdioSetting = 'pipe' | 'ignore' | 'inherit'
+
+/** The spawn options this shim reads; Node accepts more, none of which apply here. */
+export interface WorkerSpawnOptions {
+  cwd?: string | undefined
+  env?: Record<string, string | undefined> | undefined
+  stdio?: StdioSetting | readonly StdioSetting[] | undefined
+  /** Accepted and ignored: process groups do not exist, so there is no group to detach into. */
+  detached?: boolean | undefined
+}
+
+/**
+ * The readable half of a pipe: `data` events carrying Buffers, `end`, and a
+ * `destroy` that stops delivery.
+ *
+ * The stream-shaping members below are no-ops rather than omissions. A caller
+ * that configures the pipe before reading it (the browser launcher calls
+ * `setEncoding`) would otherwise die of a TypeError on the configuration line,
+ * hiding the real outcome — which for an unknown program is the `ENOENT` this
+ * shim is about to emit.
+ */
+class WorkerReadable extends EventEmitter {
+  private destroyed = false
+
+  /**
+   * Accept an encoding (chunks are always UTF-8 text carried as Buffers).
+   * @returns this stream.
+   */
+  setEncoding(): this {
+    return this
+  }
+
+  /**
+   * Accept a flow-control request; delivery is driven by the command, which
+   * has already produced whatever it produced.
+   * @returns this stream.
+   */
+  pause(): this {
+    return this
+  }
+
+  /** @returns this stream; see {@link pause}. */
+  resume(): this {
+    return this
+  }
+
+  /**
+   * Deliver one chunk to the `data` listeners.
+   * @param text - the text written by the command.
+   */
+  push(text: string): void {
+    if (this.destroyed || text === '') return
+    this.emit('data', Buffer.from(text, 'utf8'))
+  }
+
+  /** Signal end of stream. */
+  finish(): void {
+    if (this.destroyed) return
+    this.emit('end')
+  }
+
+  /** Stop delivering; the collector calls this once the process settles. */
+  destroy(): void {
+    this.destroyed = true
+    this.emit('close')
+  }
+}
+
+/** The writable half of stdin: the batch write the subprocess service performs. */
+class WorkerWritable extends EventEmitter {
+  private text = ''
+
+  /**
+   * Buffer one write.
+   * @param chunk - text or bytes to add to standard input.
+   * @returns true, since nothing here applies backpressure.
+   */
+  write(chunk: string | Uint8Array): boolean {
+    this.text += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
+    return true
+  }
+
+  /**
+   * Finish standard input.
+   * @param chunk - optional final write.
+   */
+  end(chunk?: string | Uint8Array): void {
+    if (chunk !== undefined) this.write(chunk)
+    this.emit('finish')
+  }
+
+  /** @returns everything written so far. */
+  contents(): string {
+    return this.text
+  }
+}
+
+/**
+ * One running command, wearing the parts of `ChildProcess` its consumers read.
+ */
+export class WorkerChildProcess extends EventEmitter {
+  /** The worker's own process id for this command, from the process table. */
+  readonly pid: number
+  /** Standard input, when the caller asked for a pipe; null otherwise. */
+  readonly stdin: WorkerWritable | null
+  /** Standard output, when the caller asked for a pipe; null otherwise. */
+  readonly stdout: WorkerReadable | null
+  /** Standard error, when the caller asked for a pipe; null otherwise. */
+  readonly stderr: WorkerReadable | null
+  /** Exit status once settled; null while running and after a signal. */
+  exitCode: number | null = null
+  /** The signal that ended the command, or null when it exited on its own. */
+  signalCode: NodeJS.Signals | null = null
+
+  constructor(pid: number, stdio: readonly StdioSetting[]) {
+    super()
+    this.pid = pid
+    this.stdin = stdio[0] === 'pipe' ? new WorkerWritable() : null
+    this.stdout = stdio[1] === 'pipe' ? new WorkerReadable() : null
+    this.stderr = stdio[2] === 'pipe' ? new WorkerReadable() : null
+  }
+
+  /**
+   * Deliver a signal to this command.
+   * @param signal - signal name; every one of them terminates.
+   * @returns true when the command was still running.
+   */
+  kill(signal: NodeJS.Signals = 'SIGTERM'): boolean {
+    return signalProcess(this.pid, signal)
+  }
+}
+
+/** Normalize the `stdio` option into the three-entry form the shim reads. */
+function stdioOf(option: WorkerSpawnOptions['stdio']): StdioSetting[] {
+  if (typeof option === 'string') return [option, option, option]
+  if (option === undefined) return ['pipe', 'pipe', 'pipe']
+  return [option[0] ?? 'pipe', option[1] ?? 'pipe', option[2] ?? 'pipe']
+}
+
+/** The environment a command runs with: the caller's map, minus the removals Node allows. */
+function environmentOf(option: WorkerSpawnOptions['env']): Record<string, string> {
+  const inherited = (globalThis as { process?: { env?: Record<string, string> } }).process?.env ?? {}
+  const source = option ?? inherited
+  return Object.fromEntries(Object.entries(source).filter(([, value]) => value !== undefined) as [string, string][])
+}
+
+/**
+ * A missing program fails the way Node fails a missing binary, so consumers
+ * that classify spawn errors by `code`, `path`, and `syscall` keep working.
+ */
+function spawnEnoent(program: string): NodeJS.ErrnoException {
+  const error = new Error(`spawn ${program} ENOENT`) as NodeJS.ErrnoException
+  error.code = 'ENOENT'
+  error.errno = -2
+  error.path = program
+  error.syscall = `spawn ${program}`
+  return error
+}
+
+/** Whether this argv is a shell invocation whose script the interpreter should parse. */
+function shellScriptOf(argv: readonly string[]): string | undefined {
+  const [program, flag, script] = argv
+  if ((program !== 'bash' && program !== 'sh') || flag !== '-c') return undefined
+  return script ?? ''
+}
+
+/**
+ * Run one command in the worker.
+ *
+ * The call returns immediately with a handle; the command runs in its own
+ * worker (or inline where no `Worker` exists) and reports back through the
+ * handle's pipes and events.
+ * @param program - the program name, as argv[0].
+ * @param args - its arguments.
+ * @param options - working directory, environment, and stdio dispositions.
+ * @returns the running command's handle.
+ */
+export function spawn(
+  program: string,
+  args: readonly string[] = [],
+  options: WorkerSpawnOptions = {},
+): WorkerChildProcess {
+  if (typeof program !== 'string' || program === '') {
+    // Node refuses a non-string command with this error rather than starting
+    // anything; a caller whose own lookup produced nothing reads why.
+    const invalid = new TypeError(`The "file" argument must be a non-empty string. Received ${program as unknown as string}`) as NodeJS.ErrnoException
+    invalid.code = 'ERR_INVALID_ARG_TYPE'
+    throw invalid
+  }
+  const argv = [program, ...args]
+  const stdio = stdioOf(options.stdio)
+  const entry = registerProcess()
+  const child = new WorkerChildProcess(entry.pid, stdio)
+
+  const script = shellScriptOf(argv)
+  const known = script !== undefined || standardPrograms().has(program)
+
+  const emit = (stream: 'stdout' | 'stderr', text: string): void => {
+    if (text === '') return
+    const pipe = stream === 'stdout' ? child.stdout : child.stderr
+    if (pipe !== null) {
+      pipe.push(text)
+      return
+    }
+    // An inherited stream belongs to the host: the worker's console is the
+    // only place it can go, and an ignored one goes nowhere.
+    if (stdio[stream === 'stdout' ? 1 : 2] === 'inherit') {
+      (stream === 'stdout' ? console.log : console.error)(text.replace(/\n$/, ''))
+    }
+  }
+
+  const settle = (exitCode: number): void => {
+    releaseProcess(entry.pid)
+    // A signalled command reports no exit code, which is what makes the
+    // subprocess service classify it as killed rather than finished.
+    const signal = entry.signal ?? null
+    child.exitCode = signal === null ? exitCode : null
+    child.signalCode = signal
+    child.stdout?.finish()
+    child.stderr?.finish()
+    child.emit('exit', child.exitCode, signal)
+    child.emit('close', child.exitCode, signal)
+  }
+
+  // The command starts on a microtask, so a caller that attaches listeners and
+  // writes standard input right after `spawn()` — the subprocess service does
+  // exactly that — is never racing the first output.
+  queueMicrotask(() => {
+    if (!known) {
+      releaseProcess(entry.pid)
+      child.emit('error', spawnEnoent(program))
+      return
+    }
+    entry.process = startProcess({
+      script,
+      argv,
+      cwd: options.cwd ?? DSH_ROOT,
+      env: environmentOf(options.env),
+      stdin: child.stdin?.contents() ?? '',
+      onOutput: emit,
+      onExit: settle,
+    })
+    // A signal that arrived while the process was still starting has to reach
+    // it now; the table recorded it but had nothing to deliver it to.
+    if (entry.signal !== undefined) {
+      if (entry.signal === 'SIGKILL') entry.process.destroy()
+      else entry.process.interrupt()
+    }
+  })
+
+  return child
+}
+
+/** The result shape `spawnSync` returns, holding only the members consumers read. */
+export interface WorkerSpawnSyncResult {
+  pid: number
+  status: number | null
+  signal: NodeJS.Signals | null
+  stdout: Buffer
+  stderr: Buffer
+  output: (Buffer | null)[]
+  /** Why the run did not happen; carries `code` for the callers that classify by it. */
+  error?: NodeJS.ErrnoException
+}
+
+/**
+ * Report that a command cannot run synchronously.
+ *
+ * Callers use `spawnSync` to probe for a binary (the sandbox runner probes do)
+ * and Node answers a missing one with an `error` rather than a throw, so this
+ * answers in the same shape: absent programs report `ENOENT`, and a program
+ * this shell *does* have reports that only the asynchronous path can run it.
+ * @param program - the program name.
+ * @returns the Node-shaped synchronous result carrying the failure.
+ */
+export function spawnSync(program: string): WorkerSpawnSyncResult {
+  const empty = Buffer.alloc(0)
+  const error = standardPrograms().has(program)
+    ? new Error(`${MODULE}.spawnSync cannot run ${program} in the worker host: commands run asynchronously`)
+    : spawnEnoent(program)
+  return { pid: -1, status: null, signal: null, stdout: empty, stderr: empty, output: [null, empty, empty], error }
+}
+
+/** Callback `exec` and `execFile` report through. */
+type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void
+
+/** Split the optional options argument from the callback Node allows in either position. */
+function execArguments(
+  options: WorkerSpawnOptions | ExecCallback | undefined,
+  callback: ExecCallback | undefined,
+): { options: WorkerSpawnOptions; callback: ExecCallback | undefined } {
+  if (typeof options === 'function') return { options: {}, callback: options }
+  return { options: options ?? {}, callback }
+}
+
+/**
+ * Run a command line and report its output through a callback.
+ * @param command - the shell source to run.
+ * @param options - working directory and environment, or the callback.
+ * @param callback - receives the failure (nonzero status included), stdout, and stderr.
+ * @returns the running command's handle.
+ */
+export function exec(
+  command: string,
+  options?: WorkerSpawnOptions | ExecCallback,
+  callback?: ExecCallback,
+): WorkerChildProcess {
+  const settled = execArguments(options, callback)
+  return execute(['bash', '-c', command], settled.options, settled.callback)
+}
+
+/**
+ * Run one program with an explicit argv and report its output through a callback.
+ * @param program - the program name.
+ * @param args - its arguments, or the options, or the callback.
+ * @param options - working directory and environment, or the callback.
+ * @param callback - receives the failure (nonzero status included), stdout, and stderr.
+ * @returns the running command's handle.
+ */
+export function execFile(
+  program: string,
+  args?: readonly string[] | WorkerSpawnOptions | ExecCallback,
+  options?: WorkerSpawnOptions | ExecCallback,
+  callback?: ExecCallback,
+): WorkerChildProcess {
+  const argv = Array.isArray(args) ? [program, ...args as string[]] : [program]
+  const shifted = Array.isArray(args) ? options : args as WorkerSpawnOptions | ExecCallback | undefined
+  const settled = execArguments(shifted, typeof options === 'function' ? options : callback)
+  return execute(argv, settled.options, settled.callback)
+}
+
+/** Shared body of `exec` and `execFile`: spawn, collect both streams, then report. */
+function execute(argv: readonly string[], options: WorkerSpawnOptions, callback: ExecCallback | undefined): WorkerChildProcess {
+  const child = spawn(argv[0] as string, argv.slice(1), { ...options, stdio: 'pipe' })
+  let stdout = ''
+  let stderr = ''
+  child.stdout?.on('data', (chunk: unknown) => { stdout += String(chunk) })
+  child.stderr?.on('data', (chunk: unknown) => { stderr += String(chunk) })
+  child.on('error', (error: unknown) => { callback?.(error instanceof Error ? error : new Error(String(error)), stdout, stderr) })
+  child.on('close', (code: unknown) => {
+    const status = typeof code === 'number' ? code : 1
+    callback?.(status === 0 ? null : new Error(`Command failed: ${argv.join(' ')}`), stdout, stderr)
+  })
+  return child
+}
+
+/** Run a command line synchronously (unavailable: the interpreter is asynchronous). */
+export const execSync: typeof import('node:child_process').execSync = notImplementedFail(MODULE, 'execSync')
+
+/** Run one program synchronously (unavailable: the interpreter is asynchronous). */
+export const execFileSync: typeof import('node:child_process').execFileSync = notImplementedFail(MODULE, 'execFileSync')
+
+/** Start a Node child (unavailable: the worker cannot create another Node runtime). */
+export const fork: typeof import('node:child_process').fork = notImplementedFail(MODULE, 'fork')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ../../builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:child_process` declarations this module stands in for. The four
+ * process starters keep this module's own types: they answer
+ * {@link WorkerChildProcess} and {@link WorkerSpawnSyncResult}, the pipes and exit
+ * facts a shell worker can carry, where Node declares a `ChildProcess` holding OS
+ * stream objects and, for `exec`/`execFile`, an overload ladder over encodings
+ * this shell reports as UTF-8 text.
+ */
+type NodeFace = Partial<Omit<typeof import('node:child_process'), 'spawn' | 'spawnSync' | 'exec' | 'execFile'>>
+  & Record<'spawn' | 'spawnSync' | 'exec' | 'execFile', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { spawn, spawnSync, exec, execFile, execFileSync, execSync, fork } satisfies NodeFace

+ 126 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/crypto.ts

@@ -0,0 +1,126 @@
+/**
+ * `node:crypto` for the worker: WebCrypto for randomness, `@noble/hashes` for the
+ * synchronous digests Node's streaming Hash object provides (SubtleCrypto is
+ * async, and every caller here hashes synchronously).
+ */
+import { sha1 } from '@noble/hashes/legacy.js'
+import { sha256, sha512 } from '@noble/hashes/sha2.js'
+import { randomUUID as mintUUID } from '@deepseek-ai/dsh-util-crypto'
+import { Buffer } from 'buffer'
+
+type Hasher = (input: Uint8Array) => Uint8Array
+
+const HASHERS: Record<string, Hasher> = {
+  sha1,
+  sha256,
+  sha512,
+}
+
+const encoder = new TextEncoder()
+
+const toBytes = (data: string | Uint8Array | ArrayBuffer): Uint8Array => {
+  if (typeof data === 'string') return encoder.encode(data)
+  if (data instanceof ArrayBuffer) return new Uint8Array(data)
+  return data
+}
+
+/** Node's streaming Hash face, restricted to the update/digest pair in use. */
+export interface Hash {
+  update(data: string | Uint8Array | ArrayBuffer, encoding?: string): Hash
+  digest(): Buffer
+  digest(encoding: 'hex' | 'base64'): string
+}
+
+/**
+ * Create a synchronous hash object.
+ * @param algorithm - digest name; only the algorithms the host tree uses exist.
+ * @returns the streaming hash face.
+ */
+export function createHash(algorithm: string): Hash {
+  const hasher = HASHERS[algorithm.toLowerCase().replace('-', '')]
+  if (hasher === undefined) {
+    throw new Error(`web-preview: node:crypto.createHash("${algorithm}") is not available in the worker host`)
+  }
+  const chunks: Uint8Array[] = []
+  const hash: Hash = {
+    update(data) {
+      chunks.push(toBytes(data))
+      return hash
+    },
+    digest(encoding?: 'hex' | 'base64') {
+      const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
+      const joined = new Uint8Array(total)
+      let at = 0
+      for (const chunk of chunks) {
+        joined.set(chunk, at)
+        at += chunk.byteLength
+      }
+      const digest = Buffer.from(hasher(joined))
+      return (encoding === undefined ? digest : digest.toString(encoding)) as Buffer & string
+    },
+  }
+  return hash
+}
+
+/**
+ * Random bytes.
+ * @param size - byte count.
+ * @returns a Buffer of cryptographically strong random bytes.
+ */
+export function randomBytes(size: number): Buffer<ArrayBuffer> {
+  const bytes = new Uint8Array(size)
+  globalThis.crypto.getRandomValues(bytes)
+  return Buffer.from(bytes)
+}
+
+/**
+ * Random v4 UUID. Delegated to the repository's own mint rather than to
+ * `crypto.randomUUID`, which browsers expose only in secure contexts — a
+ * preview served over plain HTTP on a LAN address has no `randomUUID`.
+ * @returns the UUID string.
+ */
+export function randomUUID(): import('node:crypto').UUID {
+  return mintUUID()
+}
+
+/**
+ * Fill a typed array with random bytes.
+ * @param target - the array to fill.
+ * @returns the same array.
+ */
+export function getRandomValues<T extends ArrayBufferView<ArrayBuffer>>(target: T): T {
+  return globalThis.crypto.getRandomValues(target)
+}
+
+/**
+ * Random integer in `[0, max)`.
+ * @param max - exclusive upper bound.
+ * @returns the integer.
+ */
+export function randomInt(max: number): number {
+  const sample = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] ?? 0
+  return Math.floor((sample / 2 ** 32) * max)
+}
+
+/** WebCrypto instance, as Node exposes it. */
+export const webcrypto = globalThis.crypto
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:crypto` declarations this module stands in for. Three members keep
+ * this module's own types: Node declares `createHash` as returning a Transform
+ * stream, while this Hash is the synchronous update/digest pair the host tree
+ * calls; `webcrypto` is the browser `Crypto` object, whose `subtle` face is
+ * declared by the DOM library rather than by Node; and `getRandomValues` accepts
+ * only a typed-array view, the values WebCrypto can fill, where Node's
+ * declaration also admits a bare `ArrayBuffer`.
+ */
+type NodeFace = Partial<Omit<typeof import('node:crypto'), 'createHash' | 'getRandomValues' | 'webcrypto'>>
+  & Record<'createHash' | 'getRandomValues' | 'webcrypto', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  createHash, randomBytes, randomUUID, getRandomValues, randomInt, webcrypto,
+} satisfies NodeFace

+ 156 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/events.ts

@@ -0,0 +1,156 @@
+/**
+ * `node:events`: a minimal EventEmitter with the members harness code uses.
+ * Emission order and listener identity follow Node; anything beyond the basic
+ * on/once/off/emit set throws.
+ */
+
+type Listener = (...args: unknown[]) => void
+
+/**
+ * A `once` wrapper, carrying the listener it stands for. Node publishes the same
+ * `listener` member, and `removeListener(event, original)` matches through it, so
+ * a caller that registered with `once` can withdraw with the function it wrote.
+ */
+type OnceWrapper = Listener & { listener: Listener }
+
+/** The `node:events` subset the harness registers on: add, remove, and emit. */
+export class EventEmitter {
+  private readonly registry = new Map<string, Listener[]>()
+
+  /**
+   * Register a listener.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this emitter.
+   */
+  on(event: string, listener: Listener): this {
+    const list = this.registry.get(event) ?? []
+    list.push(listener)
+    this.registry.set(event, list)
+    return this
+  }
+
+  /**
+   * Register a listener removed after its first call.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this emitter.
+   */
+  once(event: string, listener: Listener): this {
+    const wrapper = ((...args: unknown[]): void => {
+      this.off(event, wrapper)
+      listener(...args)
+    }) as OnceWrapper
+    wrapper.listener = listener
+    return this.on(event, wrapper)
+  }
+
+  /**
+   * Register a listener ahead of the existing ones.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this emitter.
+   */
+  prependListener(event: string, listener: Listener): this {
+    const list = this.registry.get(event) ?? []
+    list.unshift(listener)
+    this.registry.set(event, list)
+    return this
+  }
+
+  /**
+   * Remove a listener, by the function that was registered or by the one a
+   * `once` wrapper stands for.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this emitter.
+   */
+  off(event: string, listener: Listener): this {
+    const list = this.registry.get(event)
+    if (list !== undefined) {
+      // Last registration first, as Node removes it.
+      for (let at = list.length - 1; at >= 0; at--) {
+        const registered = list[at]
+        if (registered === listener || (registered as OnceWrapper | undefined)?.listener === listener) {
+          list.splice(at, 1)
+          break
+        }
+      }
+    }
+    return this
+  }
+
+  /**
+   * Alias of {@link off}.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this emitter.
+   */
+  removeListener(event: string, listener: Listener): this {
+    return this.off(event, listener)
+  }
+
+  /**
+   * Drop listeners for one event, or all of them.
+   * @param event - event name; omitted clears every event.
+   * @returns this emitter.
+   */
+  removeAllListeners(event?: string): this {
+    if (event === undefined) this.registry.clear()
+    else this.registry.delete(event)
+    return this
+  }
+
+  /**
+   * Emit an event.
+   * @param event - event name.
+   * @param args - listener arguments.
+   * @returns whether any listener ran.
+   */
+  emit(event: string, ...args: unknown[]): boolean {
+    const list = this.registry.get(event)
+    if (list === undefined || list.length === 0) return false
+    for (const listener of [...list]) listener(...args)
+    return true
+  }
+
+  /**
+   * Listeners of one event.
+   * @param event - event name.
+   * @returns a copy of the listener list.
+   */
+  listeners(event: string): Listener[] {
+    return [...this.registry.get(event) ?? []]
+  }
+
+  /**
+   * Listener count of one event.
+   * @param event - event name.
+   * @returns the count.
+   */
+  listenerCount(event: string): number {
+    return this.registry.get(event)?.length ?? 0
+  }
+
+  /**
+   * Node's max-listener knob has no effect here.
+   * @returns This emitter, for chaining.
+   */
+  setMaxListeners(): this {
+    return this
+  }
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:events` declarations this module stands in for. `EventEmitter` keeps
+ * this module's own class: Node's declaration carries the promise helpers and
+ * statics (`once`, `on`, `getEventListeners`, `errorMonitor`) that no worker
+ * caller registers through.
+ */
+type NodeFace = Partial<Omit<typeof import('node:events'), 'EventEmitter'>> & Record<'EventEmitter', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { EventEmitter } satisfies NodeFace

+ 585 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs.ts

@@ -0,0 +1,585 @@
+/**
+ * `node:fs` bridge over the worker's in-memory VFS. `MemoryVfs` owns paths,
+ * bytes, the directory tree, and Node's error codes; this module adds only what
+ * is Node-API-shaped and not VFS business: Buffer results, `Dirent` objects,
+ * file descriptors, `mkdtemp`, access checks, inert watches, and the promise face.
+ */
+import { requireActiveVfs } from '../../../storage/active.ts'
+import type { MemoryVfs } from '../../../storage/memory.ts'
+import type { VfsBigIntStats, VfsStatOptions, VfsStats, VfsWriteOptions } from '../../../storage/types.ts'
+import { Buffer } from 'buffer'
+import { dirname } from './path.ts'
+
+const vfs = (): MemoryVfs => requireActiveVfs()
+
+const notImplemented = (method: string, subject: string): never => {
+  throw new Error(`web-preview: node:fs.${method} is not implemented in the worker host (${subject})`)
+}
+
+type PathArg = string | URL | Uint8Array
+
+const asPath = (path: PathArg): string => {
+  if (typeof path === 'string') return path
+  if (path instanceof URL) return decodeURIComponent(path.pathname)
+  return new TextDecoder().decode(path)
+}
+
+type EncodingOption = BufferEncoding | { encoding?: BufferEncoding | null } | null | undefined
+
+const encodingOf = (options: EncodingOption): BufferEncoding | undefined => {
+  if (options === undefined || options === null) return undefined
+  if (typeof options === 'string') return options
+  return options.encoding ?? undefined
+}
+
+const bytesOf = (path: string): Uint8Array => vfs().readFileSync(path) as Uint8Array
+
+/** Share the VFS bytes rather than copying them. */
+const asBuffer = (bytes: Uint8Array): Buffer =>
+  Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+
+/** Node `Dirent` subset returned by `readdirSync(dir, { withFileTypes: true })`. */
+export class Dirent {
+  /** Entry name, without its directory. */
+  readonly name: string
+  /** Directory this entry was listed from. */
+  readonly parentPath: string
+  private readonly file: boolean
+
+  /**
+   * Build one directory entry.
+   * @param name - entry name.
+   * @param parentPath - directory holding it.
+   * @param file - whether the entry is a regular file.
+   */
+  constructor(name: string, parentPath: string, file: boolean) {
+    this.name = name
+    this.parentPath = parentPath
+    this.file = file
+  }
+
+  /**
+   * Entry kind, as `readdirSync` observed it.
+   * @returns Whether the entry is a regular file.
+   */
+  isFile(): boolean {
+    return this.file
+  }
+
+  /**
+   * Entry kind, as `readdirSync` observed it.
+   * @returns Whether the entry is a directory.
+   */
+  isDirectory(): boolean {
+    return !this.file
+  }
+
+  /**
+   * Symlink test, answered from the image's own shape.
+   * @returns False — the image is materialized without symlinks.
+   */
+  isSymbolicLink(): boolean {
+    return false
+  }
+}
+
+/** Access-mode constants; the VFS has no permission model, so all bits pass. */
+export const constants = {
+  F_OK: 0,
+  R_OK: 4,
+  W_OK: 2,
+  X_OK: 1,
+  COPYFILE_EXCL: 1,
+  O_RDONLY: 0,
+  O_WRONLY: 1,
+  O_RDWR: 2,
+  O_CREAT: 64,
+  O_TRUNC: 512,
+  O_APPEND: 1024,
+}
+
+/**
+ * Read a file.
+ * @param path - file path.
+ * @param options - encoding, or an options object carrying one.
+ * @returns bytes, or text when an encoding is given.
+ */
+export function readFileSync(path: PathArg, options?: EncodingOption): Buffer | string {
+  const encoding = encodingOf(options)
+  const bytes = bytesOf(asPath(path))
+  return encoding === undefined || encoding === 'utf8' || encoding === 'utf-8'
+    ? (encoding === undefined ? asBuffer(bytes) : new TextDecoder().decode(bytes))
+    : asBuffer(bytes).toString(encoding)
+}
+
+/**
+ * Write a file.
+ * @param path - file path.
+ * @param data - bytes or text.
+ * @param options - write flag and creation mode, forwarded to the VFS.
+ */
+export function writeFileSync(path: PathArg, data: string | Uint8Array, options?: VfsWriteOptions): void {
+  vfs().writeFileSync(asPath(path), data, options)
+}
+
+/**
+ * Append to a file, creating it when absent.
+ * @param path - file path.
+ * @param data - bytes or text.
+ */
+export function appendFileSync(path: PathArg, data: string | Uint8Array): void {
+  vfs().appendFileSync(asPath(path), data)
+}
+
+/**
+ * Whether a path exists.
+ * @param path - the path.
+ * @returns true when present.
+ */
+export function existsSync(path: PathArg): boolean {
+  return vfs().existsSync(asPath(path))
+}
+
+/**
+ * Stat a path.
+ * @param path - the path.
+ * @param options - `bigint` selects the BigInt stats the filesystem service reads.
+ * @returns the stats, in the plain or BigInt shape.
+ */
+export function statSync(path: PathArg, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
+  return vfs().statSync(asPath(path), options)
+}
+
+/**
+ * Change an entry's permission bits; stat reads back exactly what was set.
+ * @param path - the path.
+ * @param mode - new permission bits (`0o777` mask), numeric or Node's octal string form.
+ */
+export function chmodSync(path: PathArg, mode: number | string): void {
+  vfs().chmodSync(asPath(path), typeof mode === 'string' ? Number.parseInt(mode, 8) : mode)
+}
+
+/**
+ * Stat a path without following symlinks (the image has none).
+ * @param path - the path.
+ * @param options - `bigint` selects the BigInt stats the filesystem service reads.
+ * @returns the stats, in the plain or BigInt shape.
+ */
+export function lstatSync(path: PathArg, options?: VfsStatOptions): VfsStats | VfsBigIntStats {
+  return statSync(path, options)
+}
+
+/**
+ * Canonical path (normalization only: the image is symlink-free).
+ * @param path - the path.
+ * @returns the resolved path.
+ */
+export function realpathSync(path: PathArg): string {
+  return vfs().realpathSync(asPath(path))
+}
+
+/**
+ * List a directory.
+ * @param path - directory path.
+ * @param options - `withFileTypes` selects Dirent objects.
+ * @returns names, or Dirent objects.
+ */
+export function readdirSync(
+  path: PathArg,
+  options?: { withFileTypes?: boolean } | BufferEncoding | null,
+): string[] | Dirent[] {
+  const target = asPath(path)
+  const names = vfs().readdirSync(target)
+  if (typeof options !== 'object' || options === null || options.withFileTypes !== true) return names
+  return names.map(name => new Dirent(name, target, vfs().statSync(`${target}/${name}`).isFile()))
+}
+
+/**
+ * Create a directory.
+ * @param path - directory path.
+ * @param options - `recursive` creates parents.
+ * @returns the first created path when recursive, else undefined.
+ */
+export function mkdirSync(path: PathArg, options?: { recursive?: boolean; mode?: number }): string | undefined {
+  return vfs().mkdirSync(asPath(path), options)
+}
+
+/**
+ * Create a uniquely named directory.
+ * @param prefix - path prefix; six random characters are appended.
+ * @returns the created directory path.
+ */
+export function mkdtempSync(prefix: string): string {
+  // Not crypto.randomUUID: browsers expose that only in secure contexts.
+  const suffix = Array.from(globalThis.crypto.getRandomValues(new Uint8Array(3)), byte => byte.toString(16).padStart(2, '0')).join('')
+  const target = `${prefix}${suffix}`
+  vfs().mkdirSync(target, { recursive: true })
+  return target
+}
+
+/**
+ * Remove a file or directory.
+ * @param path - the path.
+ * @param options - `recursive`/`force`, as in Node.
+ */
+export function rmSync(path: PathArg, options?: { recursive?: boolean; force?: boolean }): void {
+  vfs().rmSync(asPath(path), options)
+}
+
+/**
+ * Remove a file.
+ * @param path - the path.
+ */
+export function unlinkSync(path: PathArg): void {
+  vfs().rmSync(asPath(path))
+}
+
+/**
+ * Rename a path.
+ * @param from - source path.
+ * @param to - target path.
+ */
+export function renameSync(from: PathArg, to: PathArg): void {
+  vfs().renameSync(asPath(from), asPath(to))
+}
+
+/**
+ * Access check: existence only.
+ * @param path - the path.
+ */
+export function accessSync(path: PathArg): void {
+  vfs().realpathSync(asPath(path))
+}
+
+interface OpenFile {
+  path: string
+  position: number
+  append: boolean
+}
+
+const openFiles = new Map<number, OpenFile>()
+let nextFd = 3
+
+/**
+ * Open a file descriptor.
+ * @param path - file path.
+ * @param flags - Node flag string: 'r', 'w', 'a', with optional '+' and the
+ * exclusive 'x' (create-only) modifier.
+ * @returns the descriptor.
+ */
+export function openSync(path: PathArg, flags = 'r'): number {
+  const target = asPath(path)
+  const exists = vfs().existsSync(target)
+  if (flags.includes('x') && exists) {
+    const error = new Error(`EEXIST: file already exists, open '${target}'`) as Error & { code: string; path: string }
+    error.code = 'EEXIST'
+    error.path = target
+    throw error
+  }
+  if (flags.startsWith('r')) vfs().realpathSync(target)
+  else if (flags.startsWith('w') || !exists) vfs().writeFileSync(target, new Uint8Array(0))
+  const fd = nextFd++
+  openFiles.set(fd, { path: target, position: 0, append: flags.startsWith('a') })
+  return fd
+}
+
+const fileOf = (fd: number, syscall: string): OpenFile => {
+  const file = openFiles.get(fd)
+  if (file === undefined) throw new Error(`EBADF: bad file descriptor, ${syscall}`)
+  return file
+}
+
+/**
+ * Read from a descriptor.
+ * @param fd - descriptor.
+ * @param buffer - destination.
+ * @param offset - destination offset.
+ * @param length - byte count.
+ * @param position - file position, or null to continue from the cursor.
+ * @returns bytes read.
+ */
+export function readSync(
+  fd: number,
+  buffer: Uint8Array,
+  offset = 0,
+  length = buffer.byteLength,
+  position: number | null = null,
+): number {
+  const file = fileOf(fd, 'read')
+  const bytes = bytesOf(file.path)
+  const from = position ?? file.position
+  const slice = bytes.subarray(from, from + length)
+  buffer.set(slice, offset)
+  if (position === null) file.position = from + slice.byteLength
+  return slice.byteLength
+}
+
+/**
+ * Write through a descriptor.
+ * @param fd - descriptor.
+ * @param data - bytes or text.
+ * @returns bytes written.
+ */
+export function writeSync(fd: number, data: string | Uint8Array): number {
+  const file = fileOf(fd, 'write')
+  const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
+  if (file.append) {
+    vfs().appendFileSync(file.path, bytes)
+    return bytes.byteLength
+  }
+  const existing = vfs().existsSync(file.path) ? bytesOf(file.path) : new Uint8Array(0)
+  const merged = new Uint8Array(Math.max(existing.byteLength, file.position + bytes.byteLength))
+  merged.set(existing, 0)
+  merged.set(bytes, file.position)
+  vfs().writeFileSync(file.path, merged)
+  file.position += bytes.byteLength
+  return bytes.byteLength
+}
+
+/**
+ * Close a descriptor.
+ * @param fd - descriptor.
+ */
+export function closeSync(fd: number): void {
+  openFiles.delete(fd)
+}
+
+/**
+ * Create a second name for one file's contents. Hard links do not exist in the
+ * VFS, so the bytes are copied.
+ * @param from - existing path.
+ * @param to - new path.
+ */
+export function linkSync(from: PathArg, to: PathArg): void {
+  writeFileSync(to, bytesOf(asPath(from)))
+}
+
+/**
+ * Open file handle (`fs.FileHandle` subset): the atomic-write and durability
+ * pair the storage backends use. `sync`/`datasync` are no-ops — an in-memory
+ * filesystem has nothing to flush, and a worker reload loses it either way.
+ */
+export interface FileHandle {
+  readonly fd: number
+  readFile(options?: EncodingOption): Promise<Buffer | string>
+  writeFile(data: string | Uint8Array, encoding?: BufferEncoding): Promise<void>
+  write(data: string | Uint8Array): Promise<{ bytesWritten: number }>
+  read(buffer: Uint8Array, offset?: number, length?: number, position?: number | null): Promise<{ bytesRead: number; buffer: Uint8Array }>
+  stat(): Promise<VfsStats>
+  truncate(length?: number): Promise<void>
+  sync(): Promise<void>
+  datasync(): Promise<void>
+  close(): Promise<void>
+}
+
+/**
+ * Open a file handle. Directories open read-only, which is what the durability
+ * helpers do before an fsync.
+ * @param path - file or directory path.
+ * @param flags - Node flag string.
+ * @returns the handle.
+ */
+export function openHandleSync(path: PathArg, flags = 'r'): FileHandle {
+  const target = asPath(path)
+  const directory = vfs().existsSync(target) && vfs().statSync(target).isDirectory()
+  const append = flags.startsWith('a')
+  const fd = directory ? -1 : openSync(target, flags)
+  return {
+    fd,
+    readFile: async (options?: EncodingOption) => readFileSync(target, options),
+    // Node appends when the handle was opened with 'a'. The JSONL session log
+    // depends on it — `open(path, 'a')` then `writeFile(batch)` — and replacing
+    // the file there destroys the header frame its reader requires.
+    writeFile: async (data: string | Uint8Array) => {
+      if (append) appendFileSync(target, data)
+      else writeFileSync(target, data)
+    },
+    write: async (data: string | Uint8Array) => ({ bytesWritten: writeSync(fd, data) }),
+    read: async (buffer: Uint8Array, offset = 0, length = buffer.byteLength, position: number | null = null) => ({
+      bytesRead: readSync(fd, buffer, offset, length, position),
+      buffer,
+    }),
+    stat: async () => statSync(target) as VfsStats,
+    truncate: async (length = 0) => {
+      writeFileSync(target, bytesOf(target).subarray(0, length))
+    },
+    sync: async () => { /* memory-backed: nothing to flush */ },
+    datasync: async () => { /* memory-backed: nothing to flush */ },
+    close: async () => {
+      if (fd !== -1) closeSync(fd)
+    },
+  }
+}
+
+/**
+ * Watch registration refuses loudly, and NOT because watching is hard.
+ *
+ * The inert form was tried: `chokidar.ts` records that "no events" is the truth
+ * about a filesystem with no external writer, and the same reasoning seemed to
+ * cover this. It does not, because of the caller. `skill-filesystem` does not
+ * merely register a listener — `openStableWatcher` opens a watcher and then
+ * loops until two consecutive mode probes agree, so a watcher that reports
+ * success and never fires leaves `observeRoots()` awaiting forever: the skill
+ * catalog RPC never answers and the worker's single thread stops serving `/api`
+ * for the rest of the session. A refusal instead fails that path fast, which the
+ * provider already handles by returning an incomplete observation.
+ *
+ * So the family split is about what the CALLER does with the capability, not
+ * about the capability: a listener registration tolerates absence, a watcher
+ * whose progress is awaited does not.
+ * @param path - the path a caller wanted watched, named in the refusal.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function watchFile(path: PathArg): never {
+  return notImplemented('watchFile', asPath(path))
+}
+
+/** Watch removal; teardown paths call it unconditionally, and nothing was watched. */
+export function unwatchFile(): void {
+  // No watch was ever established.
+}
+
+/**
+ * Streaming read is unavailable: node:stream has no implementation here.
+ * @param path - the path a caller wanted streamed, named in the refusal.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function createReadStream(path: PathArg): never {
+  return notImplemented('createReadStream', asPath(path))
+}
+
+/**
+ * Streaming write counterpart of {@link createReadStream}.
+ * @param path - the path a caller wanted streamed, named in the refusal.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function createWriteStream(path: PathArg): never {
+  return notImplemented('createWriteStream', asPath(path))
+}
+
+/** Open directory handle (`fs.Dir` subset): iteration plus the close pair. */
+export interface Dir {
+  readonly path: string
+  read(): Promise<Dirent | null>
+  close(): Promise<void>
+  closeSync(): void
+  [Symbol.asyncIterator](): AsyncIterableIterator<Dirent>
+}
+
+/**
+ * Open a directory handle. Callers use it to assert "this path is a directory"
+ * and to walk entries; the listing is taken once, since the VFS has no external
+ * writer to race with.
+ * @param path - directory path.
+ * @returns the handle.
+ */
+export function opendirSync(path: PathArg): Dir {
+  const target = asPath(path)
+  const entries = readdirSync(target, { withFileTypes: true }) as Dirent[]
+  let index = 0
+  const next = (): Dirent | null => entries[index++] ?? null
+  return {
+    path: target,
+    read: async () => next(),
+    close: async () => { index = entries.length },
+    closeSync: () => { index = entries.length },
+    async *[Symbol.asyncIterator]() {
+      for (let entry = next(); entry !== null; entry = next()) yield entry
+    },
+  }
+}
+
+/**
+ * Promise face (`node:fs/promises`) over the same VFS. Each member answers the
+ * union the VFS produces rather than Node's encoding-dependent overloads, so the
+ * check here is that every name is a real `node:fs/promises` export.
+ */
+export const promises = {
+  readFile: async (path: PathArg, options?: EncodingOption): Promise<Buffer | string> => readFileSync(path, options),
+  writeFile: async (
+    path: PathArg,
+    data: string | Uint8Array,
+    options?: { flag?: string; mode?: number } | BufferEncoding | null,
+  ): Promise<void> => {
+    const flag = typeof options === 'object' && options !== null ? options.flag : undefined
+    const mode = typeof options === 'object' && options !== null ? options.mode : undefined
+    if (flag !== undefined && flag.includes('x') && existsSync(path)) {
+      const error = new Error(`EEXIST: file already exists, open '${asPath(path)}'`) as Error & { code: string }
+      error.code = 'EEXIST'
+      throw error
+    }
+    if (flag !== undefined && flag.startsWith('a')) appendFileSync(path, data)
+    else writeFileSync(path, data, { ...flag === undefined ? {} : { flag }, ...mode === undefined ? {} : { mode } })
+  },
+  appendFile: async (path: PathArg, data: string | Uint8Array): Promise<void> => { appendFileSync(path, data) },
+  mkdir: async (path: PathArg, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined> => mkdirSync(path, options),
+  mkdtemp: async (prefix: string): Promise<string> => mkdtempSync(prefix),
+  readdir: async (
+    path: PathArg,
+    options?: { withFileTypes?: boolean } | BufferEncoding,
+  ): Promise<string[] | Dirent[]> => readdirSync(path, options),
+  stat: async (path: PathArg, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => statSync(path, options),
+  lstat: async (path: PathArg, options?: VfsStatOptions): Promise<VfsStats | VfsBigIntStats> => lstatSync(path, options),
+  realpath: async (path: PathArg): Promise<string> => realpathSync(path),
+  rm: async (path: PathArg, options?: { recursive?: boolean; force?: boolean }): Promise<void> => { rmSync(path, options) },
+  unlink: async (path: PathArg): Promise<void> => { unlinkSync(path) },
+  rename: async (from: PathArg, to: PathArg): Promise<void> => { renameSync(from, to) },
+  access: async (path: PathArg): Promise<void> => { accessSync(path) },
+  chmod: async (path: PathArg, mode: number | string): Promise<void> => { chmodSync(path, mode) },
+  cp: async (from: PathArg, to: PathArg): Promise<void> => {
+    const source = asPath(from)
+    const target = asPath(to)
+    if (statSync(source).isDirectory()) {
+      mkdirSync(target, { recursive: true })
+      for (const name of vfs().readdirSync(source)) await promises.cp(`${source}/${name}`, `${target}/${name}`)
+      return
+    }
+    mkdirSync(dirname(target), { recursive: true })
+    writeFileSync(target, bytesOf(source))
+  },
+  // The VFS has no inodes, so a hard link is a byte copy: the caller's contract
+  // is only that both names read the same content until one is removed.
+  link: async (from: PathArg, to: PathArg): Promise<void> => { linkSync(from, to) },
+  open: async (path: PathArg, flags?: string): Promise<FileHandle> => openHandleSync(path, flags),
+  opendir: async (path: PathArg): Promise<Dir> => opendirSync(path),
+  truncate: async (path: PathArg, length = 0): Promise<void> => {
+    writeFileSync(path, bytesOf(asPath(path)).subarray(0, length))
+  },
+  constants,
+} satisfies Partial<Record<keyof typeof import('node:fs/promises'), unknown>>
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * Members Node declares as encoding- and option-dependent overload ladders
+ * (`readFileSync` answering `Buffer` XOR `string`, `statSync` answering `Stats`
+ * XOR `BigIntStats`, `mkdirSync` answering `string` XOR `void`). This module
+ * answers the union its VFS actually produces from one signature, which no single
+ * signature can present as all of Node's overloads; `realpathSync` additionally
+ * carries Node's `.native` member, and `constants`, `promises`, and `Dirent` hold
+ * the subsets the host tree reads.
+ */
+type OwnSignature =
+  | 'constants' | 'promises' | 'Dirent'
+  | 'readFileSync' | 'writeFileSync' | 'appendFileSync' | 'statSync' | 'lstatSync' | 'realpathSync'
+  | 'readdirSync' | 'mkdirSync' | 'mkdtempSync' | 'rmSync' | 'opendirSync'
+  | 'openSync' | 'readSync' | 'writeSync'
+
+/**
+ * The `node:fs` declarations this module stands in for. Every other member is
+ * checked against Node; `openHandleSync` is the worker's own handle opener, which
+ * `promises.open` answers with and Node has no synchronous counterpart for.
+ */
+type NodeFace = Partial<Omit<typeof import('node:fs'), OwnSignature>>
+  & Record<OwnSignature | 'openHandleSync', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  constants, promises, Dirent,
+  readFileSync, writeFileSync, appendFileSync, existsSync, statSync, lstatSync, realpathSync, chmodSync,
+  readdirSync, mkdirSync, mkdtempSync, rmSync, unlinkSync, renameSync, accessSync, opendirSync,
+  openHandleSync, linkSync,
+  openSync, readSync, writeSync, closeSync, watchFile, unwatchFile,
+  createReadStream, createWriteStream,
+} satisfies NodeFace

+ 20 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/fs/promises.ts

@@ -0,0 +1,20 @@
+/**
+ * `node:fs/promises` face: the promise members of the VFS bridge, re-exported as
+ * named bindings so `import { readFile } from 'node:fs/promises'` resolves. The
+ * member set is checked against Node where it is built, on `promises` in
+ * `../fs.ts`.
+ */
+import { Dirent, promises } from '../fs.ts'
+
+/** The promise members of the VFS bridge, as `node:fs/promises` names them. */
+export const {
+  readFile, writeFile, appendFile, mkdir, mkdtemp, readdir, stat, lstat, realpath, rm, unlink,
+  rename, access, chmod, cp, link, open, opendir, truncate, constants,
+} = promises
+
+export { Dirent }
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+export default promises

+ 179 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/http.ts

@@ -0,0 +1,179 @@
+/**
+ * `node:http` for the worker: `createServer` returns a Server whose `listen`
+ * succeeds immediately without a socket, and retains the captured request
+ * listener so the tunnel server can feed synthesized requests into the real
+ * route table (research/transport.md §5.1: 7 Server members, all pure values).
+ * The worker entry hands {@link whenRequestListener} to the host assembly, so the
+ * package never reaches back into this app.
+ */
+
+import type { RequestListener } from '../../../transport/synthetic-http.ts'
+
+type Listener = (...args: unknown[]) => void
+
+export type { RequestListener }
+
+/** Port reported by `address()`; it becomes `webServer.port`. */
+const VIRTUAL_PORT = 3080
+
+let captured: RequestListener | undefined
+const waiting = new Set<(listener: RequestListener) => void>()
+
+/**
+ * The webserver's request listener, once `[Service.init]` has installed it.
+ * @returns the listener, or undefined before the webserver row activates.
+ */
+export function requestListener(): RequestListener | undefined {
+  return captured
+}
+
+/**
+ * Await the request listener.
+ * @returns a promise resolved with the listener as soon as it is captured.
+ */
+export async function whenRequestListener(): Promise<RequestListener> {
+  if (captured !== undefined) return captured
+  return await new Promise<RequestListener>(resolve => waiting.add(resolve))
+}
+
+/** Fake Server: event registrations are stored and never emitted. */
+class FakeServer {
+  private readonly listeners = new Map<string, Set<Listener>>()
+
+  /**
+   * Register an event listener (`upgrade`, `error`); never emitted.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this server.
+   */
+  on(event: string, listener: Listener): this {
+    const set = this.listeners.get(event) ?? new Set<Listener>()
+    set.add(listener)
+    this.listeners.set(event, set)
+    return this
+  }
+
+  /**
+   * One-shot registration counterpart of {@link on}.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this server.
+   */
+  once(event: string, listener: Listener): this {
+    return this.on(event, listener)
+  }
+
+  /**
+   * Remove a listener.
+   * @param event - event name.
+   * @param listener - the listener.
+   * @returns this server.
+   */
+  off(event: string, listener: Listener): this {
+    this.listeners.get(event)?.delete(listener)
+    return this
+  }
+
+  /**
+   * Bind: succeeds immediately. The callback must run or the webserver fiber
+   * stays in LOADING forever.
+   * @param args - Node's listen arguments; only a trailing callback matters.
+   * @returns this server.
+   */
+  listen(...args: unknown[]): this {
+    const callback = args.at(-1)
+    if (typeof callback === 'function') queueMicrotask(() => { (callback as Listener)() })
+    return this
+  }
+
+  /**
+   * Bound address.
+   * @returns the loopback authority the tunnel synthesizes.
+   */
+  address(): { address: string; family: string; port: number } {
+    return { address: '127.0.0.1', family: 'IPv4', port: VIRTUAL_PORT }
+  }
+
+  /**
+   * Close: no socket to release.
+   * @param callback - completion callback, invoked immediately.
+   * @returns this server.
+   */
+  close(callback?: Listener): this {
+    if (callback !== undefined) queueMicrotask(() => { callback() })
+    return this
+  }
+
+  /** No connection was ever accepted. */
+  closeAllConnections(): void {
+    // Nothing is ever accepted through this Server.
+  }
+
+  /** No idle connection exists either. */
+  closeIdleConnections(): void {
+    // Nothing is ever accepted through this Server.
+  }
+}
+
+/**
+ * Create the fake server and retain its request listener for the tunnel.
+ * @param listener - the request listener the webserver installs.
+ * @returns the fake Server.
+ */
+export function createServer(listener?: RequestListener): FakeServer {
+  if (listener !== undefined) {
+    captured = listener
+    for (const resolve of waiting) resolve(listener)
+    waiting.clear()
+  }
+  return new FakeServer()
+}
+
+/**
+ * Outbound HTTP has one carrier in the worker: `fetch`.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function request(): never {
+  throw new Error('web-preview: node:http.request is not available in the worker host — use fetch')
+}
+
+/**
+ * Same as {@link request}.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function get(): never {
+  throw new Error('web-preview: node:http.get is not available in the worker host — use fetch')
+}
+
+/** Status text table Node exposes; a few handlers write status lines by hand. */
+export const STATUS_CODES: typeof import('node:http').STATUS_CODES = {
+  200: 'OK',
+  204: 'No Content',
+  304: 'Not Modified',
+  400: 'Bad Request',
+  403: 'Forbidden',
+  404: 'Not Found',
+  405: 'Method Not Allowed',
+  413: 'Payload Too Large',
+  415: 'Unsupported Media Type',
+  426: 'Upgrade Required',
+  500: 'Internal Server Error',
+  503: 'Service Unavailable',
+}
+
+export { FakeServer as Server }
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:http` declarations this module stands in for. `Server` and
+ * `createServer` keep this module's own types: Node declares the server as a
+ * `net.Server` carrying sockets and a Node `RequestListener`, while this one binds
+ * nothing and captures the synthesized-request listener the tunnel feeds.
+ */
+type NodeFace = Partial<Omit<typeof import('node:http'), 'Server' | 'createServer'>>
+  & Record<'Server' | 'createServer', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { createServer, request, get, STATUS_CODES, Server: FakeServer } satisfies NodeFace

+ 73 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/module.ts

@@ -0,0 +1,73 @@
+/**
+ * `node:module` for the worker: `createRequire` hands out the worker module
+ * loader's synchronous require, so typert's `require.resolve('<pkg>/package.json')
+ * + readFileSync + import()` bypass runs unmodified over the VFS.
+ */
+import { requireActiveModuleLoader, type WorkerRequire } from '../../../module-system/module-loader.ts'
+
+/** Node `require` face the harness consumes. */
+export type NodeRequire = WorkerRequire
+
+/**
+ * Build a `require` bound to a base path or file URL.
+ * @param base - directory, file path, or file URL the resolution starts from.
+ * @returns the synchronous require face.
+ */
+export function createRequire(base: string | URL): NodeRequire {
+  return requireActiveModuleLoader().createRequire(base)
+}
+
+/** Builtin specifiers the module proxy table answers (without the `node:` prefix). */
+export const builtinModules = [
+  'assert', 'async_hooks', 'buffer', 'child_process', 'crypto', 'events', 'fs', 'http', 'module',
+  'net', 'os', 'path', 'process', 'stream', 'url', 'util', 'worker_threads',
+]
+
+/**
+ * Whether a specifier names a Node builtin.
+ * @param specifier - the module specifier.
+ * @returns true for builtin names, with or without the `node:` prefix.
+ */
+export function isBuiltin(specifier: string): boolean {
+  return builtinModules.includes(specifier.replace(/^node:/, ''))
+}
+
+/**
+ * TypeScript stripping is a Node 22+ loader feature with no worker counterpart.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function stripTypeScriptTypes(): never {
+  throw new Error('web-preview: node:module.stripTypeScriptTypes is not available in the worker host')
+}
+
+/**
+ * Loader hooks have no meaning here: the worker loader owns resolution.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function register(): never {
+  throw new Error('web-preview: node:module.register is not available in the worker host')
+}
+
+/** ESM/CJS export syncing is a no-op: the worker loader materializes CommonJS only. */
+export function syncBuiltinESMExports(): void {
+  // Nothing to sync: every builtin is a plain module object from the proxy table.
+}
+
+/** Erased type peer for the vendored loader's type-only LoadHookContext import. */
+export type LoadHookContext = never
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:module` declarations this module stands in for. `createRequire`
+ * keeps this module's own face: the loader's require carries the call and
+ * `resolve` the harness uses, not Node's `cache`, `extensions`, and `main`,
+ * which describe a CommonJS module registry the worker has no counterpart for.
+ */
+type NodeFace = Partial<Omit<typeof import('node:module'), 'createRequire'>> & Record<'createRequire', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  createRequire, builtinModules, isBuiltin, register, syncBuiltinESMExports, stripTypeScriptTypes,
+} satisfies NodeFace

+ 118 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/os.ts

@@ -0,0 +1,118 @@
+/**
+ * `node:os` for the worker: every value points into the VFS or reports the fixed
+ * platform identity the host tree is built for (`linux`, one CPU). Values are
+ * real rather than throwing because several `[Service.init]` bodies read them
+ * during construction.
+ */
+import { DSH_HOME, DSH_TMP } from '../../../storage/paths.ts'
+import type { CpuInfo, NetworkInterfaceInfo } from 'node:os'
+
+/** Line ending of the virtual platform. */
+export const EOL = '\n'
+
+/**
+ * Temporary directory.
+ * @returns the VFS temp path.
+ */
+export function tmpdir(): string {
+  return DSH_TMP
+}
+
+/**
+ * Home directory.
+ * @returns `$DSH_HOME` inside the VFS.
+ */
+export function homedir(): string {
+  return DSH_HOME
+}
+
+/**
+ * Platform identity.
+ * @returns always 'linux'.
+ */
+export function platform(): NodeJS.Platform {
+  return 'linux'
+}
+
+/**
+ * Operating-system type.
+ * @returns always 'Linux'.
+ */
+export function type(): string {
+  return 'Linux'
+}
+
+/**
+ * CPU architecture.
+ * @returns always 'x64'.
+ */
+export function arch(): string {
+  return 'x64'
+}
+
+/**
+ * Kernel release.
+ * @returns a synthetic release string.
+ */
+export function release(): string {
+  return '0.0.0-dsh-worker'
+}
+
+/**
+ * Host name.
+ * @returns a synthetic name.
+ */
+export function hostname(): string {
+  return 'dsh-worker'
+}
+
+/**
+ * Usable parallelism.
+ * @returns the browser's hardware concurrency, at least 1.
+ */
+export function availableParallelism(): number {
+  return Math.max(1, navigator.hardwareConcurrency)
+}
+
+/**
+ * CPU inventory.
+ * @returns an empty list (no per-core facts inside a worker).
+ */
+export function cpus(): CpuInfo[] {
+  return []
+}
+
+/**
+ * Network interfaces.
+ * @returns an empty record — the worker webserver binds the loopback literal, so
+ * no LAN address is ever derived.
+ */
+export function networkInterfaces(): NodeJS.Dict<NetworkInterfaceInfo[]> {
+  return {}
+}
+
+/** OS constants: only the signal table is read (terminal signal name mapping). */
+export const constants = {
+  signals: {
+    SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGILL: 4, SIGTRAP: 5, SIGABRT: 6, SIGBUS: 7, SIGFPE: 8,
+    SIGKILL: 9, SIGUSR1: 10, SIGSEGV: 11, SIGUSR2: 12, SIGPIPE: 13, SIGALRM: 14, SIGTERM: 15,
+  },
+  errno: {},
+  priority: {},
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:os` declarations this module stands in for. `constants` keeps this
+ * module's own value: Node declares the full `errno`, `priority`, and `dlopen`
+ * tables, while only the signal-name mapping is read here.
+ */
+type NodeFace = Partial<Omit<typeof import('node:os'), 'constants'>> & Record<'constants', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  EOL, tmpdir, homedir, platform, type, arch, release, hostname, availableParallelism, cpus,
+  networkInterfaces, constants,
+} satisfies NodeFace

+ 396 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/path.ts

@@ -0,0 +1,396 @@
+/**
+ * `node:path` for the worker: the POSIX algorithm, transliterated from Node's
+ * implementation. It is NOT a face over the worker host's `posixPath`: that helper
+ * normalizes before splitting, so `dirname('/a/b/..')` answers `/` where Node
+ * answers `/a/b` (45 cases diverge — `.artifacts/p2/path-diff.ts` enumerates them).
+ * A `node:` proxy has to answer what Node answers, since VFS paths were built with
+ * Node semantics. `win32` members throw: the worker host reports
+ * `process.platform === 'linux'`, so a Windows branch means a bug.
+ */
+import { DSH_ROOT } from '../../../storage/paths.ts'
+
+const CHAR_DOT = 46
+const CHAR_FORWARD_SLASH = 47
+
+/** Parsed path object returned by {@link parse}. */
+export interface ParsedPath {
+  root: string
+  dir: string
+  base: string
+  ext: string
+  name: string
+}
+
+const cwd = (): string => {
+  const scope = globalThis as { process?: { cwd?: () => string } }
+  return scope.process?.cwd?.() ?? DSH_ROOT
+}
+
+function assertPath(path: unknown): asserts path is string {
+  if (typeof path !== 'string') {
+    throw new TypeError(`Path must be a string. Received ${JSON.stringify(path)}`)
+  }
+}
+
+/** Resolve `.` and `..` segments; `allowAboveRoot` keeps leading `..` for relative inputs. */
+function normalizeString(path: string, allowAboveRoot: boolean): string {
+  let res = ''
+  let lastSegmentLength = 0
+  let lastSlash = -1
+  let dots = 0
+  let code = 0
+  for (let i = 0; i <= path.length; ++i) {
+    if (i < path.length) code = path.charCodeAt(i)
+    else if (code === CHAR_FORWARD_SLASH) break
+    else code = CHAR_FORWARD_SLASH
+    if (code === CHAR_FORWARD_SLASH) {
+      if (lastSlash === i - 1 || dots === 1) {
+        // empty segment or `.`
+      } else if (dots === 2) {
+        if (res.length < 2 || lastSegmentLength !== 2
+          || res.charCodeAt(res.length - 1) !== CHAR_DOT
+          || res.charCodeAt(res.length - 2) !== CHAR_DOT) {
+          if (res.length > 2) {
+            const lastSlashIndex = res.lastIndexOf('/')
+            if (lastSlashIndex === -1) {
+              res = ''
+              lastSegmentLength = 0
+            } else {
+              res = res.slice(0, lastSlashIndex)
+              lastSegmentLength = res.length - 1 - res.lastIndexOf('/')
+            }
+            lastSlash = i
+            dots = 0
+            continue
+          } else if (res.length !== 0) {
+            res = ''
+            lastSegmentLength = 0
+            lastSlash = i
+            dots = 0
+            continue
+          }
+        }
+        if (allowAboveRoot) {
+          res += res.length > 0 ? '/..' : '..'
+          lastSegmentLength = 2
+        }
+      } else {
+        if (res.length > 0) res += `/${path.slice(lastSlash + 1, i)}`
+        else res = path.slice(lastSlash + 1, i)
+        lastSegmentLength = i - lastSlash - 1
+      }
+      lastSlash = i
+      dots = 0
+    } else if (code === CHAR_DOT && dots !== -1) {
+      ++dots
+    } else {
+      dots = -1
+    }
+  }
+  return res
+}
+
+/**
+ * Resolve a sequence of paths into an absolute path.
+ * @param paths - path segments, right to left until an absolute one is found.
+ * @returns the absolute, normalized path.
+ */
+export function resolve(...paths: string[]): string {
+  let resolved = ''
+  let absolute = false
+  for (let i = paths.length - 1; i >= 0 && !absolute; i--) {
+    const path = paths[i]
+    assertPath(path)
+    if (path.length === 0) continue
+    resolved = resolved.length === 0 ? path : `${path}/${resolved}`
+    absolute = path.charCodeAt(0) === CHAR_FORWARD_SLASH
+  }
+  if (!absolute) {
+    const base = cwd()
+    resolved = resolved.length === 0 ? base : `${base}/${resolved}`
+    absolute = base.charCodeAt(0) === CHAR_FORWARD_SLASH
+  }
+  const normalized = normalizeString(resolved, !absolute)
+  if (absolute) return `/${normalized}`
+  return normalized.length > 0 ? normalized : '.'
+}
+
+/**
+ * Normalize a path, resolving `.`, `..`, and duplicate separators.
+ * @param path - the path.
+ * @returns the normalized path.
+ */
+export function normalize(path: string): string {
+  assertPath(path)
+  if (path.length === 0) return '.'
+  const isAbsolutePath = path.charCodeAt(0) === CHAR_FORWARD_SLASH
+  const trailingSeparator = path.charCodeAt(path.length - 1) === CHAR_FORWARD_SLASH
+  let normalized = normalizeString(path, !isAbsolutePath)
+  if (normalized.length === 0) {
+    if (isAbsolutePath) return '/'
+    return trailingSeparator ? './' : '.'
+  }
+  if (trailingSeparator) normalized += '/'
+  return isAbsolutePath ? `/${normalized}` : normalized
+}
+
+/**
+ * Whether the path is absolute.
+ * @param path - the path.
+ * @returns true when it starts at the root.
+ */
+export function isAbsolute(path: string): boolean {
+  assertPath(path)
+  return path.length > 0 && path.charCodeAt(0) === CHAR_FORWARD_SLASH
+}
+
+/**
+ * Join path segments with the separator, then normalize.
+ * @param paths - the segments.
+ * @returns the joined path.
+ */
+export function join(...paths: string[]): string {
+  if (paths.length === 0) return '.'
+  let joined: string | undefined
+  for (const path of paths) {
+    assertPath(path)
+    if (path.length === 0) continue
+    joined = joined === undefined ? path : `${joined}/${path}`
+  }
+  return joined === undefined ? '.' : normalize(joined)
+}
+
+/**
+ * Relative path from one location to another.
+ * @param from - source path.
+ * @param to - target path.
+ * @returns the relative path, or '' when both resolve identically.
+ */
+export function relative(from: string, to: string): string {
+  assertPath(from)
+  assertPath(to)
+  if (from === to) return ''
+  const fromResolved = resolve(from)
+  const toResolved = resolve(to)
+  if (fromResolved === toResolved) return ''
+  const fromParts = fromResolved.split('/').filter(part => part.length > 0)
+  const toParts = toResolved.split('/').filter(part => part.length > 0)
+  let shared = 0
+  while (shared < fromParts.length && shared < toParts.length && fromParts[shared] === toParts[shared]) shared++
+  const up = Array.from({ length: fromParts.length - shared }, () => '..')
+  return [...up, ...toParts.slice(shared)].join('/')
+}
+
+/**
+ * Directory portion of a path (lexical, as Node defines it: no normalization).
+ * @param path - the path.
+ * @returns the parent directory.
+ */
+export function dirname(path: string): string {
+  assertPath(path)
+  if (path.length === 0) return '.'
+  const hasRoot = path.charCodeAt(0) === CHAR_FORWARD_SLASH
+  let end = -1
+  let matchedSlash = true
+  for (let i = path.length - 1; i >= 1; --i) {
+    if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) {
+      if (!matchedSlash) {
+        end = i
+        break
+      }
+    } else {
+      matchedSlash = false
+    }
+  }
+  if (end === -1) return hasRoot ? '/' : '.'
+  if (hasRoot && end === 1) return '//'
+  return path.slice(0, end)
+}
+
+/**
+ * Last portion of a path, optionally without a suffix (lexical, as in Node).
+ * @param path - the path.
+ * @param suffix - extension to strip when the base ends with it.
+ * @returns the base name.
+ */
+export function basename(path: string, suffix?: string): string {
+  assertPath(path)
+  let start = 0
+  let end = -1
+  let matchedSlash = true
+  if (suffix !== undefined && suffix.length > 0 && suffix.length <= path.length) {
+    if (suffix === path) return ''
+    let extIdx = suffix.length - 1
+    let firstNonSlashEnd = -1
+    for (let i = path.length - 1; i >= 0; --i) {
+      const code = path.charCodeAt(i)
+      if (code === CHAR_FORWARD_SLASH) {
+        if (!matchedSlash) {
+          start = i + 1
+          break
+        }
+        continue
+      }
+      if (firstNonSlashEnd === -1) {
+        matchedSlash = false
+        firstNonSlashEnd = i + 1
+      }
+      if (extIdx >= 0) {
+        if (code === suffix.charCodeAt(extIdx)) {
+          if (--extIdx === -1) end = i
+        } else {
+          extIdx = -1
+          end = firstNonSlashEnd
+        }
+      }
+    }
+    if (start === end) end = firstNonSlashEnd
+    else if (end === -1) end = path.length
+    return path.slice(start, end)
+  }
+  for (let i = path.length - 1; i >= 0; --i) {
+    if (path.charCodeAt(i) === CHAR_FORWARD_SLASH) {
+      if (!matchedSlash) {
+        start = i + 1
+        break
+      }
+    } else if (end === -1) {
+      matchedSlash = false
+      end = i + 1
+    }
+  }
+  return end === -1 ? '' : path.slice(start, end)
+}
+
+/**
+ * Extension of the last path segment, including the leading dot.
+ * @param path - the path.
+ * @returns the extension, or '' when there is none.
+ */
+export function extname(path: string): string {
+  assertPath(path)
+  let startDot = -1
+  let startPart = 0
+  let end = -1
+  let matchedSlash = true
+  let preDotState = 0
+  for (let i = path.length - 1; i >= 0; --i) {
+    const code = path.charCodeAt(i)
+    if (code === CHAR_FORWARD_SLASH) {
+      if (!matchedSlash) {
+        startPart = i + 1
+        break
+      }
+      continue
+    }
+    if (end === -1) {
+      matchedSlash = false
+      end = i + 1
+    }
+    if (code === CHAR_DOT) {
+      if (startDot === -1) startDot = i
+      else if (preDotState !== 1) preDotState = 1
+    } else if (startDot !== -1) {
+      preDotState = -1
+    }
+  }
+  if (startDot === -1 || end === -1 || preDotState === 0
+    || (preDotState === 1 && startDot === end - 1 && startDot === startPart + 1)) {
+    return ''
+  }
+  return path.slice(startDot, end)
+}
+
+/**
+ * Build a path from its parsed parts.
+ * @param pathObject - dir/root/base/name/ext parts.
+ * @returns the assembled path.
+ */
+export function format(pathObject: Partial<ParsedPath>): string {
+  const dir = pathObject.dir ?? pathObject.root ?? ''
+  const base = pathObject.base ?? `${pathObject.name ?? ''}${pathObject.ext ?? ''}`
+  if (dir === '') return base
+  return dir === pathObject.root ? `${dir}${base}` : `${dir}/${base}`
+}
+
+/**
+ * Split a path into root/dir/base/ext/name (lexical, as in Node).
+ * @param path - the path.
+ * @returns the parsed parts.
+ */
+export function parse(path: string): ParsedPath {
+  assertPath(path)
+  const base = basename(path)
+  const ext = extname(path)
+  const trimmed = path.length > 1 ? path.replace(/\/+$/, '') : path
+  const lastSlash = trimmed.lastIndexOf('/')
+  const root = isAbsolute(path) ? '/' : ''
+  return {
+    root,
+    dir: trimmed === '' ? root : lastSlash === -1 ? '' : lastSlash === 0 ? '/' : trimmed.slice(0, lastSlash),
+    base,
+    ext,
+    name: ext.length > 0 ? base.slice(0, base.length - ext.length) : base,
+  }
+}
+
+/** POSIX path separator. */
+export const sep = '/' as const
+
+/** POSIX path-list delimiter. */
+export const delimiter = ':' as const
+
+/**
+ * Windows namespace prefixes do not exist here.
+ * @param path - the path.
+ * @returns the path unchanged.
+ */
+export function toNamespacedPath(path: string): string {
+  return path
+}
+
+const posixFace = {
+  resolve, normalize, isAbsolute, join, relative, dirname, basename, extname, format, parse,
+  sep, delimiter, toNamespacedPath,
+}
+
+/** POSIX member set: the module face, plus Node's self-referential namespaces. */
+export const posix: typeof posixFace & { readonly posix: unknown; readonly win32: unknown } = {
+  ...posixFace,
+  get posix(): unknown { return posix },
+  get win32(): unknown { return win32 },
+}
+
+const win32Member = (name: string) => (): never => {
+  throw new Error(`web-preview: node:path.win32.${name} is unreachable — the worker host reports platform "linux"`)
+}
+
+/** Windows member set: reaching it means a platform branch went the wrong way. */
+export const win32 = {
+  resolve: win32Member('resolve'),
+  normalize: win32Member('normalize'),
+  isAbsolute: win32Member('isAbsolute'),
+  join: win32Member('join'),
+  relative: win32Member('relative'),
+  dirname: win32Member('dirname'),
+  basename: win32Member('basename'),
+  extname: win32Member('extname'),
+  format: win32Member('format'),
+  parse: win32Member('parse'),
+  toNamespacedPath: win32Member('toNamespacedPath'),
+  sep: '\\',
+  delimiter: ';',
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:path` declarations this module stands in for. The two platform
+ * namespaces stay unknown-typed: `posix` is this module reached through itself,
+ * and `win32` holds throwing members rather than Node's `PlatformPath`, because
+ * the worker host reports `linux` and a Windows branch is a bug.
+ */
+type NodeFace = Partial<Omit<typeof import('node:path'), 'posix' | 'win32'>> & Record<'posix' | 'win32', unknown>
+
+export default posix satisfies NodeFace

+ 27 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/perf_hooks.ts

@@ -0,0 +1,27 @@
+/**
+ * `node:perf_hooks`: the worker's own high-resolution clock.
+ */
+import { notImplementedFail } from '../../notImplementedFail.ts'
+
+const MODULE = 'node:perf_hooks'
+
+/** Same clock object the worker global exposes. */
+export const performance = globalThis.performance
+
+/** Observation of performance entries has no consumer here. */
+export const PerformanceObserver: typeof import('node:perf_hooks').PerformanceObserver
+  = notImplementedFail(MODULE, 'PerformanceObserver')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:perf_hooks` declarations this module stands in for. `performance`
+ * keeps the worker's own clock: Node declares its clock with `nodeTiming`,
+ * `timerify`, and event-loop utilization, none of which a browser `Performance`
+ * object carries.
+ */
+type NodeFace = Partial<Omit<typeof import('node:perf_hooks'), 'performance'>> & Record<'performance', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { performance, PerformanceObserver } satisfies NodeFace

+ 60 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/timers/promises.ts

@@ -0,0 +1,60 @@
+/**
+ * `node:timers/promises`: real implementations over the worker's timer globals.
+ */
+import type { TimerOptions } from 'node:timers'
+
+/** The rejection an aborted wait reports, as Node and the DOM both spell it. */
+const abortError = (): DOMException => new DOMException('The operation was aborted.', 'AbortError')
+
+/**
+ * Resolve after a delay.
+ * @param delayMs - milliseconds to wait.
+ * @param value - value to resolve with; Node resolves undefined when none is handed in.
+ * @param options - abort support, as Node provides.
+ * @returns the value after the delay, or a rejection when the signal aborts.
+ */
+export function setTimeout<T = void>(
+  delayMs?: number,
+  value?: T,
+  options?: TimerOptions,
+): Promise<T> {
+  return new Promise((resolve, reject) => {
+    // A signal that has already aborted emits no further `abort` event, so the
+    // timer must not be armed at all; Node rejects such a call straight away.
+    if (options?.signal?.aborted === true) {
+      reject(abortError())
+      return
+    }
+    const timer = globalThis.setTimeout(() => { resolve(value as T) }, delayMs)
+    options?.signal?.addEventListener('abort', () => {
+      globalThis.clearTimeout(timer)
+      reject(abortError())
+    }, { once: true })
+  })
+}
+
+/**
+ * Resolve on the next macrotask.
+ * @param value - resolution value handed back after the timer.
+ * @returns a promise resolved after a zero-delay timer.
+ */
+export function setImmediate<T = void>(value?: T): Promise<T> {
+  return setTimeout(0, value)
+}
+
+/** Cooperative scheduling helpers Node exposes on this module. */
+export const scheduler = {
+  wait: async (delayMs?: number, options?: TimerOptions): Promise<void> => {
+    await setTimeout(delayMs, undefined, options)
+  },
+  yield: async (): Promise<void> => { await setTimeout(0) },
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** The `node:timers/promises` declarations this module stands in for. */
+type NodeFace = Partial<typeof import('node:timers/promises')>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { setTimeout, setImmediate, scheduler } satisfies NodeFace

+ 73 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/url.ts

@@ -0,0 +1,73 @@
+/**
+ * `node:url` for the worker: the two conversions the host tree uses, plus the
+ * WHATWG classes the browser already provides. VFS paths are POSIX, so the
+ * file-URL mapping is the simple percent-encoding pair.
+ */
+
+/**
+ * Filesystem path of a `file:` URL.
+ * @param url - file URL or its string form.
+ * @returns the decoded POSIX path.
+ */
+export function fileURLToPath(url: string | URL): string {
+  const parsed = typeof url === 'string' ? new URL(url) : url
+  if (parsed.protocol !== 'file:') {
+    throw new TypeError(`The URL must be of scheme file (received ${parsed.protocol})`)
+  }
+  return decodeURIComponent(parsed.pathname)
+}
+
+/**
+ * `file:` URL of a filesystem path.
+ * @param path - absolute or relative POSIX path.
+ * @returns the URL.
+ */
+export function pathToFileURL(path: string): URL {
+  // Only the characters the URL path parser would not escape itself are escaped
+  // here (Node does the same), so `@`, `:` and `~` survive verbatim — scoped
+  // package directories must round-trip unchanged.
+  const escaped = path
+    .replaceAll('%', '%25')
+    .replaceAll('\\', '%5C')
+    .replaceAll('\n', '%0A')
+    .replaceAll('\r', '%0D')
+    .replaceAll('\t', '%09')
+  const url = new globalThis.URL('file:///')
+  url.pathname = escaped.startsWith('/') ? escaped : `/${escaped}`
+  return url
+}
+
+/**
+ * Absolute URL from a specifier and its base.
+ * @param specifier - relative or absolute specifier.
+ * @param base - base URL.
+ * @returns the resolved URL string.
+ */
+export function resolve(specifier: string, base: string): string {
+  return new URL(specifier, base).toString()
+}
+
+/** WHATWG URL class, as `node:url` re-exports it. */
+const UrlClass = globalThis.URL
+
+/** WHATWG URLSearchParams class, as `node:url` re-exports it. */
+const UrlSearchParamsClass = globalThis.URLSearchParams
+
+export { UrlClass as URL, UrlSearchParamsClass as URLSearchParams }
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:url` declarations this module stands in for. The two classes stay
+ * the browser globals this worker runs on: the DOM and Node libraries declare
+ * `URL.createObjectURL` and the `URLSearchParams` initializer union differently,
+ * and re-declaring either would replace the objects the platform hands out.
+ */
+type NodeFace = Partial<Omit<typeof import('node:url'), 'URL' | 'URLSearchParams'>>
+  & Record<'URL' | 'URLSearchParams', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  fileURLToPath, pathToFileURL, resolve, URL: UrlClass, URLSearchParams: UrlSearchParamsClass,
+} satisfies NodeFace

+ 156 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/util.ts

@@ -0,0 +1,156 @@
+/**
+ * `node:util` for the worker: the members harness code actually imports. Node's
+ * inspect output is only used in diagnostics, so a JSON-shaped rendering is
+ * enough; `promisify` follows Node's error-first callback convention exactly
+ * because zlib-style APIs are wrapped with it at module scope.
+ */
+
+/**
+ * Wrap an error-first callback function as a promise-returning one.
+ * @param fn - callback-style function.
+ * @returns the promise-returning wrapper.
+ */
+export function promisify<A extends unknown[], R>(
+  fn: (...args: [...A, (error: unknown, value: R) => void]) => void,
+): (...args: A) => Promise<R> {
+  return (...args: A) => new Promise<R>((resolve, reject) => {
+    fn(...args, (error: unknown, value: R) => {
+      if (error !== null && error !== undefined) reject(error instanceof Error ? error : new Error(inspect(error)))
+      else resolve(value)
+    })
+  })
+}
+
+/**
+ * Wrap a promise-returning function as an error-first callback one.
+ * @param fn - promise-returning function.
+ * @returns the callback-style wrapper.
+ */
+export function callbackify<A extends unknown[], R>(
+  fn: (...args: A) => Promise<R>,
+): (...args: [...A, (error: unknown, value?: R) => void]) => void {
+  return (...args) => {
+    const callback = args.at(-1) as (error: unknown, value?: R) => void
+    const rest = args.slice(0, -1) as unknown as A
+    fn(...rest).then((value) => { callback(null, value) }, (error: unknown) => { callback(error) })
+  }
+}
+
+/**
+ * Diagnostic rendering of a value.
+ * @param value - the value.
+ * @returns a readable one-line rendering.
+ */
+export function inspect(value: unknown): string {
+  if (typeof value === 'string') return `'${value}'`
+  if (value instanceof Error) return value.stack ?? `${value.name}: ${value.message}`
+  try {
+    // `JSON.stringify` is typed as returning a string but answers undefined for
+    // undefined, functions, and symbols.
+    const rendered = JSON.stringify(value, (_key, item: unknown) =>
+      typeof item === 'bigint' ? item.toString() : item) as string | undefined
+    return rendered ?? String(value)
+  } catch {
+    // Cyclic or otherwise unserializable values still need a rendering.
+    return String(value)
+  }
+}
+
+/**
+ * printf-style formatting for the `%s`/`%d`/`%j`/`%o` placeholders Node supports.
+ * @param template - format string, or any value when used without placeholders.
+ * @param args - substitution values.
+ * @returns the formatted string.
+ */
+export function format(template: unknown, ...args: unknown[]): string {
+  if (typeof template !== 'string') return [template, ...args].map(value => inspect(value)).join(' ')
+  let index = 0
+  const substituted = template.replaceAll(/%[sdifjoO%]/g, (token) => {
+    if (token === '%%') return '%'
+    if (index >= args.length) return token
+    const value = args[index++]
+    if (token === '%d' || token === '%i') return String(Number(value))
+    if (token === '%f') return String(Number(value))
+    if (token === '%s') return typeof value === 'string' ? value : inspect(value)
+    return inspect(value)
+  })
+  const rest = args.slice(index)
+  return rest.length === 0 ? substituted : `${substituted} ${rest.map(value => inspect(value)).join(' ')}`
+}
+
+/**
+ * Structural deep equality, as `isDeepStrictEqual` defines it for plain data.
+ * @param left - first value.
+ * @param right - second value.
+ * @returns true when both sides are structurally identical.
+ */
+export function isDeepStrictEqual(left: unknown, right: unknown): boolean {
+  /* jscpd:ignore-start -- the walk necessarily matches credentials-local's
+     sameJsonValue (both are structural equality over plain data); a shared
+     helper would couple the self-contained builtin face packed into the worker
+     image to a host package. */
+  if (Object.is(left, right)) return true
+  if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false
+  if (Array.isArray(left) !== Array.isArray(right)) return false
+  const leftKeys = Object.keys(left)
+  const rightKeys = Object.keys(right)
+  if (leftKeys.length !== rightKeys.length) return false
+  return leftKeys.every(key => key in right
+    && isDeepStrictEqual((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]))
+  /* jscpd:ignore-end */
+}
+
+/** Runtime type predicates (`node:util/types`), checked against the Node module of that name. */
+export const types = {
+  isPromise: (value: unknown): value is Promise<unknown> => value instanceof Promise
+    || (typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function'),
+  isDate: (value: unknown): value is Date => value instanceof Date,
+  isRegExp: (value: unknown): value is RegExp => value instanceof RegExp,
+  // Node counts only the integer and float views, so a DataView answers false.
+  isTypedArray: (value: unknown): value is NodeJS.TypedArray => ArrayBuffer.isView(value) && !(value instanceof DataView),
+} satisfies Partial<typeof import('node:util/types')>
+
+/**
+ * CLI argument parsing has no caller inside the worker host.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function parseArgs(): never {
+  throw new Error('web-preview: node:util.parseArgs is not available in the worker host')
+}
+
+/**
+ * Deprecation wrappers pass the function through unchanged.
+ * @param fn - the function a caller wanted wrapped.
+ * @returns The same function, unwrapped.
+ */
+export function deprecate<F>(fn: F): F {
+  return fn
+}
+
+/** Text decoder class, as `node:util` re-exports it. */
+const TextDecoderClass = globalThis.TextDecoder
+
+/** Text encoder class, as `node:util` re-exports it. */
+const TextEncoderClass = globalThis.TextEncoder
+
+export { TextDecoderClass as TextDecoder, TextEncoderClass as TextEncoder }
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:util` declarations this module stands in for. Five members keep this
+ * module's own types: `promisify`, `callbackify`, and `inspect` are the plain
+ * conversions the harness calls, without Node's overload ladders and the
+ * `custom`/`styles`/`defaultOptions` members hung off them; `types` publishes the
+ * four predicates in use rather than Node's forty; and `TextDecoder` is the DOM
+ * class, whose `decode` input union the Node declaration does not accept.
+ */
+type NodeFace = Partial<Omit<typeof import('node:util'), 'promisify' | 'callbackify' | 'inspect' | 'types' | 'TextDecoder'>>
+  & Record<'promisify' | 'callbackify' | 'inspect' | 'types' | 'TextDecoder', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  promisify, callbackify, inspect, format, isDeepStrictEqual, types, parseArgs, deprecate,
+  TextDecoder: TextDecoderClass, TextEncoder: TextEncoderClass,
+} satisfies NodeFace

+ 14 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/util/types.ts

@@ -0,0 +1,14 @@
+/**
+ * `node:util/types` face: the predicate subset, re-exported from the util shim so
+ * both specifiers share one implementation. The predicates are checked against
+ * Node where they are built, on `types` in `../util.ts`.
+ */
+import { types } from '../util.ts'
+
+/** The `node:util/types` predicates the harness reads, shared with the util shim. */
+export const { isPromise, isDate, isRegExp, isTypedArray } = types
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+export default types

+ 85 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/implemented/zlib.ts

@@ -0,0 +1,85 @@
+/**
+ * `node:zlib` for the worker. The worker composition carries no compression
+ * codec: the boot patch forces the JSONL session backend onto its plaintext
+ * path (`compression: 'none'`), because the VFS is in-memory and compressing
+ * it buys nothing. The Zstandard surface keeps its module-scope shape — the
+ * backend reads `constants` and `promisify`s the callback forms while
+ * loading — and every codec call fails loud, naming the missing capability.
+ *
+ * `createZstdDecompress` returns a handle-less object on purpose: the backend
+ * probes for Node's private stream shape and falls back to its public one-shot
+ * decoder when the probe declines.
+ */
+import { notImplementedFail } from '../../notImplementedFail.ts'
+
+const MODULE = 'node:zlib'
+
+/** Zstandard parameter/flush constants read at module scope by the JSONL backend. */
+export const constants = {
+  ZSTD_c_compressionLevel: 100,
+  ZSTD_c_checksumFlag: 201,
+  ZSTD_e_continue: 0,
+  ZSTD_e_flush: 1,
+  ZSTD_e_end: 2,
+  ZSTD_CLEVEL_DEFAULT: 3,
+  Z_NO_FLUSH: 0,
+  Z_SYNC_FLUSH: 2,
+  Z_FINISH: 4,
+}
+
+/** One-shot Zstandard compression (unavailable; the composition writes plaintext logs). */
+export const zstdCompressSync: typeof import('node:zlib').zstdCompressSync = notImplementedFail(MODULE, 'zstdCompressSync')
+
+/** One-shot Zstandard decompression (unavailable; the worker never reads compressed logs). */
+export const zstdDecompressSync: typeof import('node:zlib').zstdDecompressSync
+  = notImplementedFail(MODULE, 'zstdDecompressSync')
+
+/** Callback form of {@link zstdCompressSync} (`promisify`'d at module scope by the backend). */
+export const zstdCompress: typeof import('node:zlib').zstdCompress = notImplementedFail(MODULE, 'zstdCompress')
+
+/** Callback form of {@link zstdDecompressSync}. */
+export const zstdDecompress: typeof import('node:zlib').zstdDecompress = notImplementedFail(MODULE, 'zstdDecompress')
+
+/**
+ * Streaming Zstandard decoder placeholder: the returned object deliberately
+ * lacks Node's private `_handle`/`_writeState` members, which is the signal the
+ * backend's private-shape probe checks before choosing that path.
+ * @returns the incompatible placeholder stream.
+ */
+export function createZstdDecompress(): Record<string, unknown> {
+  return { close: () => { /* nothing was opened */ } }
+}
+
+/** Streaming Zstandard encoder (unavailable; the backend only needs one-shot). */
+export const createZstdCompress: typeof import('node:zlib').createZstdCompress
+  = notImplementedFail(MODULE, 'createZstdCompress')
+
+/** gzip family (unavailable; no consumer in the reachable tree). */
+export const gzip: typeof import('node:zlib').gzip = notImplementedFail(MODULE, 'gzip')
+
+/** gzip sync counterpart. */
+export const gzipSync: typeof import('node:zlib').gzipSync = notImplementedFail(MODULE, 'gzipSync')
+
+/** gunzip counterpart. */
+export const gunzip: typeof import('node:zlib').gunzip = notImplementedFail(MODULE, 'gunzip')
+
+/** gunzip sync counterpart. */
+export const gunzipSync: typeof import('node:zlib').gunzipSync = notImplementedFail(MODULE, 'gunzipSync')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:zlib` declarations this module stands in for. Two members keep this
+ * module's own types: `constants` carries only the Zstandard and flush values the
+ * JSONL backend reads, and `createZstdDecompress` answers the placeholder the
+ * same backend's private-shape probe must decline.
+ */
+type NodeFace = Partial<Omit<typeof import('node:zlib'), 'constants' | 'createZstdDecompress'>>
+  & Record<'constants' | 'createZstdDecompress', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  constants, zstdCompress, zstdCompressSync, zstdDecompress, zstdDecompressSync,
+  createZstdCompress, createZstdDecompress, gzip, gzipSync, gunzip, gunzipSync,
+} satisfies NodeFace

+ 90 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/mock/net.ts

@@ -0,0 +1,90 @@
+/**
+ * `node:net` for the worker. Nothing accepts or dials a socket here: the fake
+ * HTTP server never emits `upgrade`, so only the address predicates and a
+ * constructible-but-loud Socket are reachable.
+ */
+
+const IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/
+const IPV6 = /^[0-9a-f:]+$/i
+
+/** Constructible placeholder: the WebSocket upgrade path never runs in the worker. */
+export class Socket {
+  /**
+   * Sockets are never written to; reaching this means an upgrade path activated.
+   * @returns Never — it throws naming the unavailable member.
+   */
+  write(): never {
+    throw new Error('web-preview: node:net Socket.write is not available in the worker host')
+  }
+
+  /**
+   * Counterpart of {@link write}.
+   * @returns Never — it throws naming the unavailable member.
+   */
+  end(): never {
+    throw new Error('web-preview: node:net Socket.end is not available in the worker host')
+  }
+
+  /** Teardown is accepted so disposal paths stay quiet. */
+  destroy(): void {
+    // No resource was ever held.
+  }
+}
+
+/**
+ * Whether a string is an IPv4 literal.
+ * @param value - candidate.
+ * @returns true for dotted-quad literals.
+ */
+export function isIPv4(value: string): boolean {
+  return IPV4.test(value) && value.split('.').every(part => Number(part) <= 255)
+}
+
+/**
+ * Whether a string is an IPv6 literal.
+ * @param value - candidate.
+ * @returns true for colon-hex literals.
+ */
+export function isIPv6(value: string): boolean {
+  return value.includes(':') && IPV6.test(value)
+}
+
+/**
+ * IP family of a literal.
+ * @param value - candidate.
+ * @returns 4, 6, or 0 when it is not an IP literal.
+ */
+export function isIP(value: string): number {
+  if (isIPv4(value)) return 4
+  if (isIPv6(value)) return 6
+  return 0
+}
+
+/**
+ * TCP listening is the fake HTTP server's business; a bare net server is unreachable.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function createServer(): never {
+  throw new Error('web-preview: node:net.createServer is not available in the worker host')
+}
+
+/**
+ * Outbound connections have no carrier in a worker.
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function connect(): never {
+  throw new Error('web-preview: node:net.connect is not available in the worker host')
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/**
+ * The `node:net` declarations this module stands in for. `Socket` keeps this
+ * module's own class: Node declares it as a duplex stream, and a placeholder
+ * that holds no connection has no stream state to expose.
+ */
+type NodeFace = Partial<Omit<typeof import('node:net'), 'Socket'>> & Record<'Socket', unknown>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { Socket, isIP, isIPv4, isIPv6, createServer, connect } satisfies NodeFace

+ 31 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/mock/sqlite.ts

@@ -0,0 +1,31 @@
+/**
+ * `node:sqlite` stub. The web profile configures session-query-sqlite with
+ * `:memory:` and `openAt: never`, so no database is opened during the acceptance
+ * chain; reaching the constructor means that configuration changed.
+ */
+import { notAvailableError, notImplementedFail } from '../../notImplementedFail.ts'
+
+const MODULE = 'node:sqlite'
+
+/** Synchronous database handle (unavailable). */
+export const DatabaseSync: typeof import('node:sqlite').DatabaseSync = notImplementedFail(MODULE, 'DatabaseSync')
+
+/** Prepared statement handle (unavailable). */
+export const StatementSync: typeof import('node:sqlite').StatementSync = notImplementedFail(MODULE, 'StatementSync')
+
+/**
+ * Backup helper (unavailable).
+ * @returns Never — it throws naming the unavailable member.
+ */
+export function backup(): never {
+  throw notAvailableError(MODULE, 'backup')
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** The `node:sqlite` declarations this module stands in for. */
+type NodeFace = Partial<typeof import('node:sqlite')>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { DatabaseSync, StatementSync, backup } satisfies NodeFace

+ 38 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/mock/stream.ts

@@ -0,0 +1,38 @@
+/**
+ * `node:stream` stub. Every harness import of this module in the reachable tree
+ * is type-only (`Duplex`/`Readable`/`Writable` annotations), so nothing here runs
+ * unless a value import appears; then it says so.
+ */
+import { notImplementedFail } from '../../notImplementedFail.ts'
+
+const MODULE = 'node:stream'
+
+/** Readable stream (unavailable; use WHATWG ReadableStream). */
+export const Readable: typeof import('node:stream').Readable = notImplementedFail(MODULE, 'Readable')
+
+/** Writable stream (unavailable). */
+export const Writable: typeof import('node:stream').Writable = notImplementedFail(MODULE, 'Writable')
+
+/** Duplex stream (unavailable). */
+export const Duplex: typeof import('node:stream').Duplex = notImplementedFail(MODULE, 'Duplex')
+
+/** Transform stream (unavailable). */
+export const Transform: typeof import('node:stream').Transform = notImplementedFail(MODULE, 'Transform')
+
+/** PassThrough stream (unavailable). */
+export const PassThrough: typeof import('node:stream').PassThrough = notImplementedFail(MODULE, 'PassThrough')
+
+/** Pipeline helper (unavailable). */
+export const pipeline: typeof import('node:stream').pipeline = notImplementedFail(MODULE, 'pipeline')
+
+/** Finished helper (unavailable). */
+export const finished: typeof import('node:stream').finished = notImplementedFail(MODULE, 'finished')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** The `node:stream` declarations this module stands in for. */
+type NodeFace = Partial<typeof import('node:stream')>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { Readable, Writable, Duplex, Transform, PassThrough, pipeline, finished } satisfies NodeFace

+ 37 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/mock/vm.ts

@@ -0,0 +1,37 @@
+/**
+ * `node:vm` stub. Script compilation in a separate realm has no browser
+ * counterpart; the self-modification and workflow rows mount and report the gap
+ * when they try to compile.
+ */
+import { notImplementedFail } from '../../notImplementedFail.ts'
+
+const MODULE = 'node:vm'
+
+/** Compiled script (unavailable). */
+export const Script: typeof import('node:vm').Script = notImplementedFail(MODULE, 'Script')
+
+/** Context creation (unavailable). */
+export const createContext: typeof import('node:vm').createContext = notImplementedFail(MODULE, 'createContext')
+
+/** In-context evaluation (unavailable). */
+export const runInContext: typeof import('node:vm').runInContext = notImplementedFail(MODULE, 'runInContext')
+
+/** New-context evaluation (unavailable). */
+export const runInNewContext: typeof import('node:vm').runInNewContext = notImplementedFail(MODULE, 'runInNewContext')
+
+/** This-context evaluation (unavailable). */
+export const runInThisContext: typeof import('node:vm').runInThisContext = notImplementedFail(MODULE, 'runInThisContext')
+
+/** Context predicate (unavailable). */
+export const isContext: typeof import('node:vm').isContext = notImplementedFail(MODULE, 'isContext')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** The `node:vm` declarations this module stands in for. */
+type NodeFace = Partial<typeof import('node:vm')>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  Script, createContext, runInContext, runInNewContext, runInThisContext, isContext,
+} satisfies NodeFace

+ 50 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/mock/worker_threads.ts

@@ -0,0 +1,50 @@
+/**
+ * `node:worker_threads` stub. Nested workers are out of scope for v1, so the
+ * workflow and code-runtime plugin bodies mount and fail on use. The
+ * thread-identity values are real: they say "this is the main thread", which is
+ * what the worker host is from the tree's point of view.
+ */
+import { notImplementedFail } from '../../notImplementedFail.ts'
+
+const MODULE = 'node:worker_threads'
+
+/** Worker-thread construction (unavailable). */
+export const Worker: typeof import('node:worker_threads').Worker = notImplementedFail(MODULE, 'Worker')
+
+/** The host tree runs on the worker's main thread. */
+export const isMainThread = true
+
+/** Thread id of the worker's main thread. */
+export const threadId = 0
+
+/** No parent port exists, which Node reports as `null` outside a worker thread. */
+export const parentPort = null
+
+/** No thread data was handed in. */
+export const workerData = undefined
+
+/** Channel construction (unavailable). */
+export const MessageChannel: typeof import('node:worker_threads').MessageChannel = notImplementedFail(MODULE, 'MessageChannel')
+
+/** Port construction (unavailable). */
+export const MessagePort: typeof import('node:worker_threads').MessagePort = notImplementedFail(MODULE, 'MessagePort')
+
+/** Object transfer marking (unavailable). */
+export const markAsUntransferable: typeof import('node:worker_threads').markAsUntransferable
+  = notImplementedFail(MODULE, 'markAsUntransferable')
+
+/** Port receiving on a message channel (unavailable). */
+export const receiveMessageOnPort: typeof import('node:worker_threads').receiveMessageOnPort
+  = notImplementedFail(MODULE, 'receiveMessageOnPort')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** The `node:worker_threads` declarations this module stands in for. */
+type NodeFace = Partial<typeof import('node:worker_threads')>
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default {
+  Worker, isMainThread, threadId, parentPort, workerData, MessageChannel, MessagePort,
+  markAsUntransferable, receiveMessageOnPort,
+} satisfies NodeFace

+ 124 - 0
packages/experimental/webworker-runtime/src/node/builtins.ts

@@ -0,0 +1,124 @@
+/**
+ * The Node-compatibility table, in one place. Two consumers share it, and they
+ * must resolve to the same module instances:
+ *   - the worker vite build aliases these specifiers for code bundled statically
+ *     into the worker (vendored loader, apiproxy, …);
+ *   - the worker module loader answers `require('node:fs')` from VFS-loaded
+ *     modules out of this table, before bare-name resolution.
+ * Anything absent here fails loudly at resolution instead of resolving to an
+ * empty module. `process` is deliberately absent: the worker host installs that
+ * global itself and fills it into this table at assembly time.
+ *
+ * Import paths carry the classification: `./implemented/<module>.ts` backs the
+ * module's real semantics over a worker data source, while `./mock/<module>.ts`
+ * is a structural placeholder whose calls report the missing capability. File
+ * names match their Node module specifiers exactly, nesting included.
+ *
+ * Every value is a {@link StaticModuleFactory}, so the loader reads a table
+ * entry only when a `require` names that specifier. What a factory defers is the
+ * table read, not module evaluation: each one answers a namespace object of the
+ * static ESM graph below, which the worker bundle evaluates at load like any
+ * other import. Deferring a shim's own start-up cost therefore belongs inside
+ * that shim, on the path that first needs it.
+ */
+import * as nodeAsyncHooks from './builtin_modules/implemented/async_hooks.ts'
+import * as nodeBuffer from './builtin_modules/implemented/buffer.ts'
+import * as nodeCrypto from './builtin_modules/implemented/crypto.ts'
+import * as nodeEvents from './builtin_modules/implemented/events.ts'
+import * as nodeFs from './builtin_modules/implemented/fs.ts'
+import * as nodeFsPromises from './builtin_modules/implemented/fs/promises.ts'
+import * as nodeHttp from './builtin_modules/implemented/http.ts'
+import * as nodeModule from './builtin_modules/implemented/module.ts'
+import * as nodeOs from './builtin_modules/implemented/os.ts'
+import * as nodePath from './builtin_modules/implemented/path.ts'
+import * as nodePerfHooks from './builtin_modules/implemented/perf_hooks.ts'
+import * as nodeTimersPromises from './builtin_modules/implemented/timers/promises.ts'
+import * as nodeUrl from './builtin_modules/implemented/url.ts'
+import * as nodeUtil from './builtin_modules/implemented/util.ts'
+import * as nodeUtilTypes from './builtin_modules/implemented/util/types.ts'
+import * as nodeZlib from './builtin_modules/implemented/zlib.ts'
+import * as nodeChildProcess from './builtin_modules/implemented/child_process.ts'
+import * as nodeNet from './builtin_modules/mock/net.ts'
+import * as nodeSqlite from './builtin_modules/mock/sqlite.ts'
+import * as nodeStream from './builtin_modules/mock/stream.ts'
+import * as nodeVm from './builtin_modules/mock/vm.ts'
+import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts'
+import * as chokidar from './external_packages/chokidar.ts'
+import * as koffi from './external_packages/koffi.ts'
+import * as landlockRun from './external_packages/node-addon-landlock-run.ts'
+import * as nodePty from './external_packages/node-pty.ts'
+import * as piAi from './external_packages/pi-ai.ts'
+import * as ripgrep from './external_packages/ripgrep.ts'
+import * as sharp from './external_packages/sharp.ts'
+import * as ws from './external_packages/ws.ts'
+import { REPLACED_EXTERNAL_PACKAGES } from './external_packages/replaced-externals.ts'
+import type { StaticModuleFactory } from '../module-system/module-loader.ts'
+
+/** Builtin modules, keyed with and without the `node:` prefix. */
+const BUILTINS: Record<string, StaticModuleFactory> = {
+  async_hooks: () => nodeAsyncHooks,
+  buffer: () => nodeBuffer,
+  child_process: () => nodeChildProcess,
+  crypto: () => nodeCrypto,
+  events: () => nodeEvents,
+  fs: () => nodeFs,
+  'fs/promises': () => nodeFsPromises,
+  http: () => nodeHttp,
+  module: () => nodeModule,
+  net: () => nodeNet,
+  os: () => nodeOs,
+  path: () => nodePath,
+  'path/posix': () => nodePath,
+  perf_hooks: () => nodePerfHooks,
+  sqlite: () => nodeSqlite,
+  stream: () => nodeStream,
+  'timers/promises': () => nodeTimersPromises,
+  url: () => nodeUrl,
+  util: () => nodeUtil,
+  'util/types': () => nodeUtilTypes,
+  vm: () => nodeVm,
+  worker_threads: () => nodeWorkerThreads,
+  zlib: () => nodeZlib,
+}
+
+/** External npm packages replaced wholesale (structural not-implemented stubs and fakes). */
+const EXTERNALS: Record<string, StaticModuleFactory> = {
+  'chokidar': () => chokidar,
+  'koffi': () => koffi,
+  'sharp': () => sharp,
+  'node-pty': () => nodePty,
+  'ws': () => ws,
+  '@vscode/ripgrep': () => ripgrep,
+  '@earendil-works/pi-ai': () => piAi,
+  '@deepseek-ai/node-addon-landlock-run': () => landlockRun,
+}
+
+/**
+ * Prefixes whose every subpath resolves to one replacement module. The loader
+ * matches the longest prefix after its exact table misses, so pi-ai's
+ * `/providers/*` and `/api/*.lazy` entries need no enumeration.
+ */
+export const REPLACED_PREFIXES: Record<string, StaticModuleFactory> = {
+  '@earendil-works/pi-ai/': () => piAi,
+}
+
+// One list, two consumers: a package replaced here must also be kept out of the
+// VFS image, so any divergence fails at worker start rather than at first require.
+const declared = [...REPLACED_EXTERNAL_PACKAGES].sort().join(',')
+const wired = Object.keys(EXTERNALS).sort().join(',')
+if (declared !== wired) {
+  throw new Error(`web-preview: replaced-external lists diverge — declared [${declared}] vs wired [${wired}]`)
+}
+
+/**
+ * Build the specifier → factory table the worker module loader consults first.
+ * @returns every replaced specifier, including its `node:`-prefixed alias.
+ */
+export function createNodeBuiltins(): Record<string, StaticModuleFactory> {
+  const table: Record<string, StaticModuleFactory> = { ...EXTERNALS }
+  for (const [name, factory] of Object.entries(BUILTINS)) {
+    table[name] = factory
+    table[`node:${name}`] = factory
+  }
+  return table
+}

+ 68 - 0
packages/experimental/webworker-runtime/src/node/external_packages/chokidar.ts

@@ -0,0 +1,68 @@
+/**
+ * `chokidar` stub: a constructible watcher that never fires. Settings and
+ * credentials call `watch()` unconditionally in `[Service.init]`, and the
+ * in-memory VFS has no external writer, so "no events" is the truth here rather
+ * than a degradation.
+ */
+
+/** No-op watcher with chokidar's chainable face. */
+export class FSWatcher {
+  /**
+   * Register a listener; no event is ever emitted.
+   * @returns this watcher.
+   */
+  on(): this {
+    return this
+  }
+
+  /**
+   * Register a one-shot listener; no event is ever emitted.
+   * @returns this watcher.
+   */
+  once(): this {
+    return this
+  }
+
+  /**
+   * Add paths to the (inert) watch set.
+   * @returns this watcher.
+   */
+  add(): this {
+    return this
+  }
+
+  /**
+   * Remove paths from the (inert) watch set.
+   * @returns this watcher.
+   */
+  unwatch(): this {
+    return this
+  }
+
+  /**
+   * Watched paths, as chokidar reports them.
+   * @returns An empty record; nothing is ever watched.
+   */
+  getWatched(): Record<string, string[]> {
+    return {}
+  }
+
+  /** Close the watcher. */
+  async close(): Promise<void> {
+    // Nothing was ever watched.
+  }
+}
+
+/**
+ * Create an inert watcher.
+ * @returns the watcher.
+ */
+export function watch(): FSWatcher {
+  return new FSWatcher()
+}
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** CommonJS default export: the members `require()` hands a caller of this module. */
+export default { watch, FSWatcher }

+ 155 - 0
packages/experimental/webworker-runtime/src/node/external_packages/koffi.ts

@@ -0,0 +1,155 @@
+/**
+ * `koffi` stub: the FFI bridge the Windows ACL layer and the Landlock launcher
+ * use. Type constructors return opaque tokens because the ACL module builds its
+ * pointer and struct descriptors at module scope — the plugin must mount. Every
+ * entry that would actually cross into native code is loud; on this platform
+ * none of it is reachable (`process.platform === 'linux'`, no sandbox).
+ */
+import { notImplementedFail } from '../notImplementedFail.ts'
+
+const MODULE = 'koffi'
+
+/** Opaque type descriptor standing in for a koffi type handle. */
+interface KoffiType {
+  readonly __dshKoffiType: string
+  /** Byte size under the x64 ABI; struct layout guards compare against it. */
+  readonly size: number
+  /** Byte alignment under the x64 ABI. */
+  readonly alignment: number
+}
+
+/** Primitive sizes koffi's own x64 ABI reports. */
+const PRIMITIVES: Record<string, number> = {
+  void: 0,
+  bool: 1,
+  char: 1,
+  uchar: 1,
+  int8: 1,
+  uint8: 1,
+  short: 2,
+  ushort: 2,
+  int16: 2,
+  uint16: 2,
+  int: 4,
+  uint: 4,
+  int32: 4,
+  uint32: 4,
+  float: 4,
+  float32: 4,
+  long: 8,
+  ulong: 8,
+  longlong: 8,
+  ulonglong: 8,
+  int64: 8,
+  uint64: 8,
+  double: 8,
+  float64: 8,
+  str: 8,
+  str16: 8,
+}
+
+const token = (label: string, size: number, alignment = Math.min(size, 8) || 1): KoffiType =>
+  ({ __dshKoffiType: label, size, alignment })
+
+const typeOf = (target: unknown): KoffiType => {
+  if (typeof target === 'string') {
+    const size = PRIMITIVES[target]
+    if (size === undefined) throw new Error(`web-preview: koffi type "${target}" is unknown to the stub`)
+    return token(target, size)
+  }
+  const descriptor = target as KoffiType | undefined
+  if (descriptor?.__dshKoffiType === undefined) {
+    throw new Error(`web-preview: koffi type ${JSON.stringify(target)} is not a stub descriptor`)
+  }
+  return descriptor
+}
+
+const describe = (target: unknown): string =>
+  typeof target === 'string' ? target : (target as KoffiType | undefined)?.__dshKoffiType ?? 'anonymous'
+
+/**
+ * Pointer type descriptor.
+ * @param target - pointee type name or descriptor.
+ * @returns the descriptor token.
+ */
+function pointer(target: unknown): KoffiType {
+  return token(`pointer(${describe(target)})`, 8)
+}
+
+/**
+ * Struct type descriptor. The size and alignment are computed with the same
+ * padding rules koffi uses on x64, because the Windows ACL layer compares them
+ * against its own header probe at module scope.
+ * @param name - struct name, or the field record when the name is omitted.
+ * @param fields - field name → type record.
+ * @returns the descriptor token.
+ */
+function struct(name: unknown, fields?: Record<string, unknown>): KoffiType {
+  const members = (typeof name === 'string' ? fields : name as Record<string, unknown>) ?? {}
+  let offset = 0
+  let alignment = 1
+  for (const member of Object.values(members)) {
+    const type = typeOf(member)
+    alignment = Math.max(alignment, type.alignment)
+    offset = Math.ceil(offset / type.alignment) * type.alignment + type.size
+  }
+  const size = Math.ceil(offset / alignment) * alignment
+  return token(`struct(${typeof name === 'string' ? name : 'anonymous'})`, size, alignment)
+}
+
+/**
+ * Array type descriptor.
+ * @param target - element type.
+ * @param length - element count.
+ * @returns the descriptor token.
+ */
+function array(target: unknown, length: number): KoffiType {
+  const element = typeOf(target)
+  return token(`array(${element.__dshKoffiType}, ${String(length)})`, element.size * length, element.alignment)
+}
+
+/**
+ * Opaque type descriptor.
+ * @param name - type name.
+ * @returns the descriptor token.
+ */
+function opaque(name?: string): KoffiType {
+  return token(`opaque(${name ?? 'anonymous'})`, 0, 1)
+}
+
+/** Primitive type table; members carry their x64 sizes. */
+const types: Record<string, KoffiType> = new Proxy({}, {
+  get: (_target, property) => typeOf(String(property)),
+  has: property => typeof property === 'string' && property in PRIMITIVES,
+})
+
+/** The koffi face its consumers read; every call refuses. */
+const koffi = {
+  pointer,
+  struct,
+  array,
+  opaque,
+  types,
+  alias: (name: string, target: unknown): KoffiType => {
+    const type = typeOf(target)
+    return token(`alias(${name})`, type.size, type.alignment)
+  },
+  sizeof: (target: unknown): number => typeOf(target).size,
+  alignof: (target: unknown): number => typeOf(target).alignment,
+  load: notImplementedFail(MODULE, 'load'),
+  alloc: notImplementedFail(MODULE, 'alloc'),
+  free: notImplementedFail(MODULE, 'free'),
+  decode: notImplementedFail(MODULE, 'decode'),
+  encode: notImplementedFail(MODULE, 'encode'),
+  address: notImplementedFail(MODULE, 'address'),
+  register: notImplementedFail(MODULE, 'register'),
+  unregister: notImplementedFail(MODULE, 'unregister'),
+  call: notImplementedFail(MODULE, 'call'),
+}
+
+export { pointer, struct, array, opaque, types }
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+export default koffi

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä