Procházet zdrojové kódy

Merge remote-tracking branch 'origin/master' into feat/code-runtime-python-protocol

Chinesezjc před 2 měsíci
rodič
revize
83716ebad0
100 změnil soubory, kde provedl 548 přidání a 502 odebrání
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml
  2. 5 5
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md
  3. 5 5
      .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml
  11. 4 5
      .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md
  12. 4 5
      .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.i18n.yaml
  14. 31 0
      .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md
  15. 31 0
      .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md
  16. 2 2
      .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml
  17. 5 5
      .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
  18. 5 5
      .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml
  20. 7 7
      .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
  21. 7 7
      .agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md
  22. 6 0
      CONTRIBUTING.i18n.yaml
  23. 23 0
      CONTRIBUTING.md
  24. 23 0
      CONTRIBUTING.zh.md
  25. 2 2
      README.i18n.yaml
  26. 4 0
      README.md
  27. 4 0
      README.zh.md
  28. 2 2
      apps/cli/reference/README.i18n.yaml
  29. 4 4
      apps/cli/reference/README.md
  30. 4 4
      apps/cli/reference/README.zh.md
  31. 26 30
      apps/cli/src/profile-boot.ts
  32. 3 0
      apps/cli/tests/web-agent-presets.e2e.ts
  33. 3 3
      apps/web/tests/hmr-live.e2e.ts
  34. 2 2
      apps/web/tests/replay-round-trip.e2e.ts
  35. 1 1
      apps/web/tests/scaffold.ts
  36. 4 4
      apps/web/tests/smoke-real.e2e.ts
  37. 1 1
      apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md
  38. 0 1
      apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md
  39. 1 0
      apps/web/tests/snapshots/web-runtime-context/web-surface-prompt.expected.md
  40. 1 1
      apps/web/vite.config.ts
  41. 2 2
      docs/api-gateway.i18n.yaml
  42. 1 1
      docs/api-gateway.md
  43. 1 1
      docs/api-gateway.zh.md
  44. 2 2
      docs/config-catalog.i18n.yaml
  45. 13 12
      docs/config-catalog.md
  46. 13 12
      docs/config-catalog.zh.md
  47. 2 2
      docs/i18n/README.i18n.yaml
  48. 1 1
      docs/i18n/README.md
  49. 1 1
      docs/i18n/README.zh.md
  50. 2 2
      docs/subsystems/attachment.i18n.yaml
  51. 3 1
      docs/subsystems/attachment.md
  52. 3 1
      docs/subsystems/attachment.zh.md
  53. 2 2
      docs/subsystems/persistence.i18n.yaml
  54. 6 4
      docs/subsystems/persistence.md
  55. 6 4
      docs/subsystems/persistence.zh.md
  56. 2 2
      packages/attachment/attachment-local/README.i18n.yaml
  57. 1 1
      packages/attachment/attachment-local/README.md
  58. 1 1
      packages/attachment/attachment-local/README.zh.md
  59. 2 2
      packages/attachment/attachment-local/src/index.ts
  60. 12 2
      packages/attachment/attachment-local/src/store.ts
  61. 26 1
      packages/attachment/attachment-local/tests/store.spec.ts
  62. 2 2
      packages/attachment/attachment/README.i18n.yaml
  63. 1 1
      packages/attachment/attachment/README.md
  64. 1 1
      packages/attachment/attachment/README.zh.md
  65. 3 1
      packages/attachment/attachment/src/index.ts
  66. 2 2
      packages/boot/app-boot/README.i18n.yaml
  67. 1 1
      packages/boot/app-boot/README.md
  68. 1 1
      packages/boot/app-boot/README.zh.md
  69. 18 6
      packages/boot/app-boot/tests/user-patches.spec.ts
  70. 2 2
      packages/boot/cmdline/README.i18n.yaml
  71. 0 2
      packages/boot/cmdline/README.md
  72. 0 2
      packages/boot/cmdline/README.zh.md
  73. 0 24
      packages/boot/cmdline/src/index.ts
  74. 1 68
      packages/boot/cmdline/tests/cmdline.spec.ts
  75. 2 2
      packages/bundle/headless/README.i18n.yaml
  76. 2 2
      packages/bundle/headless/README.md
  77. 2 2
      packages/bundle/headless/README.zh.md
  78. 2 1
      packages/bundle/headless/cordis.patch.yml
  79. 16 15
      packages/bundle/headless/src/index.ts
  80. 19 25
      packages/bundle/headless/tests/headless.spec.ts
  81. 2 2
      packages/bundle/web-app/README.i18n.yaml
  82. 3 3
      packages/bundle/web-app/README.md
  83. 3 3
      packages/bundle/web-app/README.zh.md
  84. 15 20
      packages/bundle/web-app/cordis.patch.yml
  85. 13 33
      packages/bundle/web-app/src/index.ts
  86. 1 7
      packages/bundle/web-app/src/startup.ts
  87. 1 5
      packages/bundle/web-app/tests/startup.spec.ts
  88. 21 28
      packages/bundle/web-app/tests/web-app.spec.ts
  89. 2 2
      packages/client/connection/src/client/fixture.ts
  90. 2 2
      packages/client/hmr/README.i18n.yaml
  91. 1 1
      packages/client/hmr/README.md
  92. 1 1
      packages/client/hmr/README.zh.md
  93. 3 1
      packages/client/hmr/src/index.ts
  94. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  95. 0 0
      packages/client/ui-trajectory/README.md
  96. 0 0
      packages/client/ui-trajectory/README.zh.md
  97. 17 14
      packages/client/ui-trajectory/src/client/export-log.ts
  98. 2 18
      packages/client/ui-trajectory/src/client/index.ts
  99. 33 6
      packages/client/ui-trajectory/tests/export-log.spec.ts
  100. 9 22
      packages/client/ui-trajectory/tests/views.spec.tsx

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

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

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

@@ -31,7 +31,7 @@ What makes a package a plugin? One rule: **a package is a plugin package once it
 - **Plain packages** are the absolute base the module system itself needs, plus libraries not yet converted to DI: the react family, cordis, `@deepseek-ai/dsh-client-modules` (the module system itself — it can never be a plugin, because modules precede all modules), the web shell kernel, and — for now — ui-slots, web-react, ui-primitives. Plain packages are shell-bundled, seeded into the module table, and invisible to the host graph.
 - **Plugin packages** are everything else. Each one carries a `dsh.client` manifest declaration (`{ platform, inject, immediately? }`) and one uniform shape: the shared tsdown preset emits `lib/client.js`, and `exports["./client"]` points at that bundle. Each is a governed entry of the host-authored graph. The current set is connection, runtime, ui-theme, i18n, hmr (dev graphs only), ui-layout, ui-sidebar, ui-conversation, ui-model-selector, ui-question, and ui-trajectory.
 
-The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster and the `--dev` switch.
+The manifest owns the package's loading contract: its `inject` dependency edges, plus the optional `immediately` prefetch mark (absent means lazy). The composing app owns only the roster.
 
 To add a plugin package: declare `dsh.client`, emit the `./client` bundle through the shared preset, add the name to the composing app's roster. Nothing else changes hands.
 
@@ -66,7 +66,7 @@ What happens between `dsh web` starting and the UI appearing? Three stages: the
 
 **Host side — compose the graph.**
 
-1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, and `--dev` appends the `client-hmr` row in code (`AppCLIEntry`) before the host activation audit so the same check covers it. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
+1. The composing app (`apps/cli`) ships the roster as ordinary rows in its `cordis.yml` config tree — client plugin packages are entry rows like every host plugin, including the always-mounted `client-hmr` row. A roster row that fails to import is caught by `assertEntriesLoaded`; a row whose fiber rejects is reported with its original stack by `assertEntriesActivated` ([host boot decision](2026-07-24-web-config-tree-boot-and-transport-layering.md)).
 2. The `dsh-client-modules` node half (the package is dual-face: its browser half is the module table) scans loader entries' package.json `dsh.client` declarations and composes `window.__DSH_BOOT__`: `{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`. The `inject` edges and the `immediately` mark come from manifests, never hand-copied. It refuses declared plugins without built `./client` bundles and groups their package/path rows under one required source-build instruction; malformed declaration fields also fail activation, and the host audit reports either error from the FAILED fiber.
 3. Scanning is incremental per package — there is no full-rescan code path. Each cordis `internal/plugin` emission marks the fiber's entry name dirty (entry-less fibers drop O(1)); a microtask flush reconciles each dirty name against live loader entries, with package metadata (including the negative "not a client package" verdict) cached per name forever and bundle re-hashing reachable only through `rebuilt(id)`. The activation pass seeds the same dirty set from current entries and flushes synchronously, so first scan and steady state share one implementation. Each bundle's content hash is its `rev` (cache busting + HMR diff anchor), the row set hashes into `graph.rev`, and every row is served as a script resource at `/plugins/<id>/client.js?rev=…`, with its source map at the same path plus `.map`. The graph types are single-sourced in the modules package's `./client` export — the webserver knows nothing about the graph (it is a plain route-registration plugin; modules registers the bundle route and taps the index render itself).
 
@@ -84,7 +84,7 @@ Why is the roster yml rows and not a scan? Because which plugins compose into a
 
 ### Hot reload: one driver plugin, self-watched bundles
 
-Whether hot reload is active is a composition decision: dev compositions mount the `client-hmr` row (a normal plugin package, appended by `--dev`) whose node half brings the bundle watch and the SSE channel; prod compositions mount nothing and have neither.
+Hot reload is a composition decision: the web bundle mounts the `client-hmr` row (a normal plugin package) unconditionally; its node half brings the bundle watch and the SSE channel, and the chain stays idle until a rebuild watcher rewrites client bundles. A composition that must not expose it disables the row.
 
 How does a rebuilt bundle become a reload signal? The hmr node half observes it itself — no builder tells it. It reads bundle paths from `ctx.clientModuleHost.clientPath(id)`, and one HMR-owned interval stat-polls every current graph row. Adding a row is ordered as synchronous stat baseline, then immediate `clientModuleHost.rebuilt(id)`: a write after the module host's graph hash but before that baseline is caught by the immediate re-hash, while a write after the baseline leaves a stat delta for the next poll. This avoids `fs.watchFile`, whose asynchronous first baseline can silently absorb a construction-time rebuild. Watch membership follows `onGraphChanged`; vanished rows drop out, and a bundle missing at poll time keeps its row dirty so reappearance forces a re-hash even with identical metadata. On a mtime/size delta or dirty row, `clientModuleHost.rebuilt(id)` is the single re-hash entry point; when the `rev` actually changed, the node half broadcasts a `rebuilt` frame on `GET /plugins/events` — a system SSE channel that sends the full graph on connect and `rebuilt` frames on change, presentation-only wire that never enters the session log. Polling is deliberate because inotify does not fire on the weka network mount, the same reason the build-side watcher needs `--poll`; the interval is a validated config field (default 500ms), and disposal clears the one timer. Rebuilding bundles is any tsdown watch process's business — `scripts/dev-web.ts` remains the watch-build entry point, discovering its package list through `dsh.client` while scanning `packages/*/*/package.json` at startup — and builder and host share zero protocol. A torn read self-heals: stats keep changing while the write completes, so the next poll re-hashes and broadcasts the final rev.
 
@@ -117,12 +117,12 @@ The support boundary, stated honestly. Reload is coarse by design: fresh fiber,
 | `dsh-client-runtime` | session object layer + slots service + store engine | plugin, declares `immediately` | keeps shrinking toward a pure session object layer |
 | `dsh-client-ui-theme` | theme tokens/service | plugin, declares `immediately`, plus the `./styles/*` source channel | Theme Registry (separate ruling) |
 | `dsh-client-i18n` | I18nService | plugin, declares `immediately` | per-deployment locale composition |
-| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately`; dev graphs only | rollback; reconnect handshake |
+| `dsh-client-hmr` | hot reload driver | plugin, declares `immediately` | rollback; reconnect handshake |
 | ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation |
 
 ## Consequences
 
-One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster and the `--dev` switch. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
+One governance implementation runs on both sides of the wire; the browser-specific layer is one module system plus one reload plugin. Plugin packages have one shape, so the purity gate covers them all. Dependency edges and the boot tier live with their owners — the manifests — while the composing app holds only the roster. The drift classes stay structurally closed: share-list hand-sync, load-order coupling, cross-plugin imports, roster/tier double bookkeeping. Browser-native script loading preserves the standard mapping among plugin network resources, generated bundles, and TypeScript/TSX sources, while the module system keeps only one replaceable `loadBundle` hook.
 
 Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch appears at the settled sweep, not at graph validation; the three not-yet-promoted libraries keep their static-import exports until their DI conversions land; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch.
 

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

@@ -31,7 +31,7 @@ host 侧,cordis 插件装载站在 Node 的模块机制之上——require cac
 - **普通包**是模块系统自身所需的绝对基座,加上尚未转成 DI 的库:react 家族、cordis、`@deepseek-ai/dsh-client-modules`(模块系统本身——它永远不可能是插件,因为模块先于一切模块)、web 壳内核,以及——暂时——ui-slots、web-react、ui-primitives。普通包打进壳 bundle、播种进模块表、对 host 图不可见。
 - **插件包**是其余一切。每个都携带 `dsh.client` manifest(元数据清单)声明(`{ platform, inject, immediately? }`)和同一种统一形态:共享 tsdown 预设产出 `lib/client.js`,`exports["./client"]` 指向该 bundle。每个都是 host 独家撰写的图里受治理的 entry。当前包括:connection、runtime、ui-theme、i18n、hmr(仅进 dev 图)、ui-layout、ui-sidebar、ui-conversation、ui-model-selector、ui-question、ui-trajectory。
 
-manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册与 `--dev` 开关。
+manifest 拥有包的装载约定:它的 `inject` 依赖边,加可选的 `immediately` 预取标记(缺省即 lazy)。负责组合的 app 只拥有名册。
 
 新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle,把包名加进负责组合的 app 的名册。除此之外无需任何交接。
 
@@ -66,7 +66,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 **host 侧——组合这张图。**
 
-1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,`--dev` 由代码(`AppCLIEntry`)在 host 激活检查之前追加 `client-hmr` 行,使同一项检查覆盖它。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
+1. 负责组合的 app(`apps/cli`)把名册作为普通行放进它的 `cordis.yml` 配置树——client 插件包与每个 host 插件一样是 entry 行,包括无条件挂载的 `client-hmr` 行。名册行 import 失败由 `assertEntriesLoaded` 捕获;fiber reject 的行则由 `assertEntriesActivated` 报告原始 stack([host boot 决策](2026-07-24-web-config-tree-boot-and-transport-layering.md))。
 2. `dsh-client-modules` 的 node 半(该包是双面的:浏览器半就是模块表)扫描 loader entry 的 package.json `dsh.client` 声明,组合出 `window.__DSH_BOOT__`:`{ rev, entries: [{ id, url, rev, inject?, immediately? }] }`。`inject` 边与 `immediately` 标记都来自 manifest,永不人肉抄写。它会拒绝没有已构建 `./client` bundle 的已声明插件,并把它们的 package/path 行归到一条源码构建要求下;畸形声明字段同样会让激活失败,host 检查会从 FAILED fiber 报告这两类错误。
 3. 扫描是单包增量——不存在全量重扫代码路径。每次 cordis `internal/plugin` 发射把该 fiber 的 entry 名标脏(无 entry 的 fiber O(1) 丢弃);微任务 flush 把每个脏名对账 live loader entries,包元数据(含「非 client 包」的否定结论)按名永久缓存,bundle 重哈希只经 `rebuilt(id)` 可达。激活趟从当前 entries 灌同一脏集合并同步 flush,初扫与稳态共享一条实现。每个 bundle 的内容哈希是其 `rev`(缓存失效 + HMR diff 锚点),行集合哈希进 `graph.rev`,每一行都作为脚本资源供给:`/plugins/<id>/client.js?rev=…`,对应 sourcemap 位于同一路径加 `.map`。图类型单源在 modules 包的 `./client` 出口——webserver 对图一无所知(它是朴素路由注册插件;bundle 路由和 index 渲染 tap 都由 modules 自己注册)。
 
@@ -84,7 +84,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 
 ### 热重载:一个驱动插件,自行监视的 bundle
 
-热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;prod 组合不挂载,两者皆无。
+热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSE(Server-Sent Events)通道;没有重建 watcher 改写客户端 bundle 时链路保持空闲。不得暴露它的组合可在 patch 层禁用该行。
 
 重建好的 bundle 怎么变成重载信号?hmr 的 node 半自己观察——没有构建器来通知它。它从 `ctx.clientModuleHost.clientPath(id)` 读取图上各行的 bundle 路径,由 HMR 自持的单个定时器对当前图上的每一行做 stat 轮询。新增图行时,顺序固定为先同步取得 stat 基线,再立即调用 `clientModuleHost.rebuilt(id)`:在模块 host 算出图哈希之后、取得基线之前发生的写入会被这次立即重哈希捕获;取得基线之后发生的写入则会留下 stat 差异,供下一次轮询捕获。这避开了 `fs.watchFile`:它以异步首次 stat 建立基线,可能把构造期间的重建静默吸收进基线。监视集合的成员随 `onGraphChanged` 更新;消失的行撤下监视,轮询时缺失的 bundle 则让对应行保持标脏状态,文件重现时即使元数据相同也强制重哈希。mtime/size 变化或行处于标脏状态时,`clientModuleHost.rebuilt(id)` 是重哈希的唯一入口;当 `rev` 真的变了,node 半才在 `GET /plugins/events` 上广播 `rebuilt` 帧——这是一条系统级 SSE 通道,连接即发全量图,变更时发 `rebuilt` 帧,仅供呈现的 wire,永不进会话日志。轮询是刻意选择:inotify 在 weka 网络挂载上不触发,构建侧监视器需要 `--poll` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500ms),dispose(资源释放)会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。
 
@@ -117,12 +117,12 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
 | `dsh-client-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
 | `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry(另行裁定) |
 | `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 |
-| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately`;仅进 dev 图 | 回滚;重连握手 |
+| `dsh-client-hmr` | 热重载驱动 | 插件,声明 `immediately` | 回滚;重连握手 |
 | ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分;trajectory 真实现 |
 
 ## Consequences
 
-wire 两侧跑着同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册与 `--dev` 开关。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
+wire 两侧跑着同一份治理实现;浏览器特有层只包含一套模块系统和一个重载插件。插件包只有一种形态,纯度门禁因此覆盖全部插件。依赖边与启动档位都与其所有者——manifest——同住,负责组合的 app 只握名册。各漂移缺陷类被结构性关死:共享清单人肉同步、装载顺序耦合、跨插件 import、名册/档位双重记账。浏览器原生脚本装载使插件网络资源、生成 bundle 与 TypeScript/TSX 源码保持标准映射,模块系统也只保留一个可替换的 `loadBundle` 钩子。
 
 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面;每个 bundle 多出一份 sourcemap 产物,外部脚本失败也只能给出粗粒度的 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md
-2026-07-24-web-config-tree-boot-and-transport-layering.md: a730a14ac29b84c97c3f9c5b122eb907def4d389
-2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 4fe315bb7673ba219286b176123ccbbe08f02f0d
+2026-07-24-web-config-tree-boot-and-transport-layering.md: be0a75d98c3d0004e49ad574e7e0e37ba35259bb
+2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 316cb0bc2ef23b440f52b35774c38a47f1e0570b

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md

@@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)
 
 ## Decision
 
-**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle audit — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
+**Composition is one flat assembled tree.** `apps/cli/config/base.cordis.yml` plus `apps/cli/config/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dsh.client` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/config/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). The `dsh-client-hmr` row is an ordinary always-on bundle row (originally appended in code by `--dev`; the flag is retired). Row order carries no load semantics; activation is service-availability driven. The shared audit rejects imports with no fiber, awaits only failed fibers to recover original activation errors, and reports services that leave a fiber `PENDING`; before throwing, it marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. The Node app-boot artifact embeds `@cordisjs/plugin-include` while leaving `@cordisjs/plugin-loader` external, so the include's `EntryTree` and the host bind to one Loader peer instead of splitting a config tree across two Loader implementations.
 
 **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle audit 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
+**组合结果是一棵平铺配置树。** `apps/cli/config/base.cordis.yml` 与 `apps/cli/config/web.cordis.yml` 共同持有全部行——host 运行时(32 行)、`api-gateway` 行、`webserver` 行、`dsh.client` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/config/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`dsh-client-hmr` 行是普通的常开组合包行(最初由 `--dev` 在代码中追加;该旗标已废除)。行序无装载语义;激活由服务可用性驱动。共享 audit 会拒绝没有 fiber 的 import、仅等待失败的 fiber 以恢复原始激活错误,并报告让 fiber 停在 `PENDING` 的服务;抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而无关的未处理 rejection 仍然致命。Node app-boot 产物内嵌 `@cordisjs/plugin-include`,但将 `@cordisjs/plugin-loader` 保持为外部依赖,因此 include 的 `EntryTree` 与 host 会绑定到同一个 Loader peer,而不会让一棵配置树横跨两个 Loader 实现。
 
 **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md
-2026-08-05-profile-plugin-bundles.md: 54626e3f48a2ba7db19813e6e883f0e77499d0e2
-2026-08-05-profile-plugin-bundles.zh.md: 357e0f63d4eba0f0985c9e14aad54595c7b41c77
+2026-08-05-profile-plugin-bundles.md: ffef7b67a11617599674e0515e16bfd5283d8445
+2026-08-05-profile-plugin-bundles.zh.md: b190d5e834ba3ce8b719a4a3e61f256eb2c0d82b

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md

@@ -22,7 +22,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th
 
 - **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan.
 - **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony.
-- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
+- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-provided host slots (`ctx.cmdlineArgs`, `ctx.appExit`, and the environment snapshot) are provided in `boot()`'s `prepare` hook, before any config-tree entry mounts.
 - **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file.
 
 ## Consequences

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md

@@ -22,7 +22,7 @@ Status: implemented
 
 - **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。
 - **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。
-- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
+- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器提供的宿主 slot(`ctx.cmdlineArgs`、`ctx.appExit` 与环境快照)在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。
 - **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。
 
 ## Consequences

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md
-2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073
-2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3
+2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920
+2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b

+ 4 - 5
.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md

@@ -16,18 +16,17 @@ The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `p
 
 The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset.
 
-The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change.
+The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family, and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume <session>` / `--session <id>` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change.
 
 Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup.
 
 ## Why Loader owns the ordering
 
-Four framework facts shape the mechanism:
+Three framework facts shape the mechanism:
 
-- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context.
+- **A profile's rows arrive inside the root include's `patches` option.** Include declares the `EntryGroup.key` tree-carrier marker (as Group does), so Loader keeps its config — entry and patch lists, including Include's own `path` — literal instead of recursively evaluating nested `!!js` nodes in the Include context; each expression resolves in its target row's fiber.
 - **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services.
 - **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services.
-- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver.
 
 This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services.
 
@@ -43,7 +42,7 @@ This leaves dependency ordering in Cordis activation and Loader interpolation, w
 ## Consequences
 
 - An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change.
-- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments.
+- The launcher recognizes no app row at all: the telemetry row remains its only composition probe (for the environment switch), SIGTERM exits 0 on every surface, every boot watches its user patch layers, and the one-shot runner exits through `ctx.appExit` like any other app.
 - `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown.
 - An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load.
 - A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row.

+ 4 - 5
.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md

@@ -16,18 +16,17 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍
 
 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。
 
-已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。
+已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族,`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume <session>` / `--session <id>`,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。
 
 还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。
 
 ## 为什么由 Loader 持有顺序
 
-四条框架事实塑造了这套机制:
+三条框架事实塑造了这套机制:
 
-- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。
+- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 声明了 `EntryGroup.key` 树载体标记(与 Group 相同),因此 Loader 让它的配置——条目与 patch 列表,包括 Include 自己的 `path`——保持字面值,而不是在 Include 上下文中递归求值嵌套的 `!!js` 节点;每个表达式都在其目标行的 fiber 中解析。
 - **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。
 - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。
-- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。
 
 这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。
 
@@ -43,7 +42,7 @@ boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Lo
 ## 后果
 
 - 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。
-- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。
+- 启动器完全不识别任何应用行:telemetry 行仍是它唯一的组合探测(用于环境开关),SIGTERM 在所有 surface 上以 0 退出,每次启动都监视用户 patch 层,一次性 runner 像任何应用一样经 `ctx.appExit` 退出。
 - `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。
 - 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。
 - 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.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-11-cmdline-seam-trim.md
+2026-08-11-cmdline-seam-trim.md: d7908d2c80552f13500ccd36c59a249f1374cbb1
+2026-08-11-cmdline-seam-trim.zh.md: 275a32b584afdbcc77d2356775f35c74786445ab

+ 31 - 0
.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md

@@ -0,0 +1,31 @@
+# Agent Note: Trim the command-line seams to existing interfaces
+
+Status: implemented
+
+English | [中文](2026-08-11-cmdline-seam-trim.zh.md)
+
+## Problem
+
+The app-owned command line ([note](2026-08-06-app-owned-command-line.md)) shipped with three seams that were wider than their consumers needed: a vendored in-memory row-activation state machine (`Entry.enableRuntime` plus `enableRow` exported from `dsh-cmdline`, a command-line package owning a Loader concept) whose only purpose was the `--dev` conditional reload row, a vendored `EntryConfigResolver` protocol symbol whose only implementer was Include, and a launcher that still recognized the `headless-runner` row to pick SIGTERM exit codes, gate user-patch watching, and provide a `headlessIo` seam duplicating `ctx.appExit`.
+
+## Decision
+
+Express all three with interfaces that already exist:
+
+- **No conditional dev row.** The reload chain stops being conditional: `dsh-web-app` mounts the `client-hmr` row unconditionally and `--dev` is deleted, along with the web runtime's `mode` config, the mode-forked prompt contract, and the `DSH_WEB_MODE` bash variable. Without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the chain polls unchanged files and stays idle, so the always-on row costs one stat-poll interval and an SSE route. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted with nothing replacing them.
+- **Tree-carrier config.** Include declares the existing `EntryGroup.key` marker instead of implementing `EntryConfigResolver`; the Loader hook keeps every tree carrier's config literal. Include's own `path` loses `!!js` support — no configuration ever used it, and the pinning test now asserts the literal tree-carrier contract instead.
+- **Launcher app-knowledge.** The launcher recognizes no app row. SIGTERM is a supervisor's ordinary stop request and exits 0 on every surface (SIGINT stays 130); the launcher cannot know whether the app considered its work complete, and the previous 143 depended on naming the headless row. Every boot watches its user patch layers — a one-shot surface exits through bounded shutdown, which disposes the watchers before the loop drains. The headless runner exits through `ctx.appExit` like any other app; its output streams are a package-internal `internals` test seam, and `ctx.headlessIo` is deleted.
+
+## Alternatives considered
+
+- **Keeping `enableRuntime` but moving `enableRow` out of `dsh-cmdline`**: relocation fixes the package boundary but keeps the vendored state machine whose semantics (survives reapplication, rollback on failure) must be re-derived at every upstream sync.
+- **`entry.update({ disabled: null })`**: mutates the entry's serialized options, so the next include reapplication restores `disabled: true` and unmounts the row mid-session.
+- **SIGTERM 143 for one-shot surfaces via an app-registered signal handler**: the launcher's own handler races it for the exit code; winning that race needs a new launcher interface, which is the cost this change removes.
+- **Keeping `--dev` with the row created at runtime**: an interim state of this change; it still needed a mode fork in the prompt contract, a `DSH_WEB_MODE` variable, and creation-versus-user-row arbitration, all to avoid an idle poll whose cost is negligible.
+
+## Consequences
+
+- A deployment that supervises `dsh --profile headless` with SIGTERM now observes exit 0 instead of 143; the caller sent the signal and sees no answer on stdout.
+- The reload chain runs in every `dsh web` process; a deployment that must not expose `/plugins/events` disables the `client-hmr` row in its patch layer.
+- One-shot runs mount the config-watch rows they previously skipped, costing a few milliseconds of startup.
+- The vendored Loader/Include divergence shrinks by one protocol symbol and one state machine, and `rescope-vendor:check` passes again (the modification log's rescope entry is restored to the position its exact-edit anchor requires).

+ 31 - 0
.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md

@@ -0,0 +1,31 @@
+# Agent Note:把命令行接缝收窄到既有接口
+
+Status: implemented
+
+[English](2026-08-11-cmdline-seam-trim.md) | 中文
+
+## 问题
+
+应用自有命令行([笔记](2026-08-06-app-owned-command-line.md))交付时带着三条比其消费者所需更宽的接缝:一台 vendored 的内存行激活状态机(`Entry.enableRuntime`,外加从 `dsh-cmdline` 导出的 `enableRow` —— 一个命令行包拥有了 Loader 概念),其唯一用途是 `--dev` 条件重载行、一个只有 Include 一个实现者的 vendored `EntryConfigResolver` 协议符号,以及仍然识别 `headless-runner` 行的启动器 —— 用它选择 SIGTERM 退出码、门控用户 patch 监视,并提供与 `ctx.appExit` 重复的 `headlessIo` 接缝。
+
+## 决策
+
+三者全部改用已经存在的接口表达:
+
+- **不再有条件 dev 行。** 重载链不再是条件性的:`dsh-web-app` 无条件挂载 `client-hmr` 行,`--dev` 连同 web runtime 的 `mode` 配置、按模式分叉的提示词约定和 `DSH_WEB_MODE` bash 变量一并删除。没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,链路轮询到的文件从不变化、保持空闲,因此常开的行只花费一个 stat 轮询间隔和一条 SSE 路由。`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 删除后无任何替代物。
+- **树载体配置。** Include 改为声明已有的 `EntryGroup.key` 标记,不再实现 `EntryConfigResolver`;Loader 钩子让每个树载体的配置保持字面值。Include 自己的 `path` 失去 `!!js` 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。
+- **启动器的应用知识。** 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出(SIGINT 仍为 130);启动器无从知道应用是否认为工作已完成,而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 `ctx.appExit` 退出;其输出流是包内 `internals` 测试接缝,`ctx.headlessIo` 删除。
+
+## 考虑过的替代方案
+
+- **保留 `enableRuntime` 但把 `enableRow` 移出 `dsh-cmdline`**:搬迁修正了包边界,却保留了 vendored 状态机,其语义(在重新应用后仍生效、失败时回滚)在每次上游同步时都要重新推导。
+- **`entry.update({ disabled: null })`**:改写条目的序列化选项,下一次 include 重新应用会恢复 `disabled: true` 并在会话中途卸载该行。
+- **通过应用注册的信号处理器为一次性 surface 保留 SIGTERM 143**:启动器自己的处理器会与它竞争退出码;要赢得竞争需要新的启动器接口,而这正是本次变更要移除的成本。
+- **保留 `--dev`、改为运行时创建该行**:本次变更的中间形态;它仍需要提示词约定里的模式分叉、`DSH_WEB_MODE` 变量,以及创建与用户自有行之间的仲裁,而这一切只为省下一个成本可忽略的空闲轮询。
+
+## 后果
+
+- 用 SIGTERM 监督 `dsh --profile headless` 的部署现在观察到退出码 0 而非 143;信号是调用方自己发的,且 stdout 上没有答案。
+- 重载链在每个 `dsh web` 进程中运行;不得暴露 `/plugins/events` 的部署应在其 patch 层禁用 `client-hmr` 行。
+- 一次性运行会挂载之前跳过的配置监视行,启动多花几毫秒。
+- vendored Loader/Include 偏差减少一个协议符号和一台状态机,`rescope-vendor:check` 重新通过(修改日志的 rescope 条目回到其精确编辑锚点要求的位置)。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md
-2026-07-28-web-gui-feedback-loop.md: aa488e1df087722c072d98c67d47cdf63a42f6b8
-2026-07-28-web-gui-feedback-loop.zh.md: 9b6954092b737920ed18bc412037e917512e40dc
+2026-07-28-web-gui-feedback-loop.md: fa7fcee80dc91ad7ec4a9a994927daa2cc293baa
+2026-07-28-web-gui-feedback-loop.zh.md: ea83441efa83ad0c93e8cb1f0daf71ecfeabb69e

