feat(host): adaptive directory-picker default via -auto chooser

Add @deepseek-ai/dsh-host-directory-picker-auto, a node-half-only chooser
that samples the host situation once at boot (bind host via a new
httpServer.host getter, SSH markers, platform, DISPLAY/WAYLAND_DISPLAY)
and mounts the matching dual-face backend (-native or -browse) as a real
Loader entry in the in-memory root tree; the effect disposer removes it.
Entry-level mounting keeps the seam's one-row-swaps-both-faces invariant:
the client module table discovers the mounted backend's browser half
exactly as a config row's. apps/cli now composes -auto as its
directory-picker row; composing a backend row directly remains the pin.
This commit is contained in:
creatixchu
2026-07-29 18:34:06 +08:00
parent 87c9ab06b3
commit 8cec74748b
32 changed files with 553 additions and 19 deletions
@@ -0,0 +1,29 @@
# Agent Note: Adaptive default for the directory-picker interaction
Status: implemented
English | [中文](2026-07-29-directory-picker-adaptive-default.zh.md)
## Problem
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 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` — 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 again. `native` requires every attended-host signal (loopback bind ∧ no SSH markers ∧ display session, assumed on darwin/win32); 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, or headless host → in-app browser. Detection is a heuristic (a detached tmux session loses `SSH_*`; a non-Aqua darwin process still counts as displayed) — a wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` pins the safe interaction.
- 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.