refactor(web): remove --dev; mount the reload chain unconditionally

The client-hmr row joins the web bundle as an ordinary always-on roster
row: without a rebuild watcher rewriting client bundles it polls
unchanged files and stays idle. This deletes the --dev flag, the web
runtime's mode config, the mode-forked prompt contract, the DSH_WEB_MODE
bash variable, and the post-settlement row-creation machinery the
conditional row required. dsh web + pnpm run dev:web remains the
development loop.
This commit is contained in:
Turtle
2026-08-11 15:39:03 +08:00
parent fb301ace65
commit 341051603f
42 changed files with 136 additions and 304 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: 3347bdac95eb8e06be3e7c20d24319103f738149
2026-07-23-client-plugin-loading-model.zh.md: 68f2e70253485d4e215c981d3b338d5046390247 2026-07-23-client-plugin-loading-model.zh.md: d52409e167b536162a8efb9a9ecc3092f6e5d1d2

View File

@@ -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. - **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. - **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. 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.** **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. 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). 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 ### 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. 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-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-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-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 | | ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI features | plugins, on-demand | conversation domain split; trajectory real implementation |
## Consequences ## 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. 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.

View File

@@ -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 图不可见。 - **普通包**是模块系统自身所需的绝对基座,加上尚未转成 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。 - **插件包**是其余一切。每个都携带 `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 的名册。除此之外无需任何交接。 新增一个插件包:声明 `dsh.client`,经共享预设产出 `./client` bundle把包名加进负责组合的 app 的名册。除此之外无需任何交接。
@@ -66,7 +66,7 @@ vendored Loader 经其 `internal` 约定消费模块系统——唯一调用点
**host 侧——组合这张图。** **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 报告这两类错误。 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 自己注册)。 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 ### 热重载:一个驱动插件,自行监视的 bundle
热重载是否启用是一项组合决策:dev 组合挂载 `client-hmr` 行(一个常规的插件包,由 `--dev` 追加),其 node 半带来 bundle 监视与 SSEServer-Sent Events通道prod 组合不挂载,两者皆无 热重载是一项组合决策:web 组合包无条件挂载 `client-hmr` 行(一个常规的插件包),其 node 半带来 bundle 监视与 SSEServer-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` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500msdispose资源释放会清掉那一个定时器。重建 bundle 则是任意一个 tsdown watch 进程的事——`scripts/dev-web.ts` 仍作为 watch 构建入口保留,其包清单在启动时扫描 `packages/*/*/package.json` 按 dsh.client 发现——构建器与 host 共享零协议。写一半的 bundle 被撕裂读取会自愈:写入完成期间 stat 持续变化,下一个轮询节拍会再次重哈希并广播最终的 rev。 重建好的 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` 也是同一原因;轮询间隔是一个经校验的配置字段(默认 500msdispose资源释放会清掉那一个定时器。重建 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-runtime` | 会话对象层 + slots 服务 + store 引擎 | 插件,声明 `immediately` | 持续缩向纯会话对象层 |
| `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry另行裁定 | | `dsh-client-ui-theme` | 主题 token/服务 | 插件,声明 `immediately`,外加 `./styles/*` 源码通道 | Theme Registry另行裁定 |
| `dsh-client-i18n` | I18nService | 插件,声明 `immediately` | 按部署组合语言包 | | `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 真实现 | | ui-layout / ui-sidebar / ui-conversation / ui-trajectory | UI 功能 | 插件,按需到达 | conversation 域拆分trajectory 真实现 |
## Consequences ## 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 状态。 接受的代价vendored Loader 在浏览器里背着闲置机件EntryTree 持久化是 no-op分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面;每个 bundle 多出一份 sourcemap 产物,外部脚本失败也只能给出粗粒度的 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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: 88c3fe3daed114f937c65b0afe1dbf4867f0a679 2026-08-06-app-owned-command-line.md: 2480775f654fd5c2fecebc8d59e311acee878920
2026-08-06-app-owned-command-line.zh.md: 1f6db72326312809c6c5a90e9bf26b412c7eddd8 2026-08-06-app-owned-command-line.zh.md: d754c125d5bc683156f5ac3f285e2cd711e6773b

View File

@@ -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 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 creates the `client-hmr` row after Loader settlement, 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. 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 ## 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 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. - **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. - **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. - **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 the Web runtime creates its conditional row (`dsh web --dev` and its reload chain) in the root tree after Loader settlement. A root-tree row is outside the include, so user-patch reapplication cannot touch it, and the incremental client-module scan adds it to the roster before any page loads — a browser arrives only after a human reads the URL line.
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. 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.

View File

@@ -16,18 +16,17 @@ profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍
boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。
已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 在 Loader 结算后创建 `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 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web``dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。
## 为什么由 Loader 持有顺序 ## 为什么由 Loader 持有顺序
条框架事实塑造了这套机制: 条框架事实塑造了这套机制:
- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 声明了 `EntryGroup.key` 树载体标记(与 Group 相同),因此 Loader 让它的配置——条目与 patch 列表,包括 Include 自己的 `path`——保持字面值,而不是在 Include 上下文中递归求值嵌套的 `!!js` 节点;每个表达式都在其目标行的 fiber 中解析。 - **profile 的各行位于根 include 的 `patches` 选项内部。** Include 声明了 `EntryGroup.key` 树载体标记(与 Group 相同),因此 Loader 让它的配置——条目与 patch 列表,包括 Include 自己的 `path`——保持字面值,而不是在 Include 上下文中递归求值嵌套的 `!!js` 节点;每个表达式都在其目标行的 fiber 中解析。
- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfallCordis 快照注入服务之后Loader 的监听器再插值原始配置。 - **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfallCordis 快照注入服务之后Loader 的监听器再插值原始配置。
- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfallHMR 会把原始配置带给替换 fiber而待处理行可以接受选项变更不会针对缺失服务提前求值表达式。 - **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfallHMR 会把原始配置带给替换 fiber而待处理行可以接受选项变更不会针对缺失服务提前求值表达式。
- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id随后它自己解析不出来——因此 Web runtime 在 Loader 结算后在根树中创建其条件行(`dsh web --dev` 及其重载链路)。根树的行在 include 之外,用户 patch 的重新应用无法触及它;增量式客户端模块扫描会在任何页面加载之前把它加入名录——浏览器只会在人读到 URL 行之后到来。
这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md
2026-08-11-cmdline-seam-trim.md: 3fb2f3e0941ad4cf6e2fb7e1afbe6cf31af92f41 2026-08-11-cmdline-seam-trim.md: d7908d2c80552f13500ccd36c59a249f1374cbb1
2026-08-11-cmdline-seam-trim.zh.md: 002a76fc57e1d26e15c2619a380874a0eecd435f 2026-08-11-cmdline-seam-trim.zh.md: 275a32b584afdbcc77d2356775f35c74786445ab

View File

@@ -6,13 +6,13 @@ English | [中文](2026-08-11-cmdline-seam-trim.zh.md)
## Problem ## 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), 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`. 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 ## Decision
Express all three with interfaces that already exist: Express all three with interfaces that already exist:
- **Conditional dev row.** `dsh-web-app` no longer ships a disabled `client-hmr` row; in development mode its runtime plugin creates the row in the root tree after Loader settlement with plain `loader.create`; a whole-tree name scan makes the creation reload-idempotent and defers to a user-configured `dsh-client-hmr` row (even a disabled one). A root-tree row is outside the include, so user-patch reapplication cannot restore it to disabled — the property the in-memory override existed for. The incremental client-module scan adds it to the roster before any page loads; a browser arrives only after a human reads the URL line, and its `EventSource` reconnects by spec. `Entry.enableRuntime`, its two state fields, and `enableRow` are deleted. - **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. - **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. - **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.
@@ -21,10 +21,11 @@ Express all three with interfaces that already exist:
- **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. - **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. - **`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. - **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 ## 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. - 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 `--dev` reload row is not covered by the boot activation audit; a creation failure is logged, not fatal. - 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. - 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). - 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).

View File

@@ -6,13 +6,13 @@ Status: implemented
## 问题 ## 问题
应用自有命令行([笔记](2026-08-06-app-owned-command-line.md))交付时带着三条比其消费者所需更宽的接缝:一台 vendored 的内存行激活状态机(`Entry.enableRuntime`,外加从 `dsh-cmdline` 导出的 `enableRow` —— 一个命令行包拥有了 Loader 概念)、一个只有 Include 一个实现者的 vendored `EntryConfigResolver` 协议符号,以及仍然识别 `headless-runner` 行的启动器 —— 用它选择 SIGTERM 退出码、门控用户 patch 监视,并提供与 `ctx.appExit` 重复的 `headlessIo` 接缝。 应用自有命令行([笔记](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`;开发模式下其 runtime 插件在 Loader 结算后用普通的 `loader.create` 在根树中创建该行;全树名称扫描让创建具备重载幂等性,并让位于用户自行配置的 `dsh-client-hmr` 行(即便该行被禁用)。根树的行在 include 之外,用户 patch 的重新应用无法把它恢复为禁用 —— 这正是内存覆盖机制存在的理由。增量式客户端模块扫描会在任何页面加载之前把它加入名录;浏览器只会在人读到 URL 行之后到来,其 `EventSource` 按规范自动重连`Entry.enableRuntime`、它的两个状态字段和 `enableRow` 一并删除。 - **不再有条件 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` 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。 - **树载体配置。** 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` 删除。 - **启动器的应用知识。** 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出SIGINT 仍为 130启动器无从知道应用是否认为工作已完成而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 `ctx.appExit` 退出;其输出流是包内 `internals` 测试接缝,`ctx.headlessIo` 删除。
@@ -21,10 +21,11 @@ Status: implemented
- **保留 `enableRuntime` 但把 `enableRow` 移出 `dsh-cmdline`**:搬迁修正了包边界,却保留了 vendored 状态机,其语义(在重新应用后仍生效、失败时回滚)在每次上游同步时都要重新推导。 - **保留 `enableRuntime` 但把 `enableRow` 移出 `dsh-cmdline`**:搬迁修正了包边界,却保留了 vendored 状态机,其语义(在重新应用后仍生效、失败时回滚)在每次上游同步时都要重新推导。
- **`entry.update({ disabled: null })`**:改写条目的序列化选项,下一次 include 重新应用会恢复 `disabled: true` 并在会话中途卸载该行。 - **`entry.update({ disabled: null })`**:改写条目的序列化选项,下一次 include 重新应用会恢复 `disabled: true` 并在会话中途卸载该行。
- **通过应用注册的信号处理器为一次性 surface 保留 SIGTERM 143**:启动器自己的处理器会与它竞争退出码;要赢得竞争需要新的启动器接口,而这正是本次变更要移除的成本。 - **通过应用注册的信号处理器为一次性 surface 保留 SIGTERM 143**:启动器自己的处理器会与它竞争退出码;要赢得竞争需要新的启动器接口,而这正是本次变更要移除的成本。
- **保留 `--dev`、改为运行时创建该行**:本次变更的中间形态;它仍需要提示词约定里的模式分叉、`DSH_WEB_MODE` 变量,以及创建与用户自有行之间的仲裁,而这一切只为省下一个成本可忽略的空闲轮询。
## 后果 ## 后果
- 用 SIGTERM 监督 `dsh --profile headless` 的部署现在观察到退出码 0 而非 143信号是调用方自己发的且 stdout 上没有答案。 - 用 SIGTERM 监督 `dsh --profile headless` 的部署现在观察到退出码 0 而非 143信号是调用方自己发的且 stdout 上没有答案。
- `--dev` 重载行不在启动激活审计的覆盖内;创建失败只记录日志,不致命 - 重载链在每个 `dsh web` 进程中运行;不得暴露 `/plugins/events` 的部署应在其 patch 层禁用 `client-hmr`
- 一次性运行会挂载之前跳过的配置监视行,启动多花几毫秒。 - 一次性运行会挂载之前跳过的配置监视行,启动多花几毫秒。
- vendored Loader/Include 偏差减少一个协议符号和一台状态机,`rescope-vendor:check` 重新通过(修改日志的 rescope 条目回到其精确编辑锚点要求的位置)。 - vendored Loader/Include 偏差减少一个协议符号和一台状态机,`rescope-vendor:check` 重新通过(修改日志的 rescope 条目回到其精确编辑锚点要求的位置)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: fa7fcee80dc91ad7ec4a9a994927daa2cc293baa
2026-07-28-web-gui-feedback-loop.zh.md: 9b6954092b737920ed18bc412037e917512e40dc 2026-07-28-web-gui-feedback-loop.zh.md: ea83441efa83ad0c93e8cb1f0daf71ecfeabb69e

View File

@@ -12,9 +12,9 @@ The [incident post-mortem](../../../../docs/postmortem/0003-web-agent-gui-feedba
## Decision ## 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. 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 ## 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 ## 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. **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. **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 ## 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.

View File

@@ -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 构建模式保持不变。 `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` 会绕过包脚本。服务模式本身必须失败。 **删除 `apps/web` 开发脚本,但不为 Vite 添加防护。** 不予采纳,因为事故中实际使用的命令 `npx vite` 会绕过包脚本。服务模式本身必须失败。
**每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而插件 HMR 已有独立且显式的 `dsh web --dev` 组合 **每次编辑后自动重启或替换当前 Web 进程。** 不予采纳,因为静态服务器本就会在每次请求时读取当前产物,重启还会中断发起编辑请求的会话,而客户端插件重载由始终挂载的 HMR 链路加 `pnpm run dev:web` watcher 负责
**每次请求都发送 DOM、路由或截图。** 推迟到另行设计的已记录输入机制。稳定的 URL 身份足以闭合本次反馈循环,同时不会声称宿主掌握其未接收的浏览器状态。 **每次请求都发送 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 上下文。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md # pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: 8a38677868f85d4a5b24376a96f0e336c13fa89c README.md: 46ea3c241d6775ce90a89c7be58901375a0634a3
README.zh.md: c0ee1a5fabb8eec6bd34a8a386b2e2409d570e55 README.zh.md: f020f46260d6b04b87a4918a671ca6bcbed251d9

View File

@@ -24,7 +24,7 @@ The shipped apps own these command lines:
| Profile | Arguments | | Profile | Arguments |
|---|---| |---|---|
| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` | | `web` | `--host`, `--port`, repeatable `--trusted-host` |
| `headless` | the task text, as the positional argument | | `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. 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 ## 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, which mounts the client-plugin HMR receiver row after Loader settlement; 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 ```sh
dsh web dsh web

View File

@@ -24,7 +24,7 @@
| Profile | 参数 | | Profile | 参数 |
|---|---| |---|---|
| `web` | `--host``--port``--dev`可重复的 `--trusted-host` | | `web` | `--host``--port`、可重复的 `--trusted-host` |
| `headless` | 任务文本,作为位置参数 | | `headless` | 任务文本,作为位置参数 |
一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent智能体提交任务、等待完全停稳并对 Session 执行 flush再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 一次性任务(`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 别名 ## Web 别名
`dsh web``--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host``--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority部署表达式会拼接自己的 authority`--dev` 把 web-runtime 行切换到开发模式,由其在 Loader 结算后挂载客户端插件 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 ```sh
dsh web dsh web

View File

@@ -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 { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' 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)) watcher = subprocessCtx.subprocess.spawn(spawnSpec(['pnpm', 'run', 'dev:web'], REPO_ROOT))
await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web')
host = subprocessCtx.subprocess.spawn(spawnSpec( host = subprocessCtx.subprocess.spawn(spawnSpec(
[process.execPath, binPath, 'web', '--dev', '--port', '0'], [process.execPath, binPath, 'web', '--port', '0'],
world, world,
{ {
DEEPSEEK_API_KEY: 'keyless-hmr-no-call', DEEPSEEK_API_KEY: 'keyless-hmr-no-call',
DSH_HOME: join(world, '.dsh'), 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() browser = await chromium.launch()
const page = await browser.newPage() const page = await browser.newPage()
const pageErrors: string[] = [] const pageErrors: string[] = []

View File

@@ -101,14 +101,14 @@ describe('web e2e: fresh round trip through the real assembly', () => {
callId: CallId('web-url-probe'), callId: CallId('web-url-probe'),
name: 'bash', name: 'bash',
arguments: { 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', description: 'Print current Web runtime',
}, },
agent, agent,
}) })
expect(result.isError).toBe(false) expect(result.isError).toBe(false)
expect(result.content.filter(block => block.type === 'text').map(block => block.text).join('')) 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 () => { it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => {

View File

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

View File

@@ -27,7 +27,7 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts' 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> { function waitForReadyLine(child: ChildProcess): Promise<string> {
return new Promise((resolveReady, reject) => { 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() requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
mkdirSync(join(workspace, '.git')) 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 tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
const child = spawn( const child = spawn(
process.execPath, 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, cwd: workspace,
env: { env: {
@@ -261,7 +261,7 @@ describe('dsh web keyless CLI smoke', () => {
const workspaceMessage = captured.messages?.find(message => const workspaceMessage = captured.messages?.find(message =>
message.role === 'user' && message.content?.includes('web-workspace-context-probe')) message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
const systemMessage = captured.messages?.find(message => message.role === 'system') 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) .replace('{{webUrl}}', baseUrl)
expect(systemMessage?.content).toContain(expectedWebSection) expect(systemMessage?.content).toContain(expectedWebSection)
expect(workspaceMessage).toMatchInlineSnapshot(` expect(workspaceMessage).toMatchInlineSnapshot(`

View File

@@ -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. 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}}. You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.

View File

@@ -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.

View File

@@ -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.

View File

@@ -6,7 +6,7 @@ import react from '@vitejs/plugin-react'
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) 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__. ' 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`. ' + '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. */ /** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
function rejectStandaloneServe(): Plugin { function rejectStandaloneServe(): Plugin {

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/api-gateway.md # pnpm run verify-translation-pairing --write docs/api-gateway.md
api-gateway.md: 3065f3f10861965b327a4412049878dc1ba7faec api-gateway.md: c60793532d621585fc9878b6197497b45015856b
api-gateway.zh.md: aa9b726c33fd9f51fc0b2d2ed95c4c9658662796 api-gateway.zh.md: daf46f6ad208f4cb959028ff59d98d9de7a072d6

View File

@@ -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: 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 ```sh
pnpm dsh web --dev pnpm dsh web
pnpm run dev:web pnpm run dev:web
``` ```

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md # pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: f6129274172a5c88af2c1c05bf8b07a73ed4f56e config-catalog.md: 10d8e6cbe1ab1729166860680ef0e9f4dd72cb91
config-catalog.zh.md: 000ad2c8e6d366649b1f72a45943d826d7ab96c7 config-catalog.zh.md: fb1c620ef30c85e887335c5664db43e35f408416

View File

@@ -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` ## `@deepseek-ai/dsh-code-runtime-worker`
@@ -2555,26 +2555,21 @@ Requires: `httpServer`
```ts config-catalog ```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */ /** Plugin config: composed deployment settings plus per-invocation command-line values. */
export interface Config { 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. */ /** Print the URL line on activation; a non-interactive layer can turn it off. */
printUrl: boolean printUrl: boolean
/** /**
* Register the model-visible surface context (the `app:web-surface` prompt * 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 * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
* non-interactive layer can turn it off when its user is not in the GUI, so the * layer can turn it off when its user is not in the GUI, so the
* orientation text would be false. * orientation text would be false.
*/ */
surfaceContext: boolean surfaceContext: boolean
/** Explicit `--trusted-host` authorities from this invocation. */ /** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[] 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:42`](../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` ## `@deepseek-ai/dsh-web-fetch-local`

View File

@@ -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` ## `@deepseek-ai/dsh-code-runtime-worker`
@@ -2556,26 +2556,21 @@ export interface WebServiceConfig {
```ts config-catalog ```ts config-catalog
/** Plugin config: composed deployment settings plus per-invocation command-line values. */ /** Plugin config: composed deployment settings plus per-invocation command-line values. */
export interface Config { 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. */ /** Print the URL line on activation; a non-interactive layer can turn it off. */
printUrl: boolean printUrl: boolean
/** /**
* Register the model-visible surface context (the `app:web-surface` prompt * 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 * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
* non-interactive layer can turn it off when its user is not in the GUI, so the * layer can turn it off when its user is not in the GUI, so the
* orientation text would be false. * orientation text would be false.
*/ */
surfaceContext: boolean surfaceContext: boolean
/** Explicit `--trusted-host` authorities from this invocation. */ /** Explicit `--trusted-host` authorities from this invocation. */
trustedHosts: string[] 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:42`](../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` ## `@deepseek-ai/dsh-web-fetch-local`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd README.md: 06856a47cd8ccc2c6ee5a53c40928b1bd2933cc7
README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68 README.zh.md: 8befc7c7404ea1b082842f122769967fff32df2f

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md) 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 ## Model Experience
@@ -10,7 +10,7 @@ The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides ove
#### What the model sees #### 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 #### Token effect
@@ -18,7 +18,7 @@ One source line and one prompt paragraph per session plus two managed-environmen
#### KV Cache effect #### 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 ## Known Limitations and Deferred Work

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文 [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 影响 #### Token 影响
@@ -18,7 +18,7 @@ dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
#### KV Cache 影响 #### KV Cache 影响
该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口与模式是启动期事实),因此不会使跨轮次缓存失效。 该提示词段落位于系统提示词靠前位置,且在进程整个生命周期内稳定(端口是启动期事实),因此不会使跨轮次缓存失效。
## 已知限制与延期工作 ## 已知限制与延期工作

View File

@@ -105,29 +105,32 @@
# Web glue owned by this bundle: resolves the built frontend dist (an # Web glue owned by this bundle: resolves the built frontend dist (an
# assembly fact of dsh-web-app, never user config), mounts the # assembly fact of dsh-web-app, never user config), mounts the
# frontend-static fallback owner, registers the web-surface prompt # frontend-static fallback owner, registers the web-surface prompt
# section and bash runtime variables, and prints the URL line. The webStartup # section and the bash runtime variable, and prints the URL line. The
# provider supplies invocation-only values; after the server binds, this row # webStartup provider supplies invocation-only values; after the server
# samples LAN trust once and provides `webRuntime`. A complete agent-preset # binds, this row samples LAN trust once and provides `webRuntime`. A
# persona suppresses the prompt section for that agent while retaining # complete agent-preset persona suppresses the prompt section for that
# these host-owned shell variables. # agent while retaining the host-owned shell variable.
- id: web-runtime - id: web-runtime
name: '@deepseek-ai/dsh-web-app' name: '@deepseek-ai/dsh-web-app'
inject: [webStartup] inject: [webStartup]
config: config:
mode: !!js ctx.webStartup.mode
printUrl: true printUrl: true
surfaceContext: true surfaceContext: true
trustedHosts: !!js ctx.webStartup.trustedHosts trustedHosts: !!js ctx.webStartup.trustedHosts
# 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'
# ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ──
# Dual-face: the node half scans this tree, composes window.__DSH_BOOT__, # 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 # and serves /plugins/<id>/client.js; the browser half is the module table
# the shell kernel constructs before cordis exists (adopted as a plugin # the shell kernel constructs before cordis exists (adopted as a plugin
# entry by the kernel, never fetched). In development mode the web-runtime # entry by the kernel, never fetched).
# row creates the client-plugin reload chain (dsh-client-hmr) as a root
# tree row after Loader settlement; the incremental scan adds it to the
# roster before any page loads.
- id: modules - id: modules
name: '@deepseek-ai/dsh-client-modules' name: '@deepseek-ai/dsh-client-modules'

View File

@@ -5,7 +5,7 @@
* the built frontend dist (workspace knowledge of this bundle, never user * the built frontend dist (workspace knowledge of this bundle, never user
* config), mounts the `frontend-static` fallback owner over it, registers the * config), mounts the `frontend-static` fallback owner over it, registers the
* harness-source and web-surface prompt sections, the bash-visible web runtime * 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. * `webStartup` service expressions in the bundle patch.
* @module @deepseek-ai/dsh-web-app * @module @deepseek-ai/dsh-web-app
*/ */
@@ -27,7 +27,6 @@ export const name = 'web-app'
/** This dsh installation's root, from either this package's source or built entry. */ /** This dsh installation's root, from either this package's source or built entry. */
const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url))
const HMR_ROW_NAME = '@deepseek-ai/dsh-client-hmr'
/** Runtime service that releases Web rows after bind-dependent values resolve. */ /** Runtime service that releases Web rows after bind-dependent values resolve. */
const WEB_RUNTIME_SERVICE = 'webRuntime' const WEB_RUNTIME_SERVICE = 'webRuntime'
@@ -35,19 +34,14 @@ const WEB_RUNTIME_SERVICE = 'webRuntime'
/** Services required before the web runtime can mount. */ /** Services required before the web runtime can mount. */
export const inject = ['httpServer'] 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. */ /** Plugin config: composed deployment settings plus per-invocation command-line values. */
export interface Config { 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. */ /** Print the URL line on activation; a non-interactive layer can turn it off. */
printUrl: boolean printUrl: boolean
/** /**
* Register the model-visible surface context (the `app:web-surface` prompt * 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 * section and the `DSH_WEB_URL` bash variable). A one-shot non-interactive
* non-interactive layer can turn it off when its user is not in the GUI, so the * layer can turn it off when its user is not in the GUI, so the
* orientation text would be false. * orientation text would be false.
*/ */
surfaceContext: boolean surfaceContext: boolean
@@ -56,7 +50,6 @@ export interface Config {
} }
export const Config: z<Config> = z.object({ export const Config: z<Config> = z.object({
mode: z.union([z.const('production'), z.const('development')]).default('production'),
printUrl: z.boolean().default(true), printUrl: z.boolean().default(true),
surfaceContext: z.boolean().default(true), surfaceContext: z.boolean().default(true),
trustedHosts: z.array(String).default([]), trustedHosts: z.array(String).default([]),
@@ -72,8 +65,6 @@ export interface WebRuntimeValues {
/** Environment variable naming the canonical local URL of this Web GUI. */ /** Environment variable naming the canonical local URL of this Web GUI. */
const DSH_WEB_URL = 'DSH_WEB_URL' as const 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 // 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. // local URL always prints. Not a source of truth — the schema is.
@@ -101,13 +92,10 @@ export function resolveLanTrust(bindHost: string, extra: readonly string[]): Web
} }
/** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */
function webSurfacePrompt(webUrl: string, mode: WebMode): string { function webSurfacePrompt(webUrl: string): string {
const updateContract = mode === 'development' const updateContract = 'The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while '
? 'This Web process was launched with `dsh web --dev`, so its client-plugin HMR receiver is active. ' + '`pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. '
+ '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. ' + '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. '
+ '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. '
return `You are interacting with the user through the DeepSeek Harness Web GUI at ${webUrl}. ` 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. ' + '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 browser provides no implicit DOM, route, or screenshot context. '
@@ -139,38 +127,12 @@ function resolveDistIndex(): string {
export const internals: { resolveDistIndex: () => string } = { resolveDistIndex } export const internals: { resolveDistIndex: () => string } = { resolveDistIndex }
/** /**
* Mount the Web runtime: dist serving, surface prompt, bash runtime * Mount the Web runtime: dist serving, surface prompt, the bash runtime
* variables, the development-mode client-hmr row, and the URL line. * variable, and the URL line.
* @param ctx - plugin context carrying the httpServer service. * @param ctx - plugin context carrying the httpServer service.
* @param config - validated {@link Config}. * @param config - validated {@link Config}.
*/ */
export function apply(ctx: Context, config: Config): void { export function apply(ctx: Context, config: Config): void {
if (config.mode === 'development') {
// The dev reload chain is mounted as a real tree row so the browser
// roster scan includes its client half; it is a row rather than a child
// of this plugin because its node half is a client-side package, which a
// host-side bundle cannot import. Created in the root tree after Loader
// settlement: row creation must stay out of the mounting transaction,
// and a root-tree row survives user-patch reapplication of the include.
// The incremental roster scan picks it up before any page load — a
// browser arrives only after a human reads the URL line.
const loader = ctx.get('loader')
if (loader === undefined) {
ctx.logger.warn('web-app: development mode without a Loader tree mounts no client-hmr row')
} else {
void loader.await().then(async () => {
// The tree can be disposed while settlement was in flight (early
// SIGTERM); re-check before mutating it. The name scan spans every
// tree (entries() recurses into subtrees), so a row the user
// configured in a patch layer — enabled, reconfigured, or
// deliberately disabled — wins over this default, and a reload of
// this fiber never duplicates the row a previous generation created.
if (ctx.get('loader') === undefined) return
const mounted = [...ctx.loader.entries()].some(entry => entry.options.name === HMR_ROW_NAME)
if (!mounted) await ctx.loader.create({ name: HMR_ROW_NAME })
}).catch((error: unknown) => { ctx.logger.error(error) })
}
}
const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts) const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts)
// Release dependent rows only after 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.provide(WEB_RUNTIME_SERVICE, runtime)
@@ -181,7 +143,7 @@ export function apply(ctx: Context, config: Config): void {
promptCtx.systemPrompt.section({ promptCtx.systemPrompt.section({
name: 'app:web-surface', name: 'app:web-surface',
order: -98, order: -98,
text: () => webSurfacePrompt(localWebUrl(promptCtx), config.mode), text: () => webSurfacePrompt(localWebUrl(promptCtx)),
}) })
}) })
ctx.inject(['bashEnv'], (runtimeCtx) => { ctx.inject(['bashEnv'], (runtimeCtx) => {
@@ -189,9 +151,8 @@ export function apply(ctx: Context, config: Config): void {
name: 'web-runtime', name: 'web-runtime',
variables: { variables: {
[DSH_WEB_URL]: { description: 'Canonical local URL of the DeepSeek Harness Web GUI serving this session.' }, [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) }),
}) })
}) })
} }