+ 5 - 5
.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.md

@@ -12,9 +12,9 @@ The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedba
 
 ## Decision
 
-The ordinary `dsh web` composition mounts the Web bundle's `web-runtime` plugin, which publishes one canonical loopback URL and its actual runtime mode as both model-visible orientation and managed shell facts. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` and `DSH_WEB_MODE=production|development` carry the same facts into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. A complete-prompt profile sets the row's `surfaceContext` to false and receives neither the prompt section nor the managed variables; the Web launcher uses the same setting to suppress its source-checkout prompt section.
+The ordinary `dsh web` composition mounts the Web bundle's `web-runtime` plugin, which publishes one canonical loopback URL as both model-visible orientation and a managed shell fact. The `app:web-surface` prompt section says that unqualified references identify this GUI and names the URL; `DSH_WEB_URL` carries the same fact into every foreground or managed background bash call. The section preserves the no-implicit-DOM, route, or screenshot boundary and does not claim that a LAN alias equals the browser's literal address. A complete-prompt profile sets the row's `surfaceContext` to false and receives neither the prompt section nor the managed variable; the Web launcher uses the same setting to suppress its source-checkout prompt section.
 
-The mode-specific prompt makes the agent, rather than the user, own the hidden startup contract. Production mode defines acceptance as rebuilding the affected artifacts and refreshing the existing URL. Development mode states that `dsh web --dev` activates only the HMR receiver: automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuild plus refresh. An agent in production mode explains both commands when a user requests no-refresh updates; it does not launch a replacement GUI unless asked.
+The prompt makes the agent, rather than the user, own the hidden startup contract. The client-plugin HMR receiver is always mounted, but automatic client-plugin reload additionally requires a same-checkout `pnpm run dev:web` watcher, which the agent verifies before promising no-refresh updates. Shell and other plain-package changes still require rebuilding the affected artifacts and refreshing the existing URL. The agent does not launch a replacement GUI unless asked.
 
 The `apps/web` development script and Vite configuration reject serve mode before opening a port. Their diagnostics identify `apps/web` as a build-only shell, explain that only `dsh web` injects `window.__DSH_BOOT__`, and name the production and HMR entry paths. Vite build mode remains unchanged.
 
@@ -22,7 +22,7 @@ No server restart or replacement is required merely because static artifacts cha
 
 ## Verification
 
-The keyless fresh-round-trip browser scenario boots the shipped production Web composition, drives a real replayed session, snapshots the URL/mode-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` and `$DSH_WEB_MODE` match the actual bound runtime. The real CLI smoke launches `dsh web --dev` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web --dev`, changes an initial production-roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement.
+The keyless fresh-round-trip browser scenario boots the shipped Web composition, drives a real replayed session, snapshots the URL-bearing system-prompt prefix, and invokes the assembled bash tool to prove `$DSH_WEB_URL` matches the actual bound runtime. The real CLI smoke launches `dsh web` and captures the provider request, pinning the complete two-command development contract. The `dev:web` watcher test rebuilds an isolated client bundle after a source change; the browser HMR scenario launches `dsh web`, changes an initial roster bundle, and observes the new DOM under the same page identity. A real Vite subprocess test requires serve mode to exit naturally with the full-host correction and instruments `Server.listen()` to prove it was never called. The real-Loader webserver test rewrites a static asset after the process binds and proves the same port returns the new bytes. These assertions inspect prompt state, process exit, shell output, DOM identity, and HTTP bytes rather than an agent's success statement.
 
 ## Alternatives considered
 
@@ -30,10 +30,10 @@ The keyless fresh-round-trip browser scenario boots the shipped production Web c
 
 **Remove the `apps/web` development script without guarding Vite.** Rejected because `npx vite`, the exact incident command, bypasses package scripts. Serve mode itself must fail.
 
-**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and plugin HMR has a separate explicit `dsh web --dev` composition.
+**Automatically restart or replace the current Web process after every edit.** Rejected because the static server already reads current artifacts per request, a restart would interrupt the session that requested the edit, and client-plugin reload is owned by the always-mounted HMR chain plus the `pnpm run dev:web` watcher.
 
 **Send DOM, route, or screenshots with each request.** Deferred to a separate logged-input design. Stable URL identity closes this feedback loop without claiming browser state the host does not receive.
 
 ## Consequences
 
-Ordinary Web prompts gain a dynamic URL-and-mode paragraph, so provider prefix reuse now varies by bound port and mode. Their Bash processes gain two non-secret managed environment variables. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL/mode contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. Profiles that disable `surfaceContext` also give up this feedback-loop guidance and shell context.
+Ordinary Web prompts gain a dynamic URL paragraph, so provider prefix reuse now varies by bound port. Their Bash processes gain one non-secret managed environment variable. Bare Vite can no longer be used as a shell-only visual sandbox; developers use the full host or build mode instead. In exchange, GUI work has one mechanically observable target, the agent can teach the user the exact update behavior of the process actually serving their session, and the unsupported startup path fails before a white screen. The URL contract guides the agent away from replacement ports; it does not prohibit arbitrary shell commands from starting one. Profiles that disable `surfaceContext` also give up this feedback-loop guidance and shell context.

+ 5 - 5
.agents/notes/implemented/bug-fix/2026-07-28-web-gui-feedback-loop.zh.md

@@ -12,9 +12,9 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道
 
 ## 决策
 
-常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL 及其实际运行时模式,同时将二者作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 和 `DSH_WEB_MODE=production|development` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false,并且不会收到该提示词段和这些受管变量中的任何一个;Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。
+常规 `dsh web` 组合会挂载 Web 组合包的 `web-runtime` 插件,由它发布一个规范的回环 URL,同时将其作为模型可见的界面定位信息和受管 shell 事实。`app:web-surface` 提示词段说明:未加限定的指代指向此 GUI,并给出 URL;`DSH_WEB_URL` 会把同样的事实传入每次前台或受管后台 bash 调用。该段保留「不会隐式获得 DOM、路由或截图」这一边界,也不声称局域网别名等于浏览器中的实际地址。拥有完整提示词的 profile 会把该配置行的 `surfaceContext` 设为 false,并且不会收到该提示词段和该受管变量;Web 启动器也会使用同一项设置来抑制其源码 checkout 提示词段。
 
-按模式区分的提示词让 agent 而非用户负责隐藏的启动约定。生产模式将验收定义为重新构建受影响的产物并刷新现有 URL。开发模式说明,`dsh web --dev` 只会启用 HMR(热模块替换)接收端:客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建并刷新。生产模式下的 agent 会在用户要求无需刷新即可更新时说明这两个命令;除非用户要求,否则不会启动替代 GUI。
+提示词让 agent 而非用户负责隐藏的启动约定。HMR(热模块替换)接收端始终挂载,但客户端插件要自动重新加载,还需要在同一检出中运行 `pnpm run dev:web` 监听进程,agent 会在承诺无需刷新即可更新前验证这一点。外壳和其他普通包的变更仍然需要重新构建受影响的产物并刷新现有 URL。除非用户要求,agent 不会启动替代 GUI。
 
 `apps/web` 开发脚本和 Vite 配置都会在打开端口前拒绝服务模式。诊断信息会指出 `apps/web` 只是一个仅供构建的外壳,说明只有 `dsh web` 才会注入 `window.__DSH_BOOT__`,并给出生产入口与 HMR 入口路径。Vite 构建模式保持不变。
 
@@ -22,7 +22,7 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道
 
 ## 验证
 
-无密钥的 fresh-round-trip 浏览器场景会启动已交付的生产 Web 组合,驱动真实的回放会话,对包含 URL 和模式的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 和 `$DSH_WEB_MODE` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web --dev` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web --dev`,修改生产初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
+无密钥的 fresh-round-trip 浏览器场景会启动已交付的 Web 组合,驱动真实的回放会话,对包含 URL 的系统提示词前缀生成快照,并调用组装后的 bash 工具,证明 `$DSH_WEB_URL` 与实际绑定的运行时一致。真实 CLI 冒烟测试会启动 `dsh web` 并捕获模型提供方请求,从而固定完整的双命令开发约定。`dev:web` watcher 测试会在源码发生变化后重新构建隔离的客户端 bundle;浏览器 HMR 场景会启动 `dsh web`,修改初始 roster 中的 bundle,并在页面 identity 不变的情况下观察新 DOM。真实 Vite 子进程测试要求服务模式在给出改用完整宿主的纠正信息后自然退出,并通过插桩 `Server.listen()` 证明它从未被调用。真实 loader Web 服务器测试会在进程完成绑定后改写静态资源,并证明同一端口返回新的字节。这些断言检查提示词状态、进程退出状态、shell 输出、DOM identity 和 HTTP 字节,而不是 agent 的成功声明。
 
 ## 考虑过的替代方案
 
@@ -30,10 +30,10 @@ Web agent(智能体)既无法识别承载当前会话的 GUI,也不知道
 
 **删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。
 
-**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合。
+**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而客户端插件重载由始终挂载的 HMR 链路加 `pnpm run dev:web` watcher 负责。
 
 **每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。
 
 ## 影响
 
-常规 Web 提示词会增加一个动态 URL 和模式段落,因此模型提供方的前缀复用会随绑定端口和模式变化。相应的 Bash 进程会增加两个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL/模式约定会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。
+常规 Web 提示词会增加一个动态 URL 段落,因此模型提供方的前缀复用会随绑定端口变化。相应的 Bash 进程会增加一个非敏感的受管环境变量。裸 Vite 不再能用作只依赖 shell 的视觉沙箱;开发者应改用完整宿主或构建模式。作为交换,GUI 工作有了一个可由机制观察的唯一目标,agent 可以向用户说明实际承载其会话的进程究竟如何更新,不受支持的启动路径也会在出现白屏前失败。URL 约定会引导 agent 避免使用替代端口,但不会禁止任意 shell 命令启动替代服务。禁用 `surfaceContext` 的 profile 也会放弃这项反馈闭环指引与 shell 上下文。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md
-2026-08-10-web-session-log-export.md: 427b6478ac44fb28030aa932630f276de7bb2edc
-2026-08-10-web-session-log-export.zh.md: 63b9804a54cda7eea4ff793d78a925fe296d06cb
+2026-08-10-web-session-log-export.md: 8fa62b877df1be55de2c373d4281672881dc2b9d
+2026-08-10-web-session-log-export.zh.md: 3040dda992492187245bfe92d29bc0812ef01ef2

+ 7 - 7
.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md

@@ -10,10 +10,10 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
 
 ## Decision
 
-- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line.
-- **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it.
-- **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot).
-- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar.
+- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line.
+- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it.
+- **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation.
+- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser.
 
 ## Alternatives considered
 
@@ -24,7 +24,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw
 
 ## Consequences
 
-- Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-<sanitized-id>.zip` and archive paths sanitize ids before they can shape entries.
-- `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface.
-- Fixture mode (no host) answers 404 for the export, so the button's error bar explains the gap instead of hanging; the navigation-panes golden snapshot includes the 导出 button.
+- Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-<sanitized-id>.zip` and archive paths sanitize ids before they can shape entries.
+- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface.
+- Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button.
 - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap.

+ 7 - 7
.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md

@@ -10,10 +10,10 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
 
 ## 决策
 
-- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。
-- **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。
-- **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。
-- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。
+- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。
+- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。
+- **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。
+- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。
 
 ## 考虑过的替代方案
 
@@ -24,7 +24,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话
 
 ## 后果
 
-- 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-<sanitized-id>.zip`,归档路径在塑造条目前会先净化会话 id。
-- `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`)加入持久化服务,jsonl 后端覆写并自持压缩解码。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。
-- fixture 模式(无宿主)对导出应答 404,按钮的错误条会解释这个缺口而非挂起;navigation-panes golden 快照包含「导出」按钮。
+- 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-<sanitized-id>.zip`,归档路径在塑造条目前会先净化会话 id。
+- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。
+- fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。
 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。

+ 6 - 0
CONTRIBUTING.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 CONTRIBUTING.md
+CONTRIBUTING.md: 9dd90e8e032eb80384047e18d02e07cec6138ee2
+CONTRIBUTING.zh.md: 7d4e8849ab01af8407ccc6e85135dbb37ee2fc32

+ 23 - 0
CONTRIBUTING.md

@@ -0,0 +1,23 @@
+# Contributing
+
+English | [中文](CONTRIBUTING.zh.md)
+
+Thank you for your interest in contributing to DeepSeek Harness!
+
+We deeply believe in the power of open source communities, and that belief has shaped this project from the very beginning.
+
+DeepSeek Harness is still at an early stage and under active development. We are sorry that we cannot accept external pull requests at the moment. However, contributing code to this repository is far from the only way to help. There are many other ways to get involved:
+
+- Identify and report issues or bugs in GitHub Discussions.
+  - Upvote discussions that you would like to bring to the team's attention. We are a very small team and may not be able to reply to every post, but we monitor them and consider them when allocating resources.
+- Contribute to the ecosystem:
+  - Create a plugin that excites you and share it with others.
+    - Associate your GitHub project with the `dsh-plugin` topic to help others discover your plugin.
+  - Write blog posts and how-to guides about DeepSeek Harness.
+  - Answer questions and help other members of the community.
+
+DeepSeek Harness is designed to be deeply customizable. We do not believe that packages in the official repository are inherently more important than packages created by the community. You may consider this repository an idea, an official showcase, and a source of inspiration, but not a mandate from us.
+
+We have already seen exciting projects emerge from the community, and we hope to see the ecosystem continue to grow in its own directions.
+
+Into the unknown.

+ 23 - 0
CONTRIBUTING.zh.md

@@ -0,0 +1,23 @@
+# 贡献
+
+[English](CONTRIBUTING.md) | 中文
+
+感谢你有兴趣为 DeepSeek Harness 作出贡献!
+
+我们深信开源社区的力量,这份信念从项目最初就塑造着 DeepSeek Harness。
+
+DeepSeek Harness 仍处于早期阶段,并在积极开发中。很抱歉,我们目前无法接受外部 PR(Pull Request)。但贡献代码远不是帮助这个仓库的唯一方式。你还可以通过许多其他方式参与其中:
+
+- 在 GitHub Discussions 中发现并报告问题或 bug。
+  - 为你希望引起团队关注的讨论投票。我们的团队规模很小,可能无法回复每个帖子,但我们会持续关注,并在分配资源时将这些讨论纳入考虑。
+- 为生态系统作出贡献:
+  - 创建令你感兴趣的插件,并分享给其他人。
+    - 为你的 GitHub 项目添加 `dsh-plugin` topic,帮助其他人发现你的插件。
+  - 撰写有关 DeepSeek Harness 的博客文章和操作指南。
+  - 回答问题并帮助其他社区成员。
+
+DeepSeek Harness 的设计支持深度定制。我们不认为官方仓库中的包在本质上比社区创建的包更重要。你可以将这个仓库视为一种思路、一个官方展示和一项灵感来源,而不是我们要求社区遵循的方向。
+
+我们已经看到社区中涌现出令人期待的项目,也希望生态系统继续沿着自己的方向发展。
+
+向未知进发。

+ 2 - 2
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 README.md
-README.md: b2d84672275a4ca996b6ea596c104abfaa52432a
-README.zh.md: 747a3c88bc129ad5dd24f5fa150a1661c4db0b11
+README.md: 9c19dfec19cba6f1364e4f9d5734af49675d68c2
+README.zh.md: 31d83ede854e9f0dfbbba1f8ce1094d043f6d829

+ 4 - 0
README.md

@@ -88,3 +88,7 @@ DeepSeek Harness is currently in internal testing.
 [BSD 3-Clause](LICENSE)
 
 Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
+
+## Contributing
+
+Read [CONTRIBUTING.md](CONTRIBUTING.md) before contributing to this repository.

+ 4 - 0
README.zh.md

@@ -92,3 +92,7 @@ DeepSeek Harness 目前处于内测阶段。
 [BSD 3-Clause](LICENSE)
 
 第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。
+
+## 参与贡献
+
+向本仓库贡献前,请阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。

+ 2 - 2
apps/cli/reference/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 apps/cli/reference/README.md
-README.md: cef687dc392f97886ea18b162e8f668a44ce2284
-README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82
+README.md: 46ea3c241d6775ce90a89c7be58901375a0634a3
+README.zh.md: f020f46260d6b04b87a4918a671ca6bcbed251d9

+ 4 - 4
apps/cli/reference/README.md

@@ -24,7 +24,7 @@ The shipped apps own these command lines:
 
 | Profile | Arguments |
 |---|---|
-| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` |
+| `web` | `--host`, `--port`, repeatable `--trusted-host` |
 | `headless` | the task text, as the positional argument |
 
 A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port.
