DSH / Atlas
2026-08-11implementedarchitecture

Trim the command-line seams to existing interfaces

把命令行接缝收窄到既有接口

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 w

English

Problem

The app-owned command line (note) shipped with three seams that were wider than their consumers needed: a vendored in-memory row-activation state machine (Entry.enableRuntime plus enableRow exported from dsh-cmdline, a command-line package owning a Loader concept) whose only purpose was the --dev conditional reload row, a vendored EntryConfigResolver protocol symbol whose only implementer was Include, and a launcher that still recognized the headless-runner row to pick SIGTERM exit codes, gate user-patch watching, and provide a headlessIo seam duplicating ctx.appExit.

Decision

Express all three with interfaces that already exist:

  • No conditional dev row. The reload chain stops being conditional: dsh-web-app mounts the client-hmr row unconditionally and --dev is deleted, along with the web runtime's mode config, the mode-forked prompt contract, and the DSH_WEB_MODE bash variable. Without a rebuild watcher (pnpm run dev:web) rewriting client bundles, the chain polls unchanged files and stays idle, so the always-on row costs one stat-poll interval and an SSE route. Entry.enableRuntime, its two state fields, and enableRow are deleted with nothing replacing them.
  • Tree-carrier config. Include declares the existing EntryGroup.key marker instead of implementing EntryConfigResolver; the Loader hook keeps every tree carrier's config literal. Include's own path loses !!js support — no configuration ever used it, and the pinning test now asserts the literal tree-carrier contract instead.
  • Launcher app-knowledge. The launcher recognizes no app row. SIGTERM is a supervisor's ordinary stop request and exits 0 on every surface (SIGINT stays 130); the launcher cannot know whether the app considered its work complete, and the previous 143 depended on naming the headless row. Every boot watches its user patch layers — a one-shot surface exits through bounded shutdown, which disposes the watchers before the loop drains. The headless runner exits through ctx.appExit like any other app; its output streams are a package-internal internals test seam, and ctx.headlessIo is deleted.

Alternatives considered

  • Keeping enableRuntime but moving enableRow out of dsh-cmdline: relocation fixes the package boundary but keeps the vendored state machine whose semantics (survives reapplication, rollback on failure) must be re-derived at every upstream sync.
  • entry.update({ disabled: null }): mutates the entry's serialized options, so the next include reapplication restores disabled: true and unmounts the row mid-session.
  • SIGTERM 143 for one-shot surfaces via an app-registered signal handler: the launcher's own handler races it for the exit code; winning that race needs a new launcher interface, which is the cost this change removes.
  • Keeping --dev with the row created at runtime: an interim state of this change; it still needed a mode fork in the prompt contract, a DSH_WEB_MODE variable, and creation-versus-user-row arbitration, all to avoid an idle poll whose cost is negligible.

Consequences

  • A deployment that supervises dsh --profile headless with SIGTERM now observes exit 0 instead of 143; the caller sent the signal and sees no answer on stdout.
  • The reload chain runs in every dsh web process; a deployment that must not expose /plugins/events disables the client-hmr row in its patch layer.
  • One-shot runs mount the config-watch rows they previously skipped, costing a few milliseconds of startup.
  • The vendored Loader/Include divergence shrinks by one protocol symbol and one state machine, and rescope-vendor:check passes again (the modification log's rescope entry is restored to the position its exact-edit anchor requires).

中文

问题

应用自有命令行(笔记)交付时带着三条比其消费者所需更宽的接缝:一台 vendored 的内存行激活状态机(Entry.enableRuntime,外加从 dsh-cmdline 导出的 enableRow —— 一个命令行包拥有了 Loader 概念),其唯一用途是 --dev 条件重载行、一个只有 Include 一个实现者的 vendored EntryConfigResolver 协议符号,以及仍然识别 headless-runner 行的启动器 —— 用它选择 SIGTERM 退出码、门控用户 patch 监视,并提供与 ctx.appExit 重复的 headlessIo 接缝。

决策

三者全部改用已经存在的接口表达:

  • 不再有条件 dev 行。 重载链不再是条件性的:dsh-web-app 无条件挂载 client-hmr 行,--dev 连同 web runtime 的 mode 配置、按模式分叉的提示词约定和 DSH_WEB_MODE bash 变量一并删除。没有重建 watcher(pnpm run dev:web)改写客户端 bundle 时,链路轮询到的文件从不变化、保持空闲,因此常开的行只花费一个 stat 轮询间隔和一条 SSE 路由。Entry.enableRuntime、它的两个状态字段和 enableRow 删除后无任何替代物。
  • 树载体配置。 Include 改为声明已有的 EntryGroup.key 标记,不再实现 EntryConfigResolver;Loader 钩子让每个树载体的配置保持字面值。Include 自己的 path 失去 !!js 支持 —— 从未有配置用过它,固定该行为的测试改为断言字面值树载体约定。
  • 启动器的应用知识。 启动器不再识别任何应用行。SIGTERM 是监督进程的普通停止请求,在所有 surface 上以 0 退出(SIGINT 仍为 130);启动器无从知道应用是否认为工作已完成,而之前的 143 依赖于点名 headless 行。每次启动都监视用户 patch 层 —— 一次性 surface 经由有界关闭退出,关闭会先 dispose 监视器再排空事件循环。headless runner 像任何应用一样经 ctx.appExit 退出;其输出流是包内 internals 测试接缝,ctx.headlessIo 删除。

考虑过的替代方案

  • 保留 enableRuntime 但把 enableRow 移出 dsh-cmdline:搬迁修正了包边界,却保留了 vendored 状态机,其语义(在重新应用后仍生效、失败时回滚)在每次上游同步时都要重新推导。
  • entry.update({ disabled: null }):改写条目的序列化选项,下一次 include 重新应用会恢复 disabled: true 并在会话中途卸载该行。
  • 通过应用注册的信号处理器为一次性 surface 保留 SIGTERM 143:启动器自己的处理器会与它竞争退出码;要赢得竞争需要新的启动器接口,而这正是本次变更要移除的成本。
  • 保留 --dev、改为运行时创建该行:本次变更的中间形态;它仍需要提示词约定里的模式分叉、DSH_WEB_MODE 变量,以及创建与用户自有行之间的仲裁,而这一切只为省下一个成本可忽略的空闲轮询。

后果

  • 用 SIGTERM 监督 dsh --profile headless 的部署现在观察到退出码 0 而非 143;信号是调用方自己发的,且 stdout 上没有答案。
  • 重载链在每个 dsh web 进程中运行;不得暴露 /plugins/events 的部署应在其 patch 层禁用 client-hmr 行。
  • 一次性运行会挂载之前跳过的配置监视行,启动多花几毫秒。
  • vendored Loader/Include 偏差减少一个协议符号和一台状态机,rescope-vendor:check 重新通过(修改日志的 rescope 条目回到其精确编辑锚点要求的位置)。