DSH / Atlas
2026-07-29implementedfeature

Adaptive default for the directory-picker interaction

目录选择交互的自适应默认值

The [directory-picker seam](../architecture/2026-07-28-directory-picker-capability-seam.md) made the interaction a `cordis.yml` swap point, but the shipped composition still had to pin one backend: `-browse` everywhere meant a local operator never got the OS chooser, `-native` everywhere breaks every remote deployment. The right default depends on facts only the running host knows — where the server binds, whether th

English

Problem

The directory-picker seam made the interaction a cordis.yml swap point, but the shipped composition still had to pin one backend: -browse everywhere meant a local operator never got the OS chooser, -native everywhere breaks every remote deployment. The right default depends on facts only the running host knows — where the server binds, whether the process was launched over SSH, whether a display session exists — so no static row is correct for all deployments.

Decision

A third sibling package, dsh-host-directory-picker-auto: a node-half-only chooser that owns no picking code and no UI. Its apply samples the host facts exactly once at boot — bind host from the injected httpServer (a new host getter mirrors the existing port), SSH_CONNECTION/SSH_TTY, platform, DISPLAY/WAYLAND_DISPLAY, and a PATH probe for a Linux chooser binary (zenity/kdialog) — resolves them through one exported pure function, and mounts the chosen dual-face backend with ctx.loader.create({name}) into the Loader's in-memory root tree; the effect's disposer removes the entry and joins the backend fiber's teardown (remove() alone only starts it), so unloading the chooser settles only after the backend quiesced. native requires every attended-and-servable signal: loopback bind ∧ no SSH markers ∧ a display session the native backend can drive — assumed on darwin/win32, requiring DISPLAY/WAYLAND_DISPLAY plus a chooser binary on linux, and never true elsewhere (the native backend supports exactly darwin/win32/linux). Anything ambiguous resolves to browse, which works everywhere. apps/cli now mounts -auto as its directory-picker row; composing -native or -browse directly remains the pin.

Why entry-level mounting is the load-bearing mechanism: the client module table (dsh-client-modules) reconciles Loader entries reactively over internal/plugin, so a backend mounted as a real entry gets its browser half discovered exactly as a config-row's would be — the seam's one-row-swaps-both-faces invariant survives adaptivity with zero duplicated client code. The dev HMR row (AppCLIEntry) is the mechanism precedent. Root-tree targeting matters: the root tree's write() is a no-op, so the resolved row can never be persisted back into cordis.yml (the Include subtree does write).

Alternatives considered

  • Boot-glue resolution in AppCLIEntry (ship both rows with static disabled, patch disabled from a --directory-picker=auto|native|browse flag). Works — PatchOptions patches metadata, and the modules scan skips disabled rows — but leaves the decision app-private where every future composition re-implements it; the chooser plugin gives any cordis.yml the same one-row adaptivity. Reintroduce the flag only when a deployment needs to force a backend without editing its yml.
  • One merged plugin branching per call (client tries pick, falls back to the browse dialog on directory-picker-unavailable). Rejected: the client would need both flows in one bundle — the bundle-purity gate forbids cross-plugin value imports and jscpd forbids copying the dialog — and per-call probing pays a doomed RPC on every open of a browse host.
  • Resurrecting the wire advertisement so both client flows mount and branch on the host's kind. Rejected: reverses the seam note's deletion for no consumer the chooser doesn't already serve, and collides with the single directory-flow holes.
  • Per-connection adaptivity (native for a loopback browser, browse for a remote one, same server). Deferred: needs a per-client capability, the advertisement above, and both flows mounted; no deployment serves both operator shapes at once today.

Consequences

  • The shipped web GUI adapts out of the box: attended local host → OS chooser; SSH launch, all-interfaces bind, headless host, unsupported platform, or Linux without a chooser binary → in-app browser. Detection infers operator location from launch context, which no launch-side signal can prove: a detached tmux session loses SSH_*; a non-Aqua darwin process still counts as displayed; and the ssh -L shape (a workstation-local launch later reached through a forwarded port, arriving from 127.0.0.1) resolves native and opens the chooser on the unattended workstation — per-connection adaptivity could not fix that last case either. A wrong native choice degrades to the backend's existing retryable failure dialog; deployments in these shapes compose -browse directly.
  • The chooser mounts backends by runtime string (BACKEND_PACKAGES, exported), which yml-row scanning cannot see; verify-cordis-config therefore requires every composition mounting -auto to declare both backends as dependencies, so keyless Linux CI (which only ever resolves browse) cannot hide a dropped -native dependency. The shipped-tree web e2e/snapshot lane (apps/web/tests/scaffold.ts) pins -browse by disable+insert patch — its goldens are interaction-specific and must not depend on the host running the suite.
  • One resolution per boot keeps the seam's capability-stability contract; per-connection shapes remain out of scope until a deployment demands them.
  • Mounting the chooser and a backend row together fails loud (duplicate directoryPicker service; duplicate flow in the single holes).
  • The host typecheck aggregate now references the two backend projects (declarations only, node entries carry no client merge) so the chooser's REAL-composition test can mount them — the mirror of the client aggregate's webserver reference.

中文

问题