@@ -52,7 +52,7 @@ Git-hosted plugins that ship sources build during install through their `prepare
 
 ## Web alias
 
-`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates.
+`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, and repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities). The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles.
 
 ```sh
 dsh web
@@ -63,9 +63,9 @@ dsh web --help
 
 The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence.
 
-Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
+Process shutdown gives the plugin tree up to five seconds to dispose. The first `SIGINT`/`SIGTERM` starts that graceful drain — `SIGTERM` is a supervisor's ordinary stop request and exits 0 on every surface, `SIGINT` reports 130; a second signal forces immediate exit. If one-shot normal completion is already stuck in disposal, the first `Ctrl+C` is the escalation and exits immediately instead of being swallowed.
 
-All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Long-lived surfaces watch valid edits of both `cordis.patch.yml` layers (profile and home) and reapply them transactionally; one-shot runs read the files once at startup.
+All modes treat the invoking directory as the default workspace root, load applicable `AGENTS.md` or `CLAUDE.md` instructions with a 65,536-byte render budget, and use an in-memory SQLite session content index. Every profile boot watches valid edits of both `cordis.patch.yml` layers (profile and home) and reapplies them transactionally; a one-shot surface exits through its bounded shutdown, which disposes the watchers.
 
 New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one.
 

+ 4 - 4
apps/cli/reference/README.zh.md

@@ -24,7 +24,7 @@
 
 | Profile | 参数 |
 |---|---|
-| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` |
+| `web` | `--host`、`--port`、可重复的 `--trusted-host` |
 | `headless` | 任务文本,作为位置参数 |
 
 一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。
@@ -52,7 +52,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构
 
 ## Web 别名
 
-`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。
+`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。
 
 ```sh
 dsh web
@@ -63,9 +63,9 @@ dsh web --help
 
 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。
 
-进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
+进程关闭时会给插件树最多 5 秒完成 dispose。第一次 `SIGINT`/`SIGTERM` 启动该优雅排空——`SIGTERM` 是监督进程的普通停止请求,在所有 surface 上以 0 退出,`SIGINT` 报告 130;第二次信号强制立即退出。如果一次性运行正常结束时已经卡在 dispose 中,第一次 `Ctrl+C` 就会升格并立即退出,而不会被吞掉。
 
-所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。常驻 surface 监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性运行只在启动时读取这些文件一次。
+所有模式都将调用目录作为默认 workspace 根目录,以 65,536 字节渲染预算加载适用的 `AGENTS.md` 或 `CLAUDE.md` 指令,并使用内存 SQLite 会话内容索引。每次 profile 启动都监视两个 `cordis.patch.yml` 层(profile 与 home)的有效编辑并以事务方式重新应用;一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器。
 
 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。
 

+ 26 - 30
apps/cli/src/profile-boot.ts

@@ -38,7 +38,6 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im
 const USER_PRESET_DIR = '.agent-presets'
 import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
 import { provideCmdline } from '@deepseek-ai/dsh-cmdline'
-import type { HeadlessIo } from '@deepseek-ai/dsh-headless'
 import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
 import { resolveWindowsShellLayer } from './windows-shell.ts'
 
@@ -60,9 +59,6 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me
 /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */
 const TELEMETRY_ROW_ID = 'telemetry-otel'
 
-/** The one-shot runner row: its presence means this composition exits by itself. */
-const HEADLESS_ROW_ID = 'headless-runner'
-
 /** The empty root entry list every profile tree patches over. */
 const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
 # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
@@ -193,9 +189,20 @@ export interface RunProfileOptions {
   args: readonly string[]
 }
 
-/** Re-throw setup failures unless this invocation's signal already owns shutdown. */
-function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void {
-  if (!signal.aborted) throw error
+/**
+ * Re-throw a watcher-setup failure unless a shutdown already owns the tree:
+ * a signal aborted this invocation, or an app requested exit (`ctx.appExit`
+ * from a fast one-shot) and the root's disposal rejected the in-flight setup
+ * await. Either way the failure describes a tree that is exiting as asked,
+ * not a broken watch.
+ * @param ctx - the booted root context.
+ * @param signal - this invocation's signal-shutdown fact.
+ * @param error - the setup failure.
+ */
+function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown): void {
+  if (signal.aborted) return
+  if (ctx.fiber.state !== FiberState.ACTIVE || ctx.get('loader') === undefined) return
+  throw error
 }
 
 /**
@@ -206,11 +213,6 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void
  */
 export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
   const composed = composeProfile(options.profile, options.patchFiles)
-  // A one-shot composition ends by itself, which changes what a signal means
-  // and makes watching the user's patch layer pointless.
-  const headlessRow = composed.rows.get(HEADLESS_ROW_ID)
-  const oneShot = headlessRow !== undefined && headlessRow.disabled !== true
-
   const app: { current?: Context } = {}
   const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
   const signalShutdown = new AbortController()
@@ -220,7 +222,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
   }
   // Signals own teardown throughout the startup window, not only after boot()
   // settles: an inserted provider can publish before sibling rows finish mounting.
-  process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) })
+  // SIGTERM is a supervisor's ordinary stop request and exits 0 on every
+  // surface — the launcher does not know whether the app considered its work
+  // complete; SIGINT is a user interrupt and reports 130.
+  process.on('SIGTERM', () => { interrupt(0) })
   process.on('SIGINT', () => { interrupt(130) })
   installFailLoud(NAME, process, async () => {
     await app.current?.fiber.dispose()
@@ -246,9 +251,6 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
     ...loadOptionalPatches(NAME, homePatchPath()) ?? [],
     ...composed.overlays,
   ])
-  // One-shot runs exit through the runner; watching would only hold the
-  // process open after its exit request.
-  const watchProfilePatch = !oneShot
   // Cloned for the same insert-aliasing reason as composeLive: the boot
   // application must not mutate the objects later reloads recompose from.
   const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => {
@@ -262,22 +264,16 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
       args: options.args,
       exit: code => void shutdown.shutdown(code),
     })
-    if (oneShot) {
-      const io: HeadlessIo = {
-        stdout: process.stdout,
-        stderr: process.stderr,
-        exit: (code) => { void shutdown.shutdown(code) },
-      }
-      hostCtx.provide('headlessIo', io)
-    }
   })
   app.current = ctx
   // A surface can dispose the whole tree while boot or this post-boot watcher
-  // setup is still in flight. Loader presence and fiber state own
-  // liveness; the local signal fact distinguishes that expected exit race
-  // from a real HMR error.
-  if (watchProfilePatch
-    && !signalShutdown.signal.aborted
+  // setup is still in flight — a signal, or a fast one-shot's appExit. Loader
+  // presence and fiber state own liveness; the initial check skips a tree
+  // that already exited, and the catch below re-checks for an exit that
+  // landed mid-setup. Watching is unconditional: a one-shot surface exits
+  // through its bounded shutdown, which disposes the watchers before the
+  // loop drains.
+  if (!signalShutdown.signal.aborted
     && ctx.fiber.state === FiberState.ACTIVE
     && ctx.get('loader') !== undefined) {
     try {
@@ -305,7 +301,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con
         compose: composeLive,
       })
     } catch (error) {
-      suppressSignalShutdownError(signalShutdown.signal, error)
+      suppressShutdownError(ctx, signalShutdown.signal, error)
     }
   }
   return { ctx, shutdown }

+ 3 - 0
apps/cli/tests/web-agent-presets.e2e.ts

@@ -79,6 +79,9 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis
     { id: 'skill-badge', disabled: false },
     { id: 'modules', disabled: true },
     { id: 'connection', disabled: true },
+    // The always-on reload chain waits for the browser roster and bound port
+    // disabled above.
+    { id: 'client-hmr', disabled: true },
     // The shipped `-auto` chooser resolves its interaction from a running
     // host and so waits for the webserver disabled above; the browse variant
     // supplies `directoryPicker` without one.

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

@@ -1,4 +1,4 @@
-/** Published dsh web --dev + pnpm dev:web → browser HMR, with no page reload. */
+/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */
 
 import { existsSync } from 'node:fs'
 import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
@@ -92,14 +92,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a
     watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
     await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
     host = subprocessCtx.subprocess.spawn(spawnSpec(
-      [process.execPath, binPath, 'web', '--dev', '--port', '0'],
+      [process.execPath, binPath, 'web', '--port', '0'],
       world,
       {
         DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
         DSH_HOME: join(world, '.dsh'),
       },
     ))
-    const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web --dev')
+    const baseUrl = await waitForOutput(host, /dsh web: (http:\/\/[^\s]+)/, 'built dsh web')
     browser = await chromium.launch()
     const page = await browser.newPage()
     const pageErrors: string[] = []

+ 2 - 2
apps/web/tests/replay-round-trip.e2e.ts

@@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => {
       callId: CallId('web-url-probe'),
       name: 'bash',
       arguments: {
-        command: 'printf \'%s\\n%s\\n\' "$DSH_WEB_URL" "$DSH_WEB_MODE"',
+        command: 'printf \'%s\\n\' "$DSH_WEB_URL"',
         description: 'Print current Web runtime',
       },
       agent,
     })
     expect(result.isError).toBe(false)
     expect(result.content.filter(block => block.type === 'text').map(block => block.text).join(''))