View File

@@ -1,6 +1,6 @@
/** /**
* The web app's command-line provider: it parses the `dsh --profile web` flag * 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}. * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}.
* Ordinary rows inject that service before reading it from lazy config. * Ordinary rows inject that service before reading it from lazy config.
* @module @deepseek-ai/dsh-web-app/startup * @module @deepseek-ai/dsh-web-app/startup
@@ -25,8 +25,6 @@ export interface WebStartupValues {
host?: string host?: string
/** `--port`, absent when the invocation did not name one. */ /** `--port`, absent when the invocation did not name one. */
port?: number 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. */ /** Explicit `--trusted-host` authorities, in argument order. */
trustedHosts: string[] trustedHosts: string[]
} }
@@ -35,7 +33,6 @@ export interface WebStartupValues {
interface WebOptions { interface WebOptions {
host?: string host?: string
port?: string port?: string
dev?: boolean
trustedHost?: string[] trustedHost?: string[]
} }
@@ -50,14 +47,12 @@ function webCommand(): Command {
.helpOption('-h, --help', 'show this help') .helpOption('-h, --help', 'show this help')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine') .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('--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)') .option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.addHelpText('after', ` .addHelpText('after', `
Examples: Examples:
dsh --profile web serve on the composed host and port dsh --profile web serve on the composed host and port
dsh --profile web --port 8080 serve on another 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 --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 { return {
...options.host !== undefined && { host: options.host }, ...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) }, ...options.port !== undefined && { port: Number(options.port) },
mode: options.dev === true ? 'development' : 'production',
trustedHosts: options.trustedHost ?? [], trustedHosts: options.trustedHost ?? [],
} }
} }

View File

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

View File

@@ -58,20 +58,9 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server:
return { server, seat: () => fallback } return { server, seat: () => fallback }
} }
/** A fake Loader capturing the dev-mode row creation the runtime performs after settlement. */ /** A fake Loader whose settlement the test controls (the URL line waits on it). */
function provideHmrRow(ctx: Context, settle: () => Promise<void> = async () => {}): string[] { function provideLoader(ctx: Context, settle: () => Promise<void> = async () => {}): void {
const created: string[] = [] ctx.provide('loader', { await: settle } as never)
const entries: { options: { name: string } }[] = []
ctx.provide('loader', {
entries: () => entries[Symbol.iterator](),
create: (options: { name: string }) => {
created.push(options.name)
entries.push({ options })
return Promise.resolve(options.name)
},
await: settle,
} as never)
return created
} }
interface BashContribution { interface BashContribution {
@@ -93,15 +82,14 @@ describe('web-app runtime glue', () => {
return () => {} return () => {}
}, },
} as never) } as never)
const enabledRows = provideHmrRow(ctx) provideLoader(ctx)
const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
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: '' }) await ctx.plugin(SystemPrompt, { persona: '' })
// Settle the injected registrations. // Settle the injected registrations.
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
expect(seat()).toBeDefined() // frontend-static claimed the fallback expect(seat()).toBeDefined() // frontend-static claimed the fallback
expect(enabledRows).toEqual(['@deepseek-ai/dsh-client-hmr'])
expect(ctx.get('webRuntime')).toEqual({ expect(ctx.get('webRuntime')).toEqual({
lanAddresses: ['192.168.1.5'], lanAddresses: ['192.168.1.5'],
trustedHosts: ['192.168.1.5', 'lab.internal'], trustedHosts: ['192.168.1.5', 'lab.internal'],
@@ -111,24 +99,26 @@ describe('web-app runtime glue', () => {
expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') 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') 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('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') 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() 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() stageDist()
const ctx = new Context() const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server) ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
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 ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled() expect(log).not.toHaveBeenCalled()
const assembly = await ctx.systemPrompt.assemble() const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text)
.toContain('without `--dev`') .toContain('rebuilding the affected Web artifacts')
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
@@ -143,7 +133,7 @@ describe('web-app runtime glue', () => {
return () => {} return () => {}
}, },
} as never) } as never)
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 ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
const assembly = await ctx.systemPrompt.assemble() const assembly = await ctx.systemPrompt.assemble()
@@ -158,116 +148,12 @@ describe('web-app runtime glue', () => {
const ctx = new Context() const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server) ctx.provide('httpServer', fakeHttpServer().server)
const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
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)) await new Promise(resolve => setTimeout(resolve, 0))
expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567')
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('creates the client-hmr row exactly once across runtime reloads', async () => {
stageDist()
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const created = provideHmrRow(ctx)
const mount = async (): Promise<() => Promise<void>> => {
const fiber = ctx.plugin((child: Context) => {
apply(child, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
})
await fiber
await new Promise(resolve => setTimeout(resolve, 0))
return () => fiber.dispose()
}
const disposeFirst = await mount()
expect(created).toEqual(['@deepseek-ai/dsh-client-hmr'])
await disposeFirst()
// A reload generation must not duplicate the row the previous one created.
const disposeSecond = await mount()
expect(created).toEqual(['@deepseek-ai/dsh-client-hmr'])
await disposeSecond()
await ctx.fiber.dispose()
})
it('defers to a user-configured client-hmr row anywhere in the tree', async () => {
stageDist()
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer().server)
const created: string[] = []
// The user's own row — possibly patched into an include subtree and even
// disabled there — already carries the name; the runtime must not create
// a second one beside it.
ctx.provide('loader', {
entries: () => [{ options: { id: 'my-hmr', name: '@deepseek-ai/dsh-client-hmr', disabled: true } }][Symbol.iterator](),
create: (options: { name: string }) => {
created.push(options.name)
return Promise.resolve(options.name)
},
await: () => Promise.resolve(),
} as never)
apply(ctx, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(created).toEqual([])
await ctx.fiber.dispose()
})
it('skips the dev row when the tree is disposed during settlement and logs a creation failure', async () => {
stageDist()
const raced = new Context()
raced.provide('httpServer', fakeHttpServer().server)
let release!: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
const created: string[] = []
const disposeLoader = raced.provide('loader', {
entries: () => [][Symbol.iterator](),
create: (options: { name: string }) => {
created.push(options.name)
return Promise.resolve(options.name)
},
await: () => settlement,
} as never)
apply(raced, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
disposeLoader()
release()
await new Promise(resolve => setTimeout(resolve, 0))
expect(created).toEqual([])
await raced.fiber.dispose()
const failing = new Context()
failing.provide('httpServer', fakeHttpServer().server)
const failure = new Error('row creation failed')
failing.provide('loader', {
entries: () => [][Symbol.iterator](),
create: () => Promise.reject(failure),
await: () => Promise.resolve(),
} as never)
const errors: unknown[] = []
failing.logger.error = ((error: unknown) => { errors.push(error) }) as typeof failing.logger.error
apply(failing, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(errors).toEqual([failure])
await failing.fiber.dispose()
})
it('mounts no dev row in production and only warns without a Loader in development', async () => {
stageDist()
const prod = new Context()
prod.provide('httpServer', fakeHttpServer().server)
const created = provideHmrRow(prod)
apply(prod, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0))
expect(created).toEqual([])
await prod.fiber.dispose()
const bare = new Context()
bare.provide('httpServer', fakeHttpServer().server)
const warnings: string[] = []
bare.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof bare.logger.warn
apply(bare, new Config({ mode: 'development', printUrl: false, surfaceContext: false, trustedHosts: [] }))
expect(warnings).toEqual(['web-app: development mode without a Loader tree mounts no client-hmr row'])
// Let the vitest invariant host settle before tearing the root down.
await new Promise(resolve => setTimeout(resolve, 0))
await bare.fiber.dispose()
})
it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => {
stageDist() stageDist()
// Settlement path: the line waits for loader.await() so supervisors can // Settlement path: the line waits for loader.await() so supervisors can
@@ -276,9 +162,9 @@ describe('web-app runtime glue', () => {
settled.provide('httpServer', fakeHttpServer().server) settled.provide('httpServer', fakeHttpServer().server)
let release: () => void let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve }) const settlement = new Promise<void>((resolve) => { release = resolve })
provideHmrRow(settled, () => settlement) provideLoader(settled, () => settlement)
const log = vi.spyOn(console, 'log').mockImplementation(() => {}) const log = vi.spyOn(console, 'log').mockImplementation(() => {})
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)) await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled() expect(log).not.toHaveBeenCalled()
release!() release!()
@@ -291,8 +177,8 @@ describe('web-app runtime glue', () => {
log.mockClear() log.mockClear()
const failed = new Context() const failed = new Context()
failed.provide('httpServer', fakeHttpServer().server) failed.provide('httpServer', fakeHttpServer().server)
provideHmrRow(failed, async () => { throw new Error('boot failed') }) provideLoader(failed, async () => { throw new Error('boot failed') })
apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
expect(log).not.toHaveBeenCalled() expect(log).not.toHaveBeenCalled()
await failed.fiber.dispose() await failed.fiber.dispose()
@@ -307,8 +193,8 @@ describe('web-app runtime glue', () => {
await child await child
let releaseTorn: () => void let releaseTorn: () => void
const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve }) const tornSettlement = new Promise<void>((resolve) => { releaseTorn = resolve })
provideHmrRow(torn, () => tornSettlement) provideLoader(torn, () => tornSettlement)
apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] }))
await child.dispose() // the httpServer service goes away await child.dispose() // the httpServer service goes away
releaseTorn!() releaseTorn!()
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
@@ -324,7 +210,7 @@ describe('web-app runtime glue', () => {
const { server } = fakeHttpServer() const { server } = fakeHttpServer()
Object.defineProperty(server, 'port', { get: () => undefined }) Object.defineProperty(server, 'port', { get: () => undefined })
ctx.provide('httpServer', server) ctx.provide('httpServer', server)
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 ctx.plugin(SystemPrompt, { persona: '' })
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing')

View File

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

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md) 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. 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.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文 [English](README.md) | 中文
为通过脚本加载的客户端插件提供热重载。该静态加载配置项只组合进 `--dev` 图(`dsh web --dev`);生产图省略该项,因此打包进 shell 的代码保持不活动 为通过脚本加载的客户端插件提供热重载。web 组合包无条件挂载该行;没有重建 watcher`pnpm run dev:web`)改写客户端 bundle 时,轮询观察不到变化,链路保持空闲
浏览器侧订阅系统 SSEServer-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 通道。 浏览器侧订阅系统 SSEServer-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 通道。

View File

@@ -4,7 +4,9 @@
* mounts deliver no inotify events), reports content changes through * mounts deliver no inotify events), reports content changes through
* `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel * `clientModuleHost.rebuilt(id)`, and serves the `/plugins/events` SSE channel
* broadcasting graph/rebuilt frames to the browser half (src/client/). * 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 { statSync } from 'node:fs'
import type { ServerResponse } from 'node:http' import type { ServerResponse } from 'node:http'

View File

@@ -2,7 +2,7 @@
* Watch-build for client-plugin HMR: runs every `dsh.client` plugin package * Watch-build for client-plugin HMR: runs every `dsh.client` plugin package
* through the tsdown JS API in watch mode. Reload signaling is not this * through the tsdown JS API in watch mode. Reload signaling is not this
* script's business — the host webserver stat-polls the bundles it serves and * script's business — the host webserver stat-polls the bundles it serves and
* broadcasts `rebuilt` frames itself (`dsh web --dev`), so any process that * broadcasts `rebuilt` frames itself (`dsh web`), so any process that
* rewrites `lib/client.js` files triggers reloads; this script is merely the * rewrites `lib/client.js` files triggers reloads; this script is merely the
* convenient way to keep them all rebuilt on source change. * convenient way to keep them all rebuilt on source change.
* *