# Agent Note：web GUI 宿主的能力可辨识目录选择 seam

状态：已实现

[English](2026-07-28-directory-picker-capability-seam.md) | 中文

## 问题

web GUI 的"打开本地文件夹"流程被焊死在一种交互上：`host.pickDirectory` 调用编译进 `dsh-host-apiproxy` 的原生 OS 选择器（私有模块，仅测试注入缝）。这个形态服务不了远程部署——没有任何 OS 对话框能弹到另一台机器的浏览器里——而计划中的应用内目录浏览器（Figma `Harness` 802-56979）需要列举／创建原语，那是**另一种交互契约**，不是同一契约的另一种实现。想换交互只能改网关源码，违背仓库"一切皆插件"的立场。

## 决策

在 `packages/host/` 落一个三包能力 seam——`directory-picker`（接口）、`directory-picker-native`、`directory-picker-browse`（后端）——唯一契约方法 `capability()` 返回**可辨识联合**：`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关（`dsh-host-apiproxy`）注入 `directoryPicker`，提供对应的 RPC，另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识，是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。

**client 侧靠 slot 组合，而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞（`conversation.hero.workspace.directoryFlow`／`sidebar.workspaces.directoryFlow`；之所以是两个 key，是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同）。后端包是**双面包**：browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者，`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话（`open`/`busy`/`onPicked`/`onCancel`/`onError`）承载整个交换：ui-workspace 保留触发（菜单入口仅在洞被占用时渲染）与接纳（`createWorkspace({path})`、冲突／错误对话框、重新选择），占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程；错配在构造上不可能，同时挂两个流程包会在 client 加载期失败（`single` 洞）。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后，供客户端分支用的 wire 事实不再有任何消费者。洞注册表（`ctx.slots.entries`）取而代之，成为每次打开菜单的占用读取。

并入本决策的位置与策略裁决：

- **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型／会话的存储栈（policy 事件、sandbox 可换后端）。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实（home 锚定、隐藏约定）也不是存储原语。picker seam 保持无展示、无模型；`packages/host/` 是它消费方域的家。
- **依赖调研（手写 vs 引入）。** Node 标准库本身就是维护中的跨平台 OS 层（`readdir(withFileTypes)`、`homedir`、路径语义）；调研过的替代品都过不了依赖门槛——文件管理器包（`node-file-manager`、`files-and-folders`、Syncfusion 的 provider）是整套 HTTP 应用（契合度不过），盘符工具（原生插件 `drivelist`、约七年未更的 `windows-drive-letters`）健康度／比例失当。browse 后端是标准库上的薄适配。
- **隐藏条目：返回并打标。** 宿主标注 `hidden`（POSIX 点前缀约定）并返回全部条目；客户端过滤。展示策略留在客户端，计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制，直到原生探测值回其成本。
- **符号链接：为可进入性而跟随。** 用 `stat` 探测符号链接（断链／循环→跳过）；面包屑保留操作者导航的逻辑路径，`workspace.create` 在接纳时本就做 realpath 规范化。
- **列举层级有上限，且流式处理。** 单次 `list` 至多返回 `maxEntries` 行（配置项，默认 1000——GitHub 网页端目录列举的同一上限）。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口，内存保持 O(maxEntries)，可进入性探测只触及窗口内候选；线上 `DirectoryListing` 携带必填的 `truncated` 标志，让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝（超大层级不能为每个 dirent 付出一次全窗扫描），且 `list(path, signal)` 透传载体的请求信号，滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await（打开、每次读取、每次符号链接探测）都与信号赛跑，中止路径放弃而非等待 close（Node 会把 close 排在在飞读取之后），被放弃的 settlement 全部吞掉，清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存／响应性漏洞。
- **全盘可浏览，不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法，浏览根只会是 UX 范围而非边界；没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
- **native 后端保留。** 插件化正是目的：多方都能提供该 seam（Electron 壳可以经自己的对话框 API 提供 `native` 交互）。kind 命名：最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现（应用内弹窗），这个词起不到判别作用；`native` 命名的是选择器运行的位置。

## 曾考虑的替代方案

- **给 `ctx.fs` 增加浏览方法。** 否决：上述权限域耦合；且面向展示的列举契约（hidden 标志、面包屑、home 锚点）不属于存储 seam。
- **统一方法集的 seam（`pick(): path`）。** 否决：应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端，需要协议上的原语；而对话框实现不了原语。交互差异不可约，故用判别标签。
- **apiproxy 里直接调标准库（不建 seam）。** 否决：换装点仍是改网关源码，失去 fixture／测试后端，与促成这项工作的插件教义相悖。
- **引入文件管理器／盘符枚举依赖。** 按上文调研否决；依赖政策要求记录于此。

## 后果

- `cordis.yml` 决定交互形态；`apps/cli` 挂 `-browse`（随附默认——开箱即得可远程的选取），一行同时切换了后端与 UI；`-native` 仍是宿主屏幕方案。
- 协议新增 `host.listDirectory`／`host.createDirectory` 与四个错误码；connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。
- 未来的新交互（或提供 `native` 交互的 Electron 实现）只是一个双面后端包——无需网关手术，也不动 ui-workspace。
- `ApiProxyDefaults.pickDirectory`（仅测试注入）删除；测试像提供其他服务一样提供 stub `ctx.directoryPicker`。