-      .toBe(`${scaffold.baseUrl}\nproduction\n`)
+      .toBe(`${scaffold.baseUrl}\n`)
   })
 
   it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {

+ 1 - 1
apps/web/tests/scaffold.ts

@@ -416,7 +416,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     // (apps/web IS @deepseek-ai/dsh-frontend); only the URL line is silenced.
     // Preserve the composed surface-context choice because a patch replaces
     // the row's complete config.
-    { id: 'web-runtime', config: { mode: 'production', printUrl: false, surfaceContext } },
+    { id: 'web-runtime', config: { printUrl: false, surfaceContext } },
     ...options.remoteAuthority === undefined
       ? []
       : [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],

+ 4 - 4
apps/web/tests/smoke-real.e2e.ts

@@ -27,7 +27,7 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
 
-const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
+const WEB_SURFACE_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/web-surface-prompt.expected.md', import.meta.url))
 
 function waitForReadyLine(child: ChildProcess): Promise<string> {
   return new Promise((resolveReady, reject) => {
@@ -187,7 +187,7 @@ describe('dsh web keyless CLI smoke', () => {
     }
   })
 
-  it('routes --dev runtime context and workspace instructions through the real CLI request', async () => {
+  it('routes web runtime context and workspace instructions through the real CLI request', async () => {
     requireDist()
     const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
     mkdirSync(join(workspace, '.git'))
@@ -226,7 +226,7 @@ describe('dsh web keyless CLI smoke', () => {
     const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
     const child = spawn(
       process.execPath,
-      ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0', '--dev'],
+      ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
       {
         cwd: workspace,
         env: {
@@ -261,7 +261,7 @@ describe('dsh web keyless CLI smoke', () => {
       const workspaceMessage = captured.messages?.find(message =>
         message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
       const systemMessage = captured.messages?.find(message => message.role === 'system')
-      const expectedWebSection = readFileSync(DEVELOPMENT_PROMPT, 'utf8').trimEnd()
+      const expectedWebSection = readFileSync(WEB_SURFACE_PROMPT, 'utf8').trimEnd()
         .replace('{{webUrl}}', baseUrl)
       expect(systemMessage?.content).toContain(expectedWebSection)
       expect(workspaceMessage).toMatchInlineSnapshot(`

+ 1 - 1
apps/web/tests/snapshots/fresh-round-trip/system-prompt.expected.md

@@ -2,6 +2,6 @@ You are an AI agent powered by the DeepSeek Harness SDK.
 
 The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
 
-You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
+You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.
 
 You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.

+ 0 - 1
apps/web/tests/snapshots/web-runtime-context/development-prompt.expected.md

@@ -1 +0,0 @@
-You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.

+ 1 - 0
apps/web/tests/snapshots/web-runtime-context/web-surface-prompt.expected.md

@@ -0,0 +1 @@
+You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background task and verify its exact URL.

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

@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
 const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
 const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
   + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
-  + 'For client-plugin HMR, run `pnpm dsh web --dev` together with `pnpm run dev:web`.'
+  + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.'
 
 /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
 function rejectStandaloneServe(): Plugin {

+ 2 - 2
docs/api-gateway.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/api-gateway.md
-api-gateway.md: 3065f3f10861965b327a4412049878dc1ba7faec
-api-gateway.zh.md: aa9b726c33fd9f51fc0b2d2ed95c4c9658662796
+api-gateway.md: c60793532d621585fc9878b6197497b45015856b
+api-gateway.zh.md: daf46f6ad208f4cb959028ff59d98d9de7a072d6

+ 1 - 1
docs/api-gateway.md

@@ -141,7 +141,7 @@ SRC solves only dispatch for a Host process running from source. The Client does
 The repository `dsh` script completes the Host, Client, and Web build before starting the source Host. Web development runs that command and the Client plugin watcher in separate terminals:
 
 ```sh
-pnpm dsh web --dev
+pnpm dsh web
 pnpm run dev:web
 ```
 

+ 1 - 1
docs/api-gateway.zh.md

@@ -141,7 +141,7 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho
 仓库的 `dsh` 脚本会先完成 Host、Client 与 Web 构建,再启动源码 Host。Web 开发需要在两个终端中分别运行该命令和 Client plugin watcher:
 
 ```sh
-pnpm dsh web --dev
+pnpm dsh web
 pnpm run dev:web
 ```
 

+ 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: 414e15bcf8f1ac450c1607ed76061b25805e2af2
-config-catalog.zh.md: d557e299f1fc7b44d307270225982bc93e01d9db
+config-catalog.md: cc34a9a88d4f4b35ebbc17a23dc2cd610490feb9
+config-catalog.zh.md: 5fa32cf06073bfa3d14f6cd8e7bcb2ebcfa91e63

+ 13 - 12
docs/config-catalog.md

@@ -371,7 +371,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/client/hmr/src/index.ts:29`](../packages/client/hmr/src/index.ts)
+Source: [`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts)
 
 ## `@deepseek-ai/dsh-code-runtime-worker`
 
@@ -579,7 +579,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/bundle/headless/src/index.ts:29`](../packages/bundle/headless/src/index.ts)
+Source: [`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless/src/index.ts)
 
 ## `@deepseek-ai/dsh-hooks-claude`
 
@@ -647,7 +647,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c
 Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
 
 ```ts config-catalog
-/** Gateway plugin config for native Host integration. */
+/** Gateway plugin configuration. */
 export interface Config {
   /**
    * Whether this deployment can hand paths to a native desktop opener —
@@ -657,10 +657,16 @@ export interface Config {
    * container whose DISPLAY points nowhere a user can see.
    */
   nativeOpen?: boolean
+  /**
+   * DEFLATE level for every session-log ZIP entry: `0` stores without
+   * compression, `1` favors CPU/latency, and `9` favors archive size.
+   * @default 6
+   */
+  sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
 }
 ```
 
-Source: [`packages/host/apiproxy/src/index.ts:37`](../packages/host/apiproxy/src/index.ts)
+Source: [`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts)
 
 ## `@deepseek-ai/dsh-host-directory-picker-browse`
 
@@ -2555,26 +2561,21 @@ Requires: `httpServer`
 ```ts config-catalog
 /** Plugin config: composed deployment settings plus per-invocation command-line values. */
 export interface Config {
-  /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
-  mode: WebMode
   /** Print the URL line on activation; a non-interactive layer can turn it off. */
   printUrl: boolean
   /**
    * Register the model-visible surface context (the `app:web-surface` prompt
-   * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
-   * non-interactive layer can turn it off when its user is not in the GUI, so the
+   * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
+   * layer can turn it off when its user is not in the GUI, so the
    * orientation text would be false.
    */
   surfaceContext: boolean
   /** Explicit `--trusted-host` authorities from this invocation. */
   trustedHosts: string[]
 }
-
-/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
-export type WebMode = 'production' | 'development'
 ```
 
-Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
+Source: [`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts)
 
 ## `@deepseek-ai/dsh-web-fetch-local`
 

+ 13 - 12
docs/config-catalog.zh.md

@@ -373,7 +373,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/client/hmr/src/index.ts:29`](../packages/client/hmr/src/index.ts)
+来源:[`packages/client/hmr/src/index.ts:31`](../packages/client/hmr/src/index.ts)
 
 ## `@deepseek-ai/dsh-code-runtime-worker`
 
@@ -581,7 +581,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/bundle/headless/src/index.ts:29`](../packages/bundle/headless/src/index.ts)
+来源:[`packages/bundle/headless/src/index.ts:31`](../packages/bundle/headless/src/index.ts)
 
 ## `@deepseek-ai/dsh-hooks-claude`
 
@@ -649,7 +649,7 @@ export interface Config {
 需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace`
 
 ```ts config-catalog
-/** Gateway plugin config for native Host integration. */
+/** Gateway plugin configuration. */
 export interface Config {
   /**
    * Whether this deployment can hand paths to a native desktop opener —
@@ -659,10 +659,16 @@ export interface Config {
    * container whose DISPLAY points nowhere a user can see.
    */
   nativeOpen?: boolean
+  /**
+   * DEFLATE level for every session-log ZIP entry: `0` stores without
+   * compression, `1` favors CPU/latency, and `9` favors archive size.
+   * @default 6
+   */
+  sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
 }
 ```
 
-来源:[`packages/host/apiproxy/src/index.ts:37`](../packages/host/apiproxy/src/index.ts)
+来源:[`packages/host/apiproxy/src/index.ts:41`](../packages/host/apiproxy/src/index.ts)
 
 ## `@deepseek-ai/dsh-host-directory-picker-browse`
 
@@ -2556,26 +2562,21 @@ export interface WebServiceConfig {
 ```ts config-catalog
 /** Plugin config: composed deployment settings plus per-invocation command-line values. */
 export interface Config {
-  /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
-  mode: WebMode
   /** Print the URL line on activation; a non-interactive layer can turn it off. */
   printUrl: boolean
   /**
    * Register the model-visible surface context (the `app:web-surface` prompt
-   * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
-   * non-interactive layer can turn it off when its user is not in the GUI, so the
+   * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
+   * layer can turn it off when its user is not in the GUI, so the
    * orientation text would be false.
    */
   surfaceContext: boolean
   /** Explicit `--trusted-host` authorities from this invocation. */
   trustedHosts: string[]
 }
-
-/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
-export type WebMode = 'production' | 'development'
 ```
 
-来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts)
+来源:[`packages/bundle/web-app/src/index.ts:38`](../packages/bundle/web-app/src/index.ts)
 
 ## `@deepseek-ai/dsh-web-fetch-local`
 

+ 2 - 2
docs/i18n/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 docs/i18n/README.md
-README.md: 9875eb0c9924daa0b519923e9aac8a67de8cda61
-README.zh.md: eed73226dffd9bc1f6af7b21af5b0b77363878e2
+README.md: 23400801426f77dae5136406cd747dbe4b06a4c5
+README.zh.md: fe3cc7b5a5403fc9cf0c9ce536178d4fa7581e3c

+ 1 - 1
docs/i18n/README.md

@@ -41,7 +41,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
 
 ## Scope and exclusions
 
-**Scope**: every non-vendor README, plus every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.
+**Scope**: the root CONTRIBUTING document, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.
 
 Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.
 

+ 1 - 1
docs/i18n/README.zh.md

@@ -41,7 +41,7 @@
 
 ## 范围与排除
 
-**范围**:除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。
+**范围**:根目录 CONTRIBUTING 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。
 
 有经评审中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。
 

+ 2 - 2
docs/subsystems/attachment.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/attachment.md
-attachment.md: bfc1a54107c75b442f6b5b61fb705852ab4213db
-attachment.zh.md: 4da600390ea111e9b2f640c51ab786ca0505db6e
+attachment.md: ff7f14ceae8d4f8055d5cfd4367373729dc5ecbc
+attachment.zh.md: d7a9527788588d5504fdeffd8ae7849b0f8b1378

+ 3 - 1
docs/subsystems/attachment.md

@@ -104,9 +104,11 @@ abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
 /**
  * Read one image and verify that bytes still match the recorded reference.
  * @param ref - durable reference from the session log.
+ * @param signal - optional cancellation for backend read and verification work.
  * @returns the verified bytes and canonical reference.
+ * @throws the signal reason when aborted, or a storage error when verification fails.
  */
-abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
+abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
 ```
 
 Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts)

+ 3 - 1
docs/subsystems/attachment.zh.md

@@ -104,9 +104,11 @@ abstract saveImage(input: SaveImageAttachment): Promise<ImageAttachmentRef>
 /**
  * Read one image and verify that bytes still match the recorded reference.
  * @param ref - durable reference from the session log.
+ * @param signal - optional cancellation for backend read and verification work.
  * @returns the verified bytes and canonical reference.
+ * @throws the signal reason when aborted, or a storage error when verification fails.
  */
-abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
+abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
 ```
 
 Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts)

+ 2 - 2
docs/subsystems/persistence.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
-persistence.md: 792d50c52ecb2255dee429098d2ef00744479292
-persistence.zh.md: 6e1f29ee6dfc12193e7e5e4e79b4bfc5f751410f
+persistence.md: fde8348d64a200eda5133abf66deedee6be09857
+persistence.zh.md: 7a334501ee7fcbefda9d1381dfe41a79679d33b3

+ 6 - 4
docs/subsystems/persistence.md

@@ -124,7 +124,7 @@ Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resumi
 
 ## `SessionRawArtifact` — verbatim stored artifact text
 
-A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives; backends without a per-session artifact, such as SQLite, inherit the `undefined` default.
+A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives. Consumers first test `supportsRawArtifacts`: `false` means the backend does not provide this capability (for example SQLite), while `readRaw(...) === undefined` means a supported backend has no materialized artifact for that session.
 
 ```ts type-equiv
 /** A backend's own raw artifact text for one session, verbatim. */
@@ -262,13 +262,15 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined
  * bytes the backend wrote (decoded from its physical encoding, e.g. a
  * decompressed JSONL). The returned `content` is the raw text, not a
  * reconstruction from parsed events, so it preserves backend-specific
- * serialization (chunk packing, key order, line breaks). Backends without a
- * per-session artifact (SQLite) inherit the `undefined` default.
+ * serialization (chunk packing, key order, line breaks). Callers first test
+ * {@link supportsRawArtifacts}; `undefined` then means only that the requested
+ * session has no materialized artifact.
  * @param _id - the persisted session to read (unused by the default: no
  * per-session artifact).
  * @param signal - optional cancellation for backend read work.
  * @returns the raw artifact plus its parsed header, or `undefined` when the
- * session is absent or the backend owns no per-session artifact.
+ * session is absent.
+ * @throws when this backend does not expose per-session raw artifacts.
  */
 readRaw(_id: SessionId, signal?: AbortSignal): Promise<SessionRawArtifact | undefined>
 

+ 6 - 4
docs/subsystems/persistence.zh.md

@@ -124,7 +124,7 @@ interface CreateSessionOptions {
 
 ## `SessionRawArtifact`——逐字存储工件文本
 
-后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留;没有每会话工件的后端(如 SQLite)继承 `undefined` 默认。
+后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留。Consumer 须先检查 `supportsRawArtifacts`:`false` 表示后端不提供此能力(如 SQLite),而 `readRaw(...) === undefined` 表示受支持的后端没有该会话的已实体化工件。
 
 ```ts type-equiv
 /** A backend's own raw artifact text for one session, verbatim. */
@@ -262,13 +262,15 @@ abstract locate(meta: SessionHeader): SessionLocation | undefined
  * bytes the backend wrote (decoded from its physical encoding, e.g. a
  * decompressed JSONL). The returned `content` is the raw text, not a
  * reconstruction from parsed events, so it preserves backend-specific
- * serialization (chunk packing, key order, line breaks). Backends without a
- * per-session artifact (SQLite) inherit the `undefined` default.
+ * serialization (chunk packing, key order, line breaks). Callers first test
+ * {@link supportsRawArtifacts}; `undefined` then means only that the requested
+ * session has no materialized artifact.
  * @param _id - the persisted session to read (unused by the default: no
  * per-session artifact).
  * @param signal - optional cancellation for backend read work.
  * @returns the raw artifact plus its parsed header, or `undefined` when the
- * session is absent or the backend owns no per-session artifact.
+ * session is absent.
+ * @throws when this backend does not expose per-session raw artifacts.
  */
 readRaw(_id: SessionId, signal?: AbortSignal): Promise<SessionRawArtifact | undefined>
 

+ 2 - 2
packages/attachment/attachment-local/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/attachment/attachment-local/README.md
-README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f
-README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa
+README.md: ba0b9efb2cf51bfef671020bed4a2c16f6ee0119
+README.zh.md: 8e2474357a0dbb5e8834a3b25de7a977827a29e3

+ 1 - 1
packages/attachment/attachment-local/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable.
 
-`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path.
+`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
 
 ## Model Experience
 

+ 1 - 1
packages/attachment/attachment-local/README.zh.md

@@ -4,7 +4,7 @@
 
 这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。
 
-`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。
+`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
 
 ## 模型体验
 

+ 2 - 2
packages/attachment/attachment-local/src/index.ts

@@ -68,8 +68,8 @@ export class LocalAttachmentStore extends AttachmentStore {
     return saveImageFile(this.root, input, this.imageLimits)
   }
 
-  async readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
-    return readImageFile(this.root, ref)
+  async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment> {
+    return readImageFile(this.root, ref, signal)
   }
 }
 

+ 12 - 2
packages/attachment/attachment-local/src/store.ts

@@ -197,22 +197,32 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li
  * Read and verify one content-addressed image.
  * @param root - absolute `DSH_HOME/attachments/v1` root.
  * @param ref - reference recorded in the session log.
+ * @param signal - optional cancellation for filesystem and verification work.
  * @returns verified bytes and reference.
+ * @throws the signal reason when aborted, or an AttachmentError when verification fails.
  */
-export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise<StoredImageAttachment> {
+export async function readImageFile(
+  root: string,
+  ref: ImageAttachmentRef,
+  signal?: AbortSignal,
+): Promise<StoredImageAttachment> {
+  signal?.throwIfAborted()
   const sha256 = ensureReference(ref)
   let data: Uint8Array
   try {
-    data = new Uint8Array(await readFile(objectPath(root, sha256)))
+    data = new Uint8Array(await readFile(objectPath(root, sha256), { signal }))
   } catch (error) {
+    signal?.throwIfAborted()
     if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
     throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
   }
+  signal?.throwIfAborted()
   if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
   // The digest proves these are the exact bytes admission fully decoded, so
   // the read path only re-derives the header fields (no raster decode, no
   // per-request pixel amplification on history replay).
   const metadata = await probeImage(data)
+  signal?.throwIfAborted()
   if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes
     || metadata.width !== ref.width || metadata.height !== ref.height) {
     throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')

+ 26 - 1
packages/attachment/attachment-local/tests/store.spec.ts

@@ -9,12 +9,23 @@ import sharp from 'sharp'
 import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
 import { readImageFile, saveImageFile } from '../src/store.ts'
 
-const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] }))
+const fsControl = vi.hoisted(() => ({
+  readSignals: [] as AbortSignal[],
+  syncedDirectories: [] as string[],
+}))
 
 vi.mock('node:fs/promises', async (importOriginal) => {
   const actual = await importOriginal<typeof import('node:fs/promises')>()
   return {
     ...actual,
+    readFile(...args: Parameters<typeof actual.readFile>): ReturnType<typeof actual.readFile> {
+      const options = args[1]
+      if (typeof options === 'object' && options !== null) {
+        const signal = (options as { signal?: AbortSignal }).signal
+        if (signal !== undefined) fsControl.readSignals.push(signal)
+      }
+      return actual.readFile(...args)
+    },
     async open(...args: Parameters<typeof actual.open>): ReturnType<typeof actual.open> {
       if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0]))
       return actual.open(...args)
@@ -130,6 +141,20 @@ describe('local attachment store', () => {
     await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG })
   })
 
+  it('forwards read cancellation to the filesystem and preserves its reason', async () => {
+    const storageRoot = await root()
+    const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)
+    const controller = new AbortController()
+    fsControl.readSignals.length = 0
+
+    await expect(readImageFile(storageRoot, ref, controller.signal)).resolves.toEqual({ ref, data: PNG })
+    expect(fsControl.readSignals).toEqual([controller.signal])
+
+    const cancellation = new Error('attachment read cancelled')
+    controller.abort(cancellation)
+    await expect(readImageFile(storageRoot, ref, controller.signal)).rejects.toBe(cancellation)
+  })
+
   it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => {
     const storageRoot = await root()
     await expect(saveImageFile(storageRoot, {

+ 2 - 2
packages/attachment/attachment/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/attachment/attachment/README.md
-README.md: 4f450316294e554396adb9a8454051a08d9befd3
-README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890
+README.md: baeeca0cf939f1a3d4608769b362d532507b90f5
+README.zh.md: 238b90794c510e71fffe34d62b044a5c2ece8a6e

+ 1 - 1
packages/attachment/attachment/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
 
-Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata.
+Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
 
 ## Model Experience
 

+ 1 - 1
packages/attachment/attachment/README.zh.md

@@ -4,7 +4,7 @@
 
 持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
 
-未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。
+未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
 
 ## 模型体验
 

+ 3 - 1
packages/attachment/attachment/src/index.ts

@@ -52,9 +52,11 @@ export abstract class AttachmentStore extends Service {
   /**
    * Read one image and verify that bytes still match the recorded reference.
    * @param ref - durable reference from the session log.
+   * @param signal - optional cancellation for backend read and verification work.
    * @returns the verified bytes and canonical reference.
+   * @throws the signal reason when aborted, or a storage error when verification fails.
    */
-  abstract readImage(ref: ImageAttachmentRef): Promise<StoredImageAttachment>
+  abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise<StoredImageAttachment>
 }
 
 export default AttachmentStore

+ 2 - 2
packages/boot/app-boot/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/boot/app-boot/README.md
-README.md: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700
-README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a
+README.md: 4c82aa749edbeada0a344b0b119d1644544d9732
+README.zh.md: 38298e091af4aa1b09c31dfa8141ce68fa1d3f50

+ 1 - 1
packages/boot/app-boot/README.md

@@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home:
 - **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback.
 - **`cordis.patch.yml`** (home level) and **`profiles/<name>/cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`.
 
-Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
+Every profile boot keeps `cordis.patch.yml` live through `watchUserPatches` (a one-shot surface disposes the watcher through its bounded shutdown). The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh.
 
 ## Model Experience
 

+ 1 - 1
packages/boot/app-boot/README.zh.md

@@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(Harness home 由 [`
 - **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。
 - **`cordis.patch.yml`**(home 级)与 **`profiles/<name>/cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。
 
-长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
+每次 profile 启动都由 `watchUserPatches` 持续应用 `cordis.patch.yml` 的变更(一次性 surface 经由有界关闭 dispose 监视器)。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。
 
 ## 模型体验
 

+ 18 - 6
packages/boot/app-boot/tests/user-patches.spec.ts

@@ -110,21 +110,33 @@ function entryConfig(ctx: Context, id: string): unknown {
 }
 
 describe('Loader config interpolation', () => {
-  it("resolves Include's own !!js options", async () => {
+  it("keeps Include's config literal — a nested row's !!js belongs to that row's fiber", async () => {
     const dir = tmp()
-    writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n')
-    writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n  name: ./noop.mjs\n')
+    writeFileSync(join(dir, 'reader.mjs'), [
+      'export const name = "reader"',
+      'export function apply(ctx, config) { ctx.provide("observedValue", config.value) }',
+      '',
+    ].join('\n'))
+    writeFileSync(join(dir, 'cordis.yml'), '- id: reader\n  name: ./reader.mjs\n')
     const ctx = new Context()
     await ctx.plugin(Loader)
     ctx.loader.builtins.include = Include
-    ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href)
+    ctx.provide('answer', 42)
     try {
+      // The include is a tree carrier: its own config (path, patches) stays
+      // literal, and the expression nested inside the patched row's config
+      // resolves against the row's fiber, not the include's.
       await ctx.loader.create({
         name: 'cordis:include',
-        config: { path: { __jsExpr: "ctx.get('includePath')" } },
+        config: {
+          path: pathToFileURL(join(dir, 'cordis.yml')).href,
+          patches: [{ id: 'reader', name: './reader.mjs', config: { value: { __jsExpr: "ctx.get('answer')" } } }],
+        },
       })
       await ctx.loader.await()
-      expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true)
+      const reader = [...ctx.loader.entries()].find(entry => entry.options.id === 'reader')
+      expect(reader?.options.config).toEqual({ value: { __jsExpr: "ctx.get('answer')" } })
+      expect(ctx.get('observedValue')).toBe(42)
     } finally {
       await ctx.fiber.dispose()
     }

+ 2 - 2
packages/boot/cmdline/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/boot/cmdline/README.md
-README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96
-README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114
+README.md: 2e8e58b23785fa78bd2663a459817669309a81be
+README.zh.md: c04d76905edb4afa6b18b36b8284b14990be6bdd

+ 0 - 2
packages/boot/cmdline/README.md

@@ -51,8 +51,6 @@ Every row configured from those values uses ordinary service injection and direc
 
 Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset.
 
-`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering.
-
 ### Shared immutable arguments
 
 `get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments.

+ 0 - 2
packages/boot/cmdline/README.zh.md

@@ -51,8 +51,6 @@ export function apply(ctx: Context): void {
 
 Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。
 
-`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。
-
 ### 共享不可变参数
 
 `get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。

+ 0 - 24
packages/boot/cmdline/src/index.ts

@@ -17,8 +17,6 @@
 
 import type { Command } from 'commander'
 import type { Context } from '@deepseek-ai/cordis'
-// Empty type import carries the Loader Context merge used by enableRow.
-import type {} from '@deepseek-ai/cordis-plugin-loader'
 
 /**
  * The invocation's inner arguments: everything after the launcher's own flags,
@@ -133,28 +131,6 @@ export function parseCmdline<T>(
   }
 }
 
-/**
- * Turn on a row this composition ships disabled, because this invocation asked
- * for it (`dsh web --dev` and its client-plugin reload chain).
- *
- * A row cannot be inserted from inside a mounting plugin — the Loader returns a
- * prefixed id it then fails to resolve — so a conditional row ships disabled
- * and a row mounted beside it enables it after startup resolves the invocation.
- * The Loader keeps that activation in memory, separate from serialized options,
- * so reapplying the composition cannot restore the invocation's row to disabled.
- * @param ctx - plugin context whose Loader tree carries the row.
- * @param id - the row id.
- * @returns nothing once the row has started or is waiting for its dependencies.
- * @throws when the Loader or named row is absent.
- */
-export async function enableRow(ctx: Context, id: string): Promise<void> {
-  const loader = ctx.get('loader')
-  if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service')
-  const entry = [...loader.entries()].find(candidate => candidate.options.id === id)
-  if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`)
-  await entry.enableRuntime()
-}
-
 /**
  * Whether a thrown value is commander's own control-flow error (help, version,
  * a parse error, or `program.error`).

+ 1 - 68
packages/boot/cmdline/tests/cmdline.spec.ts

@@ -14,9 +14,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
 import Include from '@deepseek-ai/cordis-plugin-include'
 import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { afterEach, describe, expect, it } from 'vitest'
-import {
-  enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan,
-} from '../src/index.ts'
+import { internals, parseCmdline, provideCmdline, type CmdlinePlan } from '../src/index.ts'
 
 /** Every value one boot of the fixture tree observed. */
 interface Observed {
@@ -175,71 +173,6 @@ describe('parseCmdline', () => {
   })
 })
 
-describe('enableRow', () => {
-  it('enables the named Loader row and fails loud when the Loader or row is absent', async () => {
-    const withoutLoader = new Context()
-    await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service')
-
-    const ctx = new Context()
-    let enabled = false
-    ctx.provide('loader', {
-      entries: () => [{
-        options: { id: 'client-hmr' },
-        enableRuntime: async () => { enabled = true },
-      }],
-    } as never)
-    await enableRow(ctx, 'client-hmr')
-    expect(enabled).toBe(true)
-    await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable')
-  })
-
-  it('keeps invocation-only activation through config reapplication', async () => {
-    const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-'))
-    const observed = { starts: 0, stops: 0 }
-    ;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed
-    writeFileSync(join(dir, 'conditional.mjs'), `
-export function apply(ctx) {
-  globalThis.__runtimeEnableObserved.starts += 1
-  ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 })
-}
-`)
-    writeFileSync(join(dir, 'cordis.yml'), [
-      '- id: conditional',
-      `  name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`,
-      '  disabled: true',
-      '',
-    ].join('\n'))
-
-    const ctx = new Context()
-    await ctx.plugin(Loader)
-    ctx.loader.builtins.include = Include
-    await ctx.loader.create({
-      name: 'cordis:include',
-      config: { path: pathToFileURL(join(dir, 'cordis.yml')).href },
-    })
-    await ctx.loader.await()
-    const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional')
-    const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include')
-    expect(conditional).toBeDefined()
-    expect(include?.fiber).toBeDefined()
-    expect(conditional?.options.disabled).toBe(true)
-    expect(observed).toEqual({ starts: 0, stops: 0 })
-
-    await enableRow(ctx, 'conditional')
-    await ctx.loader.await()
-    expect(conditional?.disabled).toBe(false)
-    expect(conditional?.options.disabled).toBe(true)
-    expect(observed).toEqual({ starts: 1, stops: 0 })
-
-    await include!.fiber!.update(include!.options.config, true)
-    await ctx.loader.await()
-    expect(conditional?.disabled).toBe(false)
-    expect(conditional?.options.disabled).toBe(true)
-    expect(observed).toEqual({ starts: 1, stops: 0 })
-    disposers.push(async () => { await ctx.fiber.dispose() })
-  })
-})
-
 describe('provideCmdline', () => {
   it('hands the app a snapshot the caller cannot mutate afterwards', () => {
     const ctx = new Context()

+ 2 - 2
packages/bundle/headless/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/bundle/headless/README.md
-README.md: 31a4894dbb191d2244371ca7272339e96e253053
-README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a
+README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3
+README.zh.md: 1dcba9635b37efebeb0cc1129cc67bc7c01d0d1d

+ 2 - 2
packages/bundle/headless/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
 
-After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
+After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates.
 
 ## Model Experience
 
@@ -17,4 +17,4 @@ None; the runner adds nothing to the request prefix.
 ## Known Limitations and Deferred Work
 
 - **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
-- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the hook.
+- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request.

+ 2 - 2
packages/bundle/headless/README.zh.md

@@ -4,7 +4,7 @@
 
 dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
 
-Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
+Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。
 
 ## 模型体验
 
@@ -17,4 +17,4 @@ Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/a
 ## 已知限制与延期工作
 
 - **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。
-- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该钩子。
+- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。

+ 2 - 1
packages/bundle/headless/cordis.patch.yml

@@ -9,7 +9,8 @@
     persona: >-
       You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
 
-# One-shot runs never watch or reload their composition.
+# The shared module-reload HMR row stays off; the launcher's watch-only
+# fallback still keeps the user patch layers live until the run exits.
 - id: hmr
   disabled: true
 

+ 16 - 15
packages/bundle/headless/src/index.ts

@@ -16,8 +16,10 @@ import type {} from '@deepseek-ai/dsh-agent-default-model'
 import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
-// Empty type import carries the loader Context merge for the settlement await.
+// Empty type imports carry the loader Context merge for the settlement await
+// and the cmdline Context merge for the appExit host value.
 import type {} from '@deepseek-ai/cordis-plugin-loader'
+import type {} from '@deepseek-ai/dsh-cmdline'
 
 /** Stable Cordis plugin name. */
 export const name = 'headless-runner'
@@ -41,22 +43,18 @@ interface RunOutcome {
   reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
 }
 
-/**
- * Process-facing effects of one run, injectable for tests. The launcher owns
- * bounded tree shutdown and wires `exit()` to it.
- */
-export interface HeadlessIo {
+/** Process-facing effects of one run: output streams plus the launcher's bounded exit request. */
+interface HeadlessIo {
   stdout: { write(chunk: string): unknown }
   stderr: { write(chunk: string): unknown }
   /** Request process exit with `code` after the tree disposes. */
   exit(code: number): void
 }
 
-declare module '@deepseek-ai/cordis' {
-  interface Context {
-    /** Process-facing effects provided before the headless tree mounts. */
-    headlessIo?: HeadlessIo
-  }
+/** The process streams the runner writes to; tests substitute captures. */
+export const internals: { stdout: HeadlessIo['stdout']; stderr: HeadlessIo['stderr'] } = {
+  stdout: process.stdout,
+  stderr: process.stderr,
 }
 
 /** Aggregate the last assistant text and turn outcome in one owned interval. */
@@ -137,13 +135,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
 
 /**
  * Mount the one-shot direct driver.
- * @param ctx - plugin context carrying core services and the launcher-owned IO seam.
+ * @param ctx - plugin context carrying core services and the launcher-provided exit request.
  * @param config - validated task config.
  */
 export function apply(ctx: Context, config: Config): void {
-  const io = ctx.headlessIo
-  if (io === undefined) {
-    throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
+  // Read through the global service store, not the property proxy: appExit is
+  // an optional host value, never an injected dependency.
+  const exit = ctx.get('appExit')
+  if (exit === undefined) {
+    throw new Error('headless-runner: the launcher must provide ctx.appExit before the tree mounts')
   }
+  const io: HeadlessIo = { stdout: internals.stdout, stderr: internals.stderr, exit }
   void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
 }

+ 19 - 25
packages/bundle/headless/tests/headless.spec.ts

@@ -1,6 +1,6 @@
 /** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
 
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
 import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
@@ -8,7 +8,10 @@ import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
 import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
 import SessionStore from '@deepseek-ai/dsh-session'
 import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
-import { apply, Config, type HeadlessIo } from '../src/index.ts'
+import { apply, Config, internals } from '../src/index.ts'
+
+const originalInternals = { ...internals }
+afterEach(() => { Object.assign(internals, originalInternals) })
 
 interface Script {
   before?(session: Session): void
@@ -93,13 +96,10 @@ async function bench(script: Script): Promise<{
       let err = ''
       const order: string[] = []
       ctx.on('session/flush', () => { order.push('flush') })
+      internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
+      internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
       const exited = new Promise<number>((resolve) => {
-        const io: HeadlessIo = {
-          stdout: { write: (chunk: string) => { out += chunk; return true } },
-          stderr: { write: (chunk: string) => { err += chunk; return true } },
-          exit: (code) => { order.push('exit'); resolve(code) },
-        }
-        ctx.provide('headlessIo', io)
+        ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
       })
       apply(ctx, { task: 'do the thing' })
       return { code: await exited, out, err, order }
@@ -181,12 +181,10 @@ describe('headless runner', () => {
   it('reports a direct Agent creation failure', async () => {
     const ctx = new Context()
     let err = ''
+    internals.stdout = { write: () => true }
+    internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
     const exited = new Promise<number>((resolve) => {
-      ctx.provide('headlessIo', {
-        stdout: { write: () => true },
-        stderr: { write: (chunk: string) => { err += chunk; return true } },
-        exit: resolve,
-      } satisfies HeadlessIo)
+      ctx.provide('appExit', resolve)
     })
     ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
     ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
@@ -200,12 +198,10 @@ describe('headless runner', () => {
   it('stringifies a non-Error Agent creation failure', async () => {
     const ctx = new Context()
     let err = ''
+    internals.stdout = { write: () => true }
+    internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
     const exited = new Promise<number>((resolve) => {
-      ctx.provide('headlessIo', {
-        stdout: { write: () => true },
-        stderr: { write: (chunk: string) => { err += chunk; return true } },
-        exit: resolve,
-      } satisfies HeadlessIo)
+      ctx.provide('appExit', resolve)
     })
     ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
     ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
@@ -224,11 +220,9 @@ describe('headless runner', () => {
   it('abandons a run when the tree is disposed during Loader settlement', async () => {
     const ctx = new Context()
     let exited = false
-    ctx.provide('headlessIo', {
-      stdout: { write: () => true },
-      stderr: { write: () => true },
-      exit: () => { exited = true },
-    } satisfies HeadlessIo)
+    internals.stdout = { write: () => true }
+    internals.stderr = { write: () => true }
+    ctx.provide('appExit', () => { exited = true })
     const services = ctx.plugin((child: Context) => {
       child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
       child.provide('sessions', {} as never)
@@ -246,9 +240,9 @@ describe('headless runner', () => {
     await ctx.fiber.dispose()
   })
 
-  it('fails loud without the launcher-owned headlessIo seam', () => {
+  it('fails loud without the launcher-provided exit request', () => {
     const ctx = new Context()
-    expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
+    expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
   })
 
   it('validates config: the task is required', () => {

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd
-README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68
+README.md: 06856a47cd8ccc2c6ee5a53c40928b1bd2933cc7
+README.zh.md: 8befc7c7404ea1b082842f122769967fff32df2f

+ 3 - 3
packages/bundle/web-app/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
+The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
 
 ## Model Experience
 
@@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove
 
 #### What the model sees
 
-When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered.
+When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered.
 
 #### Token effect
 
@@ -18,7 +18,7 @@ One source line and one prompt paragraph per session plus two managed-environmen
 
 #### KV Cache effect
 
-The prompt section sits near the system prompt's head and is stable for the life of the process (port and mode are boot facts), so it does not invalidate the cache across turns.
+The prompt section sits near the system prompt's head and is stable for the life of the process (the port is a boot fact), so it does not invalidate the cache across turns.
 
 ## Known Limitations and Deferred Work
 

+ 3 - 3
packages/bundle/web-app/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
+dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
 
 ## 模型体验
 
@@ -10,7 +10,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
 
 #### 模型看到的内容
 
-当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。
+当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。
 
 #### Token 影响
 
@@ -18,7 +18,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
 
 #### KV Cache 影响
 
-该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。
+该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口是启动期事实),因此不会使跨轮次缓存失效。
 
 ## 已知限制与延期工作
 

+ 15 - 20
packages/bundle/web-app/cordis.patch.yml

@@ -8,8 +8,8 @@
 # The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an
 # ordinary Cordis service. Rows configured from flags inject that service, so
 # Loader resolves their expressions only after it exists. The web runtime then
-# provides bind-dependent `webRuntime` values to the trust fence and client
-# roster. `dsh --profile web --help` provides neither service, so no server binds.
+# provides bind-dependent `webRuntime` values to the trust fence.
+# `dsh --profile web --help` provides neither service, so no server binds.
 
 # ── surface-specific values the base deliberately omits ─────────────────────
 
@@ -105,39 +105,34 @@
     # Web glue owned by this bundle: resolves the built frontend dist (an
     # assembly fact of dsh-web-app, never user config), mounts the
     # frontend-static fallback owner, registers the web-surface prompt
-    # section and bash runtime variables, and prints the URL line. The webStartup
-    # provider supplies invocation-only values; after the server binds, this row
-    # samples LAN trust once and provides `webRuntime`. A complete agent-preset
-    # persona suppresses the prompt section for that agent while retaining
-    # these host-owned shell variables.
+    # section and the bash runtime variable, and prints the URL line. The
+    # webStartup provider supplies invocation-only values; after the server
+    # binds, this row samples LAN trust once and provides `webRuntime`. A
+    # complete agent-preset persona suppresses the prompt section for that
+    # agent while retaining the host-owned shell variable.
     - id: web-runtime
       name: '@deepseek-ai/dsh-web-app'
       inject: [webStartup]
       config:
-        mode: !!js ctx.webStartup.mode
         printUrl: true
         surfaceContext: true
         trustedHosts: !!js ctx.webStartup.trustedHosts
 
-    # The client-plugin reload chain: a dev-only row this bundle ships off,
-    # which the runtime row turns on before client discovery. It is a row rather
-    # than a child of web-runtime because its node half is a client-side package,
-    # which a host-side bundle cannot import.
+    # The client-plugin reload chain, always mounted: it is idle until a
+    # rebuild watcher (pnpm run dev:web) actually rewrites client bundles. It
+    # is a row rather than a child of web-runtime because its node half is a
+    # client-side package, which a host-side bundle cannot import.
     - id: client-hmr
       name: '@deepseek-ai/dsh-client-hmr'
-      inject: [webStartup]
-      disabled: true
 
     # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
 
-    # Dual-face: this waits for the runtime row to decide whether HMR belongs
-    # in the first graph. The node half then scans this tree, composes
-    # window.__DSH_BOOT__, and serves /plugins/<id>/client.js; the browser half
-    # is the module table the shell kernel constructs before cordis exists
-    # (adopted as a plugin entry by the kernel, never fetched).
+    # Dual-face: the node half scans this tree, composes window.__DSH_BOOT__,
+    # and serves /plugins/<id>/client.js; the browser half is the module table
+    # the shell kernel constructs before cordis exists (adopted as a plugin
+    # entry by the kernel, never fetched).
     - id: modules
       name: '@deepseek-ai/dsh-client-modules'
-      inject: [webRuntime]
 
     # Owns both ends of the web transport: node half binds the gateway to the
     # webserver under /api; browser half is the fetch/SSE client.

+ 13 - 33
packages/bundle/web-app/src/index.ts

@@ -5,7 +5,7 @@
  * the built frontend dist (workspace knowledge of this bundle, never user
  * config), mounts the `frontend-static` fallback owner over it, registers the
  * harness-source and web-surface prompt sections, the bash-visible web runtime
- * variables, and the URL line. App command-line values arrive through the
+ * variable, and the URL line. App command-line values arrive through the
  * `webStartup` service expressions in the bundle patch.
  * @module @deepseek-ai/dsh-web-app
  */
@@ -16,7 +16,6 @@ import { fileURLToPath } from 'node:url'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
-import { enableRow } from '@deepseek-ai/dsh-cmdline'
 import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static'
 import type {} from '@deepseek-ai/cordis-plugin-loader'
 import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -28,7 +27,6 @@ export const name = 'web-app'
 
 /** This dsh installation's root, from either this package's source or built entry. */
 const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
-const HMR_ROW_ID = 'client-hmr'
 
 /** Runtime service that releases Web rows after bind-dependent values resolve. */
 const WEB_RUNTIME_SERVICE = 'webRuntime'
@@ -36,19 +34,14 @@ const WEB_RUNTIME_SERVICE = 'webRuntime'
 /** Services required before the web runtime can mount. */
 export const inject = ['httpServer']
 
-/** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */
-export type WebMode = 'production' | 'development'
-
 /** Plugin config: composed deployment settings plus per-invocation command-line values. */
 export interface Config {
-  /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */
-  mode: WebMode
   /** Print the URL line on activation; a non-interactive layer can turn it off. */
   printUrl: boolean
   /**
    * Register the model-visible surface context (the `app:web-surface` prompt
-   * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot
-   * non-interactive layer can turn it off when its user is not in the GUI, so the
+   * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
+   * layer can turn it off when its user is not in the GUI, so the
    * orientation text would be false.
    */
   surfaceContext: boolean
@@ -57,7 +50,6 @@ export interface Config {
 }
 
 export const Config: z<Config> = z.object({
-  mode: z.union([z.const('production'), z.const('development')]).default('production'),
   printUrl: z.boolean().default(true),
   surfaceContext: z.boolean().default(true),
   trustedHosts: z.array(String).default([]),
@@ -73,8 +65,6 @@ export interface WebRuntimeValues {
 
 /** Environment variable naming the canonical local URL of this Web GUI. */
 const DSH_WEB_URL = 'DSH_WEB_URL' as const
-/** Environment variable naming the Web runtime mode. */
-const DSH_WEB_MODE = 'DSH_WEB_MODE' as const
 
 // Display-only mirror of the webserver schema's loopback host: the address the
 // local URL always prints. Not a source of truth — the schema is.
@@ -102,13 +92,10 @@ export function resolveLanTrust(bindHost: string, extra: readonly string[]): Web
 }
 
 /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
-function webSurfacePrompt(webUrl: string, mode: WebMode): string {
-  const updateContract = mode === 'development'
-    ? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. '
-      + 'No-refresh updates occur only when `pnpm run dev:web` is also running from this same checkout to rebuild client-plugin bundles; verify that watcher before promising automatic updates. '
-      + 'Client-plugin changes then reload automatically, while apps/web shell and other plain-package changes still require a rebuild and page refresh. '
-    : 'This Web process was launched without `--dev`, so HMR is inactive: rebuild the affected Web artifacts and verify this existing URL after a page refresh. '
-      + 'If the user wants no-refresh client-plugin updates, explain that this GUI must be restarted with `dsh web --dev` and `pnpm run dev:web` must also run from this same checkout; do not present either command alone as sufficient. '
+function webSurfacePrompt(webUrl: string): string {
+  const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while '
+    + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. '
+    + 'Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. '
   return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. `
     + 'When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. '
     + 'The browser provides no implicit DOM, route, or screenshot context. '
@@ -140,20 +127,14 @@ function resolveDistIndex(): string {
 export const internals: { resolveDistIndex: () => string } = { resolveDistIndex }
 
 /**
- * Mount the Web runtime: dist serving, surface prompt, bash runtime
- * variables, and the URL line.
+ * Mount the Web runtime: dist serving, surface prompt, the bash runtime
+ * variable, and the URL line.
  * @param ctx - plugin context carrying the httpServer service.
  * @param config - validated {@link Config}.
- * @returns nothing once the invocation's client roster and runtime contributions are registered.
  */
-export async function apply(ctx: Context, config: Config): Promise<void> {
-  // Client discovery must start after the optional HMR row has a pending
-  // fiber. Otherwise its first browser graph omits the reload receiver, which
-  // cannot use that receiver to discover itself later.
-  if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID)
+export function apply(ctx: Context, config: Config): void {
   const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts)
-  // Release dependent rows only after the optional row has a pending fiber and
-  // bind-dependent trust has been sampled once.
+  // Release dependent rows only after bind-dependent trust has been sampled once.
   ctx.provide(WEB_RUNTIME_SERVICE, runtime)
   ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() })
   if (config.surfaceContext) {
@@ -162,7 +143,7 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
       promptCtx.systemPrompt.section({
         name: 'app:web-surface',
         order: -98,
-        text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode),
+        text: () => webSurfacePrompt(localWebUrl(promptCtx)),
       })
     })
     ctx.inject(['bashEnv'], (runtimeCtx) => {
@@ -170,9 +151,8 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
         name: 'web-runtime',
         variables: {
           [DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' },
-          [DSH_WEB_MODE]: { description: 'Web runtime mode: production, or development when the client-plugin HMR receiver is active.' },
         },
-        resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx), [DSH_WEB_MODE]: config.mode }),
+        resolve: () => ({ [DSH_WEB_URL]: localWebUrl(runtimeCtx) }),
       })
     })
   }

+ 1 - 7
packages/bundle/web-app/src/startup.ts

@@ -1,6 +1,6 @@
 /**
  * The web app's command-line provider: it parses the `dsh --profile web` flag
- * family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help`
+ * family (`--host`, `--port`, `--trusted-host`) and its `--help`
  * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
  * Ordinary rows inject that service before reading it from lazy config.
  * @module @deepseek-ai/dsh-web-app/startup
@@ -25,8 +25,6 @@ export interface WebStartupValues {
   host?: string
   /** `--port`, absent when the invocation did not name one. */
   port?: number
-  /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */
-  mode: 'production' | 'development'
   /** Explicit `--trusted-host` authorities, in argument order. */
   trustedHosts: string[]
 }
@@ -35,7 +33,6 @@ export interface WebStartupValues {
 interface WebOptions {
   host?: string
   port?: string
-  dev?: boolean
   trustedHost?: string[]
 }
 
@@ -50,14 +47,12 @@ function webCommand(): Command {
     .helpOption('-h, --help', 'show this help')
     .option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
     .option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
-    .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
     .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
     .addHelpText('after', `
 Examples:
   dsh --profile web                          serve on the composed host and port
   dsh --profile web --port 8080              serve on another port
   dsh --profile web --host 0.0.0.0           reach it from another machine on the LAN
-  dsh --profile web --dev                    mount the client-plugin HMR receiver
 `)
 }
 
@@ -74,7 +69,6 @@ function planWebStartup(program: Command): WebStartupValues {
   return {
     ...options.host !== undefined && { host: options.host },
     ...options.port !== undefined && { port: Number(options.port) },
-    mode: options.dev === true ? 'development' : 'production',
     trustedHosts: options.trustedHost ?? [],
   }
 }

+ 1 - 5
packages/bundle/web-app/tests/startup.spec.ts

@@ -57,7 +57,6 @@ export const apply = ctx => globalThis.__webStartupApply(ctx)
     '  config:',
     "    host: !!js ctx.webStartup.host ?? '127.0.0.1'",
     '    port: !!js ctx.webStartup.port ?? 3080',
-    '    mode: !!js ctx.webStartup.mode',
     '    trustedHosts: !!js ctx.webStartup.trustedHosts',
     '- id: provider',
     `  name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`,
@@ -91,14 +90,12 @@ describe('web command-line provider', () => {
     const { values, observed } = await bootProvider([
       '--host', '0.0.0.0',
       '--port', '8080',
-      '--dev',
       '--trusted-host', 'lab.internal', 'lab-2.internal',
       '--trusted-host', '10.0.0.9',
     ])
     expect(values).toEqual({
       host: '0.0.0.0',
       port: 8080,
-      mode: 'development',
       trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'],
     })
     expect(observed.readerConfig).toEqual(values)
@@ -107,11 +104,10 @@ describe('web command-line provider', () => {
 
   it('leaves deployment values to each consumer when flags omit them', async () => {
     const { values, observed } = await bootProvider([])
-    expect(values).toEqual({ mode: 'production', trustedHosts: [] })
+    expect(values).toEqual({ trustedHosts: [] })
     expect(observed.readerConfig).toEqual({
       host: '127.0.0.1',
       port: 3080,
-      mode: 'production',
       trustedHosts: [],
     })
   })

+ 21 - 28
packages/bundle/web-app/tests/web-app.spec.ts

@@ -58,17 +58,9 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
   return { server, seat: () => fallback }
 }
 
-/** Install the optional HMR row the runtime sequences before client discovery. */
-function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] {
-  const updates: string[] = []
-  ctx.provide('loader', {
-    entries: () => [{
-      options: { id: 'client-hmr' },
-      enableRuntime: async () => { updates.push('client-hmr') },
-    }],
-    await: settle,
-  } as never)
-  return updates
+/** A fake Loader whose settlement the test controls (the URL line waits on it). */
+function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
+  ctx.provide('loader', { await: settle } as never)
 }
 
 interface BashContribution {
@@ -90,15 +82,14 @@ describe('web-app runtime glue', () => {
         return () => {}
       },
     } as never)
-    const enabledRows = provideHmrRow(ctx)
+    provideLoader(ctx)
     const log = vi.spyOn(console, 'log').mockImplementation(() => {})
-    await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
+    apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] }))
     await ctx.plugin(SystemPrompt, { persona: '' })
     // Settle the injected registrations.
     await new Promise(resolve => setTimeout(resolve, 0))
 
     expect(seat()).toBeDefined() // frontend-static claimed the fallback
-    expect(enabledRows).toEqual(['client-hmr'])
     expect(ctx.get('webRuntime')).toEqual({
       lanAddresses: ['192.168.1.5'],
       trustedHosts: ['192.168.1.5', 'lab.internal'],
@@ -108,24 +99,26 @@ describe('web-app runtime glue', () => {
     expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout')
     const section = assembly.sections.find(entry => entry.name === 'app:web-surface')
     expect(section?.text).toContain('http://127.0.0.1:4567')
-    expect(section?.text).toContain('--dev')
+    // The single update contract: the receiver is always on; no-refresh
+    // reloads additionally need the rebuild watcher.
+    expect(section?.text).toContain('pnpm run dev:web')
     const webRuntime = contributions.find(contribution => contribution.name === 'web-runtime')
-    expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567', DSH_WEB_MODE: 'development' })
+    expect(webRuntime?.resolve()).toEqual({ DSH_WEB_URL: 'http://127.0.0.1:4567' })
     await ctx.fiber.dispose()
   })
 
-  it('stays quiet in production mode with printUrl off and reports the production update contract', async () => {
+  it('stays quiet with printUrl off', async () => {
     stageDist()
     const ctx = new Context()
     ctx.provide('httpServer', fakeHttpServer().server)
     const log = vi.spyOn(console, 'log').mockImplementation(() => {})
-    await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
+    apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
     await ctx.plugin(SystemPrompt, { persona: '' })
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(log).not.toHaveBeenCalled()
     const assembly = await ctx.systemPrompt.assemble()
     expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
-      .toContain('without `--dev`')
+      .toContain('rebuilding the affected Web artifacts')
     await ctx.fiber.dispose()
   })
 
@@ -140,7 +133,7 @@ describe('web-app runtime glue', () => {
         return () => {}
       },
     } as never)
-    await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
+    apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] }))
     await ctx.plugin(SystemPrompt, { persona: '' })
     await new Promise(resolve => setTimeout(resolve, 0))
     const assembly = await ctx.systemPrompt.assemble()
@@ -155,7 +148,7 @@ describe('web-app runtime glue', () => {
     const ctx = new Context()
     ctx.provide('httpServer', fakeHttpServer().server)
     const log = vi.spyOn(console, 'log').mockImplementation(() => {})
-    await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
+    apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
     await ctx.fiber.dispose()
@@ -169,9 +162,9 @@ describe('web-app runtime glue', () => {
     settled.provide('httpServer', fakeHttpServer().server)
     let release: () => void
     const settlement = new Promise<void>((resolve) => { release = resolve })
-    provideHmrRow(settled, () => settlement)
+    provideLoader(settled, () => settlement)
     const log = vi.spyOn(console, 'log').mockImplementation(() => {})
-    await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
+    apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(log).not.toHaveBeenCalled()
     release!()
@@ -184,8 +177,8 @@ describe('web-app runtime glue', () => {
     log.mockClear()
     const failed = new Context()
     failed.provide('httpServer', fakeHttpServer().server)
-    provideHmrRow(failed, async () => { throw new Error('boot failed') })
-    await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
+    provideLoader(failed, async () => { throw new Error('boot failed') })
+    apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
     await new Promise(resolve => setTimeout(resolve, 0))
     expect(log).not.toHaveBeenCalled()
     await failed.fiber.dispose()
@@ -200,8 +193,8 @@ describe('web-app runtime glue', () => {
     await child
     let releaseTorn: () => void
     const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
-    provideHmrRow(torn, () => tornSettlement)
-    await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] }))
+    provideLoader(torn, () => tornSettlement)
+    apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
     await child.dispose() // the httpServer service goes away
     releaseTorn!()
     await new Promise(resolve => setTimeout(resolve, 0))
@@ -217,7 +210,7 @@ describe('web-app runtime glue', () => {
     const { server } = fakeHttpServer()
     Object.defineProperty(server, 'port', { get: () => undefined })
     ctx.provide('httpServer', server)
-    await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] }))
+    apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] }))
     await ctx.plugin(SystemPrompt, { persona: '' })
     await new Promise(resolve => setTimeout(resolve, 0))
     await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')

+ 2 - 2
packages/client/connection/src/client/fixture.ts

@@ -2835,8 +2835,8 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
       return Promise.resolve({ accepted: true })
     },
     // Satisfies the ApiProxy contract type only: the browser export button
-    // fetches GET /api/session.export directly (window.fetch), so this stub is
-    // never reached through the fixture's dispatch.
+    // hands GET /api/session.export to the native download manager, so this
+    // stub is never reached through the fixture's dispatch.
     downloads: {
       sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
     },

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

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

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

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-Hot reload for script-loaded client plugins. A static-arrival entry composed only into `--dev` graphs (`dsh web --dev`); production graphs omit the row, so the shell-bundled code stays inert.
+Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle.
 
 The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `<style data-plugin>` tags, `entry.refresh()` re-imports and remounts, `fiber.await()` rethrows startup failures loud. Dependents reload through cordis itself: a fiber's activation epoch strings its service providers' uids, so replacing a provider's fiber cascades every dependent with zero client-side graph analysis. The node half detects rebuilds with one interval that stat-polls each graph bundle from a synchronous baseline, immediately re-hashes after adding a row, retains missing rows as dirty, and broadcasts only real rev changes; any tsdown watch process producing the bundle therefore triggers HMR with no builder→host channel.
 

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

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动。
+为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher(`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲。
 
 浏览器侧订阅系统 SSE(Server-Sent Events)通道(`GET /plugins/events`),每个 `rebuilt` 帧重载一个插件,并通过队列串行执行。每帧的顺序是:`invalidate`、`prefetch`(旧 fiber 仍在服务时加载并注册新组合包)、`registry.delete`(在 fiber dispose(资源释放)之前执行:仅 dispose fiber 会触发 vendored Loader 的 self-dispose 分支,把配置项标为禁用)、排空旧 fiber、删除 `entry.fiber`、移除自身拥有的 `<style data-plugin>` 标签、通过 `entry.refresh()` 重新导入并挂载、通过 `fiber.await()` 直接重新抛出启动失败。依赖方由 Cordis 自身重载:fiber 的激活 epoch 会串联其服务提供方的 uid,因此替换提供方 fiber 会级联所有依赖方,无需客户端图分析。node 侧使用一个 interval 检测重建:从同步基线开始 stat-poll 每个图组合包;新增一行后立即重新计算 hash;缺失行保持 dirty;只广播真实 rev 变更。因此,任何生成组合包的 tsdown watch 进程都能触发 HMR(热模块替换),无需 builder→host 通道。
 

+ 3 - 1
packages/client/hmr/src/index.ts

@@ -4,7 +4,9 @@
  * mounts deliver no inotify events), reports content changes through
  * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
  * broadcasting graph/rebuilt frames to the browser half (src/client/).
- * Dev-only row: prod compositions never mount this plugin.
+ * The web bundle mounts this row unconditionally: without a rebuild
+ * watcher rewriting client bundles, the poll observes no changes and the
+ * chain stays idle.
  */
 import { statSync } from 'node:fs'
 import type { ServerResponse } from 'node:http'

+ 2 - 2
packages/client/ui-trajectory/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
-README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d
-README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d
+README.md: f4b3bd223c2872f0341d49bdaa102440d73b4f29
+README.zh.md: 9bcb3b6ad98d672cc524c168f2024be9ba56b577

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
packages/client/ui-trajectory/README.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 0 - 0
packages/client/ui-trajectory/README.zh.md


+ 17 - 14
packages/client/ui-trajectory/src/client/export-log.ts

@@ -1,7 +1,8 @@
 /**
- * Session log export: browser download of the host-streamed ZIP. The archive
- * itself is produced and streamed by the host (GET /api/session.export); this
- * module only derives the download filename and triggers the browser save.
+ * Session log export delivery. The host streams the archive from
+ * `GET /api/session.export`; this module owns the browser-native download
+ * handoff so the browser can stream the response directly to its download
+ * manager instead of buffering the ZIP in JavaScript.
  * @module
  */
 
@@ -27,16 +28,18 @@ export function sessionLogZipFilename(sessionId: string): string {
 }
 
 /**
- * Trigger a browser download of a blob response.
- * @param blob - the response body to save (passed straight through, no copy).
- * @param filename - the download filename.
+ * Hand one host-streamed session archive to the browser download manager.
+ * The operation resolves after dispatching the native download; HTTP delivery
+ * continues outside JavaScript and is reported by the browser itself.
+ * @param sessionId - the root session id to export with all descendants.
+ * @returns a promise that rejects if the browser handoff itself fails.
  */
-export function downloadBlob(blob: Blob, filename: string): void {
-  const url = URL.createObjectURL(blob)
-  const anchor = document.createElement('a')
-  anchor.href = url
-  anchor.download = filename
-  anchor.click()
-  // Revoke one tick later: some browsers read the blob URL after click().
-  setTimeout(() => { URL.revokeObjectURL(url) }, 0)
+export function downloadSessionLog(sessionId: string): Promise<void> {
+  return Promise.resolve().then(() => {
+    const query = new URLSearchParams({ sessionId, includeDescendants: 'true' })
+    const anchor = document.createElement('a')
+    anchor.href = `/api/session.export?${query.toString()}`
+    anchor.download = sessionLogZipFilename(sessionId)
+    anchor.click()
+  })
 }

+ 2 - 18
packages/client/ui-trajectory/src/client/index.ts

@@ -10,7 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 // owning package) must be in the program for the register calls to type.
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { createTrajectoryDurationStore } from './duration-store.ts'
-import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
+import { downloadSessionLog } from './export-log.ts'
 import { en, NS, zh } from './locales.ts'
 import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
 import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
@@ -60,23 +60,7 @@ export function apply(ctx: Context): void {
           return session.getSnapshot().views.get('trajectory') !== before
         },
         setActualDuration: (value) => { duration.set(value) },
-        exportLog: async () => {
-          // The host streams the ZIP (root + descendant artifacts verbatim)
-          // from GET /api/session.export; the browser downloads the response.
-          // A null origin (no-location Node contexts) falls back like the
-          // carrier's resolveBase so the URL stays valid.
-          const loc = (globalThis as { location?: { origin?: string } }).location
-          const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal'
-          const url = new URL('/api/session.export', origin)
-          url.searchParams.set('sessionId', sessionId)
-          url.searchParams.set('includeDescendants', 'true')
-          const response = await fetch(url)
-          if (!response.ok) {
-            const detail = await response.text().catch(() => '')
-            throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
-          }
-          downloadBlob(await response.blob(), sessionLogZipFilename(sessionId))
-        },
+        exportLog: () => downloadSessionLog(sessionId),
       }
     },
   }, TrajectoryView))

+ 33 - 6
packages/client/ui-trajectory/tests/export-log.spec.ts

@@ -1,12 +1,15 @@
-// @vitest-environment node
+// @vitest-environment jsdom
 /**
- * Session-log export filename derivation. The archive itself is produced and
- * streamed by the host (GET /api/session.export); this package only derives
- * the download filename and triggers the browser save.
+ * Session-log export browser delivery: safe filename derivation and a native
+ * download handoff that leaves the streamed response outside JavaScript.
  */
 
-import { describe, expect, it } from 'vitest'
-import { sessionLogZipFilename } from '../src/client/export-log.ts'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { downloadSessionLog, sessionLogZipFilename } from '../src/client/export-log.ts'
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
 
 describe('sessionLogZipFilename', () => {
   it('keeps safe session ids verbatim', () => {
@@ -22,3 +25,27 @@ describe('sessionLogZipFilename', () => {
     expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip')
   })
 })
+
+describe('downloadSessionLog', () => {
+  it('hands the descendant-inclusive endpoint directly to the browser', async () => {
+    const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
+
+    await downloadSessionLog('session/with spaces')
+
+    expect(click).toHaveBeenCalledOnce()
+    const anchor = click.mock.contexts[0] as HTMLAnchorElement
+    const url = new URL(anchor.href)
+    expect(url.pathname).toBe('/api/session.export')
+    expect(url.searchParams.get('sessionId')).toBe('session/with spaces')
+    expect(url.searchParams.get('includeDescendants')).toBe('true')
+    expect(anchor.download).toBe('dsh-session-session_with_spaces.zip')
+  })
+
+  it('rejects when the browser download handoff fails', async () => {
+    vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
+      throw new Error('download denied')
+    })
+
+    await expect(downloadSessionLog('session-root')).rejects.toThrow('download denied')
+  })
+})

+ 9 - 22
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -1141,39 +1141,26 @@ describe('timeline projection', () => {
 describe('session log export', () => {
   afterEach(() => {
     vi.unstubAllGlobals()
-    Reflect.deleteProperty(URL, 'createObjectURL')
     Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click')
   })
 
   it('downloads the host-streamed ZIP with descendants on click', async () => {
-    // exportLog always fetches a URL instance, so the mock's shape stays narrow.
-    const fetchMock = vi.fn(async (input: URL) => {
-      expect(input.pathname).toBe('/api/session.export')
-      expect(input.searchParams.get('sessionId')).toBe(SID)
-      expect(input.searchParams.get('includeDescendants')).toBe('true')
-      return new Response('zip-bytes')
-    })
-    vi.stubGlobal('fetch', fetchMock)
-    const createObjectURL = vi.fn(() => 'blob:export')
-    URL.createObjectURL = createObjectURL
     const clickAnchor = vi.fn()
     HTMLAnchorElement.prototype.click = clickAnchor
     const b = await bench(historySnapshot(NODES))
     mount(b.slots)
     fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
     fireEvent.click(screen.getByRole('button', { name: 'Export session log' }))
-    await vi.waitFor(() => {
-      expect(fetchMock).toHaveBeenCalledOnce()
-    })
-    // The blob download lands a few microtasks after the fetch settles.
-    await vi.waitFor(() => {
-      expect(createObjectURL).toHaveBeenCalled()
-    })
-    expect(clickAnchor).toHaveBeenCalled()
+    await vi.waitFor(() => { expect(clickAnchor).toHaveBeenCalledOnce() })
+    const anchor = clickAnchor.mock.contexts[0] as HTMLAnchorElement
+    const url = new URL(anchor.href)
+    expect(url.pathname).toBe('/api/session.export')
+    expect(url.searchParams.get('sessionId')).toBe(SID)
+    expect(url.searchParams.get('includeDescendants')).toBe('true')
   })
 
-  it('surfaces the download failure in the visible alert bar', async () => {
-    vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 })))
+  it('surfaces a browser handoff failure in the visible alert bar', async () => {
+    HTMLAnchorElement.prototype.click = vi.fn(() => { throw new Error('download denied') })
     const b = await bench(historySnapshot(NODES))
     mount(b.slots)
     fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
@@ -1181,7 +1168,7 @@ describe('session log export', () => {
     await vi.waitFor(() => {
       const alert = screen.queryByRole('alert')
       expect(alert).not.toBeNull()
-      expect(alert!.textContent).toContain('HTTP 404')
+      expect(alert!.textContent).toContain('download denied')
     })
   })
 })

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů