# Conflicts: # .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml
7.8 KiB
Agent Note: A capability-discriminated directory-picker seam for the web-GUI host
Status: implemented
English | 中文
Problem
The web GUI's "Open local folder" flow was hardwired to one interaction: host.pickDirectory invoked a native OS chooser compiled into dsh-host-apiproxy (private module, test-only injection seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned in-app directory browser (Figma Harness 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance.
Decision
A three-package capability seam in packages/host/ — directory-picker (interface), directory-picker-native, directory-picker-browse (backends) — with one contract method: capability() returns a discriminated union, { kind: 'native', pick(signal) } or { kind: 'browse', list(path?), createDirectory(path, name) }. The gateway (dsh-host-apiproxy) injects directoryPicker, serves the matching RPCs, and answers directory-picker-unavailable for the other kind. The union is discriminated because the backends differ in interaction shape — flattening them into one method set would force every backend to fake the other's shape.
The client side is slot-composed, not advertisement-branched. ui-workspace's two trigger surfaces each declare a single directory-flow hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are dual-face: the browser half registers the matching interaction into both holes — -native a renderless occupant driving host.pickDirectory, -browse the in-app Select Workspace Directory dialog. The hole's owner conversation (open/busy/onPicked/onCancel/onError) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (createWorkspace({path}), conflict/error dialog, Choose again), the occupant owns everything between open and the picked path. One cordis.yml row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (single hole). The earlier host.describe.directoryPicker advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (ctx.slots.entries) replaces it as the per-menu-open occupancy read.
Placement and policy rulings folded into this decision:
- Not the
ctx.fsseam.packages/fs/is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swappingfs-sandboxfor the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free;packages/host/is its consumer-domain home. - Dependency survey (hand-roll vs adopt). Node's stdlib is the maintained cross-platform OS layer (
readdir(withFileTypes),homedir, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (node-file-manager,files-and-folders, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (drivelistnative addon,windows-drive-letters~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - Hidden entries: return-and-flag. The host stamps
hidden(POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows'FILE_ATTRIBUTE_HIDDENis not exposed by dirents — documented limitation until a native probe pays for itself. - Symlinks: follow for enterability.
statprobes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, andworkspace.createalready canonicalizes via realpath at adoption. - Listing levels are bounded, and streamed. One
listcall returns at mostmaxEntriesrows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams viaopendirinto a name-sorted window ofmaxEntries + 1candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wireDirectoryListingcarries a requiredtruncatedflag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), andlist(path, signal)threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - Whole-filesystem scope, no roots config.
workspace.createaccepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - The native backend stays. Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the
nativeinteraction through its own dialog API). Kind naming:dialogwas the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate;nativenames where the chooser runs.
Alternatives considered
- Extend
ctx.fswith browse methods. Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. - One uniform seam method set (
pick(): path). Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. - Direct stdlib calls inside apiproxy (no seam). Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
- Adopting a file-manager/drive-enumeration dependency. Rejected per the survey above; recorded here as the dependency policy requires.
Consequences
cordis.ymlchooses the interaction;apps/climounts-browse(the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together;-nativeremains the host-display alternative.- The wire gains
host.listDirectory/host.createDirectoryand four error codes; the connection fixture serves a deterministic browse tree and a deterministicpickDirectorypath for keyless assembled tests. - A future interaction (or an Electron provider of the
nativeinteraction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. ApiProxyDefaults.pickDirectory(test-only injection) is gone; tests provide a stubctx.directoryPickerlike any other service.