目录选择 seam 把交互形态做成了 cordis.yml 的切换点,但随附的组合仍必须固定一个后端:处处用 -browse 意味着本地操作者永远得不到 OS 选择器,处处用 -native 则弄坏所有远程部署。正确的默认值取决于只有运行中的宿主才知道的事实——服务器绑定在哪里、进程是否经 SSH 启动、是否存在显示会话——因此没有哪一静态行对所有部署都正确。

决策

第三个同级包 dsh-host-directory-picker-auto:一个只有 node 半侧的选择器,不持有任何选取代码,也没有 UI。它的 apply 在启动时恰好采样一次宿主事实——从注入的 httpServer 读绑定宿主(新增的 host getter 与既有的 port 对称)、SSH_CONNECTIONSSH_TTY、平台、DISPLAYWAYLAND_DISPLAY、以及对 Linux 选择器二进制(zenity/kdialog)的一次 PATH 探查——经由一个导出的纯函数判定,再用 ctx.loader.create({name}) 把选中的双面后端挂进 Loader 的内存根树;该 effect 的 disposer 会移除该条目并汇入后端 fiber 的拆卸(单靠 remove() 只是启动拆卸),因此,只有后端完全停稳后,选择器的卸载才会完成。native 要求全部“有人值守且可服务”信号:回环绑定 ∧ 无 SSH 标记 ∧ native 后端能驱动的显示会话——darwin/win32 上视为存在,linux 上要求 DISPLAYWAYLAND_DISPLAY 外加一个选择器二进制,其余平台一律不成立(native 后端恰好支持 darwin/win32/linux)。任何含糊情形都判定为处处可用的 browseapps/cli 现在把 -auto 挂为它的 directory-picker 行;直接组合 -native-browse 仍是固定交互的方式。

条目级挂载之所以是承重机制:client 模块表(dsh-client-modules)基于 internal/pluginLoader 条目做响应式协调,因此以真实条目挂载的后端,其 browser half 被发现的方式与配置行完全相同——seam 的“一行同时换两面”不变式在自适应下依然成立,且没有一行重复的 client 代码。开发环境的 HMR 行(AppCLIEntry)是该机制的先例。瞄准根树很关键:根树的 write() 是 no-op,因此判定出的行绝不会被持久化回 cordis.yml(Include 子树写回)。

曾考虑的替代方案

  • AppCLIEntry 里做启动胶水判定(随附两行并带静态 disabled,由 --directory-picker=auto|native|browse 标志修补 disabled)。可行——PatchOptions 能修补元数据,模块扫描也会跳过禁用行——但把决策留成应用私有,此后每个组合都要重新实现;选择器插件让任何 cordis.yml 都获得同样的一行自适应。只有当某个部署需要不改自己的 yml 就强制指定后端时,才重新引入该标志。
  • 合并成一个按调用分支的插件(client 先试 pick,收到 directory-picker-unavailable 再回退到浏览对话框)。否决:client 得把两套流程装进同一个 bundle——bundle 纯净门禁禁止跨插件的值导入,jscpd 禁止复制对话框——而且按调用探测让 browse 宿主每次打开都付出一次注定失败的 RPC。
  • 复活 wire 广播,让两套 client 流程都挂载并按宿主的 kind 分支。否决:推翻 seam Agent Note 的那次删除,却服务不了任何选择器尚未服务的消费方,还与 single 目录流洞相冲突。
  • 按连接自适应(同一台服务器,回环浏览器用 native、远程浏览器用 browse)。延期:需要按客户端的能力对象、上述广播,以及同时挂载两套流程;今天没有部署同时服务两种操作者形态。

后果

  • 随附的 web GUI 开箱即自适应:有人值守的本地宿主 → OS 选择器;SSH 启动、全网卡绑定、无头宿主、不支持的平台,或没有选择器二进制的 Linux → 应用内浏览器。探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点:脱离的 tmux 会话会丢失 SSH_*;非 Aqua 的 darwin 进程仍被算作有显示;而 ssh -L 形态(在工作站本地启动、之后经转发端口访问,从 127.0.0.1 到达)会判定 native,把选择器弹在无人值守的工作站上——即便按连接自适应也修不了最后这一情形。错误的 native 选择会退化为后端既有的可重试失败对话框;处于这些形态的部署直接组合 -browse
  • 选择器按运行时字符串(已导出的 BACKEND_PACKAGES)挂载后端,yml 行扫描看不到这一点;因此 verify-cordis-config 要求每个挂载 -auto 的组合把两个后端都声明为依赖,使无密钥的 Linux CI(它永远只会判定出 browse)无法掩盖被丢掉的 -native 依赖。随附树的 web e2e/快照通道(apps/web/tests/scaffold.ts)以 disable+insert 补丁固定 -browse——其预期输出取决于具体交互,绝不能依赖运行该套件的宿主。
  • 每次启动只判定一次,维持 seam 的能力稳定性约定;按连接的形态在有部署提出需求前仍不在范围内。
  • 同时挂载选择器某个后端行会明确报错(重复的 directoryPicker 服务;single 洞中的重复流程)。
  • host 类型检查聚合现在引用两个后端项目(仅声明,node 入口不携带 client 合并),使选择器的 REAL-composition 测试能挂载它们——与 client 聚合对 webserver 的引用互为镜像。