feat(tui): experimental dev-only /reload command for loader configs

Squashes feat/tui-reload-command and fix/reload-idle-only: /reload requires
an idle agent.
This commit is contained in:
Turtle
2026-07-22 14:29:28 +08:00
parent 19f9a509aa
commit 9c8994f65a
3 changed files with 76 additions and 0 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-tui-reload-command.md: e5600f0ab5cd82dc556df76006fcf532d8c7d302
2026-07-21-tui-reload-command.zh.md: 3798b0518df1c379cca808bd4af38490016567cb
@@ -0,0 +1,35 @@
# Agent Note: The /reload command re-reads loader configs on demand
Status: implemented
English | [中文](2026-07-21-tui-reload-command.zh.md)
## Problem
HMR's file watcher only reacts to in-place `change` events under its configured roots (the config leaf's directory in the shipped demos). Editors that replace files by rename (BSD `sed -i`, `git checkout`) produce no event, and runtimes without the HMR entry (or without `--expose-internals`) have no config reload path at all. During development that means restarting the TUI to apply a config edit the watcher missed. Widening the watch roots to the whole repo was considered and rejected in discussion: dense package sharing makes module-level HMR a remount-most-of-the-tree operation with unpredictable externals boundaries.
## Decision
`dsh-tui` gains an **experimental, dev-only** `/reload` slash command: it walks `ctx.loader.entries()` and calls `refresh()` on every file-backed subtree (`Include`), i.e. the exact code path the HMR watcher's config-change branch drives, invoked manually and watcher-independent. Unchanged files are no-ops (content comparison in `Include.read`); invalid files warn and keep the running tree (the hot-reload-resilience contract); include `patches` — including the dsh CLI's personal overlay — re-apply on every re-read.
The TUI reaches the Loader **structurally** (`ctx.loader` via a local type, not `inject`): tests and embedders run the TUI without a Loader, where `/reload` degrades to a warning notice instead of failing the mount. Module-source hot reload stays watcher-owned; `/reload` refreshes configs only.
## Alternatives considered
**Widening the HMR watch roots to `packages/`/`apps/`.** Rejected for now: plugin-source changes reload every dependent plugin's fiber, and the repo's dense shared packages (`dsh-session`, `dsh-llm`, `dsh-tools`) make that a teardown of the spine and the UI mid-session — a restart in disguise with partial-reload hazards. A manual config-scope command captures the safe, predictable subset.
**Declaring `loader` in `inject`.** Rejected: it would make the Loader a hard dependency of the TUI, breaking every Loader-less composition (unit harness, embedders) for a dev convenience.
**A `cordis_reload` model-facing tool in dsh-tool-cordis.** Rejected: this is an operator action for the human at the terminal, not a capability the model should trigger; the cordis toolset's mount/unmount surface already covers the model's runtime-modification story.
## Consequences
- `/reload` appears in the help line, autocomplete (marked EXPERIMENTAL (dev)), and the two help-rendering snapshots (re-recorded).
- The command reports tree count and completion as transcript notices; per-file failures surface only in loader logs, which the TUI does not display — acceptable for a dev-only surface, noted in the completion message.
- A re-entrancy guard serializes reloads: `/reload` while one is in flight is refused with a warning, keeping the loader's unmutexed tree-update pass single-writer; the guard releases on completion or failure.
- `/reload` runs only while the agent is idle: a reload can dispose and re-mount entries, which under an active turn could tear tools or the adapter out from under in-flight calls. The check is advisory (a send can race in after it) but removes the common footgun.
- If `refresh()`'s never-reject contract ever changes, the command reports the failure instead of leaving an unhandled rejection.
## Testing
`packages/ui/tui/tests/tui.spec.ts` pins: `/reload` refreshes every file-backed subtree and skips plain entries (structural fake Loader), reports completion, refuses re-entry while a gated refresh is in flight and runs again after release, releases the guard on the failure arm, refuses a running agent and runs again at idle, reports a rejecting refresh, and degrades to a warning without a Loader — including mounted as a real plugin fiber, where a throwing service lookup would escape. Verified live in tmux against the real tree: probe edit → reload applies; invalid edit → reload keeps the running tree.
@@ -0,0 +1,35 @@
# Agent Note: /reload 命令按需重读 loader 配置
Status: implemented
[English](2026-07-21-tui-reload-command.md) | 中文
## Problem
HMR 的文件监听器只对其配置根目录(示例中即配置叶子所在目录)下的就地 `change` 事件起反应。以重命名方式替换文件的编辑器(BSD `sed -i``git checkout`)不产生事件,而没有挂载 HMR 配置项(或没有 `--expose-internals`)的运行时则完全没有配置重载路径。开发时这意味着监听器漏掉一次配置编辑就得重启 TUI。曾考虑把监听根目录扩大到整个仓库,讨论后否决:包之间的密集共享使模块级 HMR 变成「重挂大半棵树」的操作,externals 边界也不可预测。
## Decision
`dsh-tui` 增加一个**实验性、仅供开发**的 `/reload` 斜杠命令:遍历 `ctx.loader.entries()`,对每个文件后端的子树(`Include`)调用 `refresh()`——即 HMR 监听器配置变更分支所走的同一条代码路径,改为手动触发、不依赖监听器。未变化的文件是无操作(`Include.read` 做内容比较);无效文件记录警告并保留运行中的树(热重载韧性契约);include 的 `patches`——包括 dsh CLI 的个人 overlay——在每次重读时重新应用。
TUI 以**结构方式**访问 Loader(通过局部类型访问 `ctx.loader`,而非 `inject`):测试和嵌入方在没有 Loader 的情况下运行 TUI,此时 `/reload` 退化为一条警告通知而不是挂载失败。模块源码热重载仍由监听器负责;`/reload` 只刷新配置。
## Alternatives considered
**把 HMR 监听根目录扩大到 `packages/`/`apps/`。** 暂缓否决:插件源码变更会重载每个依赖插件的 fiber,而仓库中密集共享的包(`dsh-session``dsh-llm``dsh-tools`)使其等同于会话中途拆掉主干和 UI——伪装成热重载的重启,还带部分重载的隐患。手动的、只覆盖配置范围的命令抓住了安全、可预测的那个子集。
**在 `inject` 中声明 `loader`。** 否决:那会让 Loader 成为 TUI 的硬依赖,为了一个开发便利破坏所有无 Loader 的组合(单元测试 harness、嵌入方)。
**在 dsh-tool-cordis 里做一个面向模型的 `cordis_reload` 工具。** 否决:这是终端前人类操作者的动作,不是模型应当触发的能力;cordis 工具集的 mount/unmount 表面已经覆盖模型的运行时修改需求。
## Consequences
- `/reload` 出现在帮助行、自动补全(标注 EXPERIMENTAL (dev))和两个渲染帮助的快照中(已重新录制)。
- 命令以 transcript 通知报告树数量与完成;单文件失败只出现在 loader 日志里,TUI 不显示——对仅供开发的表面可以接受,完成消息中已注明。
- 重入保护串行化重载:前一次进行中时 `/reload` 会被拒绝并提示警告,使 loader 无互斥的树更新过程保持单写者;保护在完成或失败时释放。
- `/reload` 只在 agent 空闲时运行:重载可能卸载并重新挂载配置项,在活跃轮次下这会把工具或适配器从进行中的调用脚下抽掉。检查是建议性的(检查后仍可能有 send 竞争进来),但消除了常见的坑。
-`refresh()` 的永不 reject 契约将来改变,命令会报告失败而不是留下未处理的 rejection。
## Testing
`packages/ui/tui/tests/tui.spec.ts` 固定:`/reload` 刷新每个文件后端子树并跳过普通配置项(结构化的假 Loader)、报告完成、在门控的刷新进行中拒绝重入并在释放后可再次运行、失败分支同样释放保护、拒绝运行中的 agent 并在空闲后可再次运行、报告 reject 的 refresh、无 Loader 时退化为警告——包括作为真实插件 fiber 挂载的情形,在那里会抛出的服务查找会泄露出去。已在 tmux 中对真实配置树实机验证:探针编辑 → reload 生效;无效编辑 → reload 保留运行中的树。