diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml
new file mode 100644
index 0000000000..ccef0728d9
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
+2026-07-28-directory-picker-capability-seam.md: 8d9d34e7aed4525b243380a4a90801fe59bfc213
+2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb
diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
new file mode 100644
index 0000000000..8d9d34e7ae
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
@@ -0,0 +1,36 @@
+# Agent Note: A capability-discriminated directory-picker seam for the web-GUI host
+
+Status: implemented
+
+English | [中文](2026-07-28-directory-picker-capability-seam.zh.md)
+
+## 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-dialog`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'dialog', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, advertises the kind through `host.describe.directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind; the client branches on the advertised kind and hides the affordance for unknown kinds (merge-extensible default). Composition (`cordis.yml`) is the swap point; 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.
+
+Placement and policy rulings folded into this decision:
+
+- **Not the `ctx.fs` seam.** `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 — swapping `fs-sandbox` for 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 (`drivelist` native 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_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself.
+- **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption.
+- **Whole-filesystem scope, no roots config.** `workspace.create` accepts 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 dialog backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide `dialog` natively). The backend names changed from mechanism (`native`/`local` — both run locally) to interaction (`-dialog`/`-browse`).
+
+## Alternatives considered
+
+- **Extend `ctx.fs` with 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 dialog 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.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior), and the in-app browser PR flips the default to `-browse` with the GUI branching on `describe`.
+- The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests.
+- A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery.
+- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service.
diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md
new file mode 100644
index 0000000000..282f3905c3
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md
@@ -0,0 +1,36 @@
+# 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-dialog`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'dialog', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,经 `host.describe.directoryPicker` 广播 kind,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答;客户端按广播的 kind 分支,未知 kind 隐藏入口(可合并扩展的默认分支)。组合(`cordis.yml`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。
+
+并入本决策的位置与策略裁决:
+
+- **不用 `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 规范化。
+- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
+- **dialog 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以原生提供 `dialog`)。后端命名从机制(`native`/`local`——两者都在本机运行)改为交互(`-dialog`/`-browse`)。
+
+## 曾考虑的替代方案
+
+- **给 `ctx.fs` 增加浏览方法。** 否决:上述权限域耦合;且面向展示的列举契约(hidden 标志、面包屑、home 锚点)不属于存储 seam。
+- **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。
+- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。
+- **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。
+
+## 后果
+
+- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变),应用内浏览器 PR 将把默认翻到 `-browse` 并让 GUI 按 `describe` 分支。
+- 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。
+- 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。
+- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。
diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml
index 5397c08746..1ca28ecdaa 100644
--- a/apps/cli/cordis.yml
+++ b/apps/cli/cordis.yml
@@ -233,6 +233,11 @@
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
+# Directory-picking backend consumed by the gateway's host.* picker RPCs.
+# Swap point: mount '-browse' instead for the in-app browser (remote-capable).
+- id: directory-picker
+ name: '@deepseek-ai/dsh-host-directory-picker-dialog'
+
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
diff --git a/apps/cli/package.json b/apps/cli/package.json
index cd3edd2dd1..90b51e0397 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -48,13 +48,13 @@
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+ "@deepseek-ai/dsh-host-directory-picker-dialog": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
- "@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
@@ -69,6 +69,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
+ "@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks-local": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 9093f41f15..5cedd97dba 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -136,6 +136,10 @@ flowchart LR
svc_spillStore["ctx.spillStore
Spill storage seam"]
pkg_spill_local["spill-local"]
pkg_spill_policy["spill-policy"]
+ pkg_directory_picker["directory-picker"]
+ svc_directoryPicker["ctx.directoryPicker
Workspace-directory picking seam"]
+ pkg_directory_picker_dialog["directory-picker-dialog"]
+ pkg_directory_picker_browse["directory-picker-browse"]
pkg_webserver["webserver"]
svc_httpServer["ctx.httpServer
HTTP route registration"]
pkg_connection["connection"]
@@ -159,6 +163,9 @@ flowchart LR
pkg_compact --> svc_compact
pkg_compact_basic --> svc_compact
pkg_compact_tool_result_prune --> svc_toolResultPrune
+ pkg_directory_picker --> svc_directoryPicker
+ pkg_directory_picker_browse --> svc_directoryPicker
+ pkg_directory_picker_dialog --> svc_directoryPicker
pkg_fs --> svc_fs
pkg_fs_local --> svc_fs
pkg_fs_sandbox --> svc_fs
@@ -235,6 +242,7 @@ flowchart LR
svc_codeRuntime --> pkg_tools
svc_commands --> pkg_tui
svc_compact --> pkg_compact_basic
+ svc_directoryPicker --> pkg_apiproxy
svc_fs --> pkg_tool_fs
svc_httpServer --> pkg_connection
svc_httpServer --> pkg_hmr
@@ -348,6 +356,7 @@ flowchart LR
| `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
+| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-dialog`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the dialog backend opens one native OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe. |
| `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. |
| `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. |
| `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index d30aff1f02..7d10c920c6 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -505,7 +505,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-c
## `@deepseek-ai/dsh-host-apiproxy`
-Requires: `agents` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace`
+Requires: `agents` · `directoryPicker` · `llm` · `sessions` · `tools` · `userInteraction` · `workspace`
```ts config-catalog
/** Gateway plugin config: host-level agent routing and Workspace creation root. */
@@ -2184,6 +2184,8 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts))
+- `@deepseek-ai/dsh-host-directory-picker-browse` ([`packages/host/directory-picker-browse/src/index.ts`](../packages/host/directory-picker-browse/src/index.ts))
+- `@deepseek-ai/dsh-host-directory-picker-dialog` ([`packages/host/directory-picker-dialog/src/index.ts`](../packages/host/directory-picker-dialog/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
- `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts))
- `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts))
@@ -2230,6 +2232,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts))
- `@deepseek-ai/dsh-helper` ([`packages/sdk/helper/src/index.ts`](../packages/sdk/helper/src/index.ts))
- `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts))
+- `@deepseek-ai/dsh-host-directory-picker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts))
- `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts))
- `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts))
- `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts))
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 75464938b0..eb212207b3 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -469,6 +469,20 @@ Types: [CompactionResult](../core-data-structures/compaction.md) · [CompactionT
Source: [`packages/compact/compact/src/index.ts:54`](../../packages/compact/compact/src/index.ts)
+## `ctx.directoryPicker` — `DirectoryPicker` (abstract seam)
+
+Abstract directory-picking service. Subclass, implement `capability()`, and load the subclass as a plugin — it registers as `ctx.directoryPicker` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior). The capability object must be stable for the service lifetime: consumers may capture it across calls.
+
+```ts cordis-catalog
+/**
+ * The backend's interaction capability.
+ * @returns the discriminated capability consumers switch on.
+ */
+abstract capability(): DirectoryPickerCapability
+```
+
+Source: [`packages/host/directory-picker/src/index.ts:108`](../../packages/host/directory-picker/src/index.ts)
+
## `ctx.fs` — `FileSystem` (abstract seam)
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
diff --git a/docs/module-graph.md b/docs/module-graph.md
index f69b89e379..03f4bee072 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -182,6 +182,9 @@ flowchart TD
end
subgraph group_host["packages/host"]
pkg_host_apiproxy["host-apiproxy"]
+ pkg_host_directory_picker["host-directory-picker"]
+ pkg_host_directory_picker_browse["host-directory-picker-browse"]
+ pkg_host_directory_picker_dialog["host-directory-picker-dialog"]
pkg_host_webserver["host-webserver"]
end
subgraph group_lsp["packages/lsp"]
@@ -257,6 +260,9 @@ flowchart TD
pkg_code_runtime --> pkg_invariants
pkg_jsonrpc_demo --> pkg_invariants
pkg_host_apiproxy --> pkg_invariants
+ pkg_host_directory_picker --> pkg_invariants
+ pkg_host_directory_picker_browse --> pkg_invariants
+ pkg_host_directory_picker_dialog --> pkg_invariants
pkg_host_webserver --> pkg_invariants
pkg_storage --> pkg_invariants
pkg_subprocess --> pkg_invariants
@@ -924,6 +930,9 @@ flowchart TD
| [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/support/invariants) |
| [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
+| [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) |
+| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/support/invariants) |
+| [`host-directory-picker-dialog`](../packages/host/directory-picker-dialog) | `host` | [`invariants`](../packages/support/invariants) |
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts
index a1380c4b58..b68a7c1565 100644
--- a/packages/client/connection/src/client/api.ts
+++ b/packages/client/connection/src/client/api.ts
@@ -8,6 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
+ DirectoryEntry, DirectoryListing, DirectoryPickerKind,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 5c7befd8c9..a490f0d27d 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -419,6 +419,37 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
+
+ // In-memory browse tree behind the fixture's `browse` picker capability —
+ // deterministic content mirroring the design mock so assembled Web tests
+ // and snapshots can walk it. Leaves are materialized lazily: a child listed
+ // by its parent lists as empty until something is created inside it.
+ const FIXTURE_HOME = '/home/fixture'
+ const directoryTree = new Map([
+ ['/', ['home']],
+ ['/home', ['fixture']],
+ [FIXTURE_HOME, ['Documents', 'Downloads', '.config']],
+ [`${FIXTURE_HOME}/Documents`, [
+ 'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform',
+ 'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog',
+ ]],
+ ])
+ const childrenOf = (path: string): string[] | undefined => {
+ const known = directoryTree.get(path)
+ if (known !== undefined) return known
+ const parent = path.slice(0, path.lastIndexOf('/')) || '/'
+ const name = path.slice(path.lastIndexOf('/') + 1)
+ return directoryTree.get(parent)?.includes(name) === true ? [] : undefined
+ }
+ const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => {
+ const crumbs = [{ name: '/', path: '/', hidden: false }]
+ let acc = ''
+ for (const segment of path.split('/').filter(Boolean)) {
+ acc += `/${segment}`
+ crumbs.push({ name: segment, path: acc, hidden: false })
+ }
+ return crumbs
+ }
const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
@@ -774,8 +805,40 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
},
host: {
- describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
- pickDirectory: request => ok(request, { path: null }),
+ describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, directoryPicker: 'browse' as const }),
+ pickDirectory: request => err(request, {
+ code: 'directory-picker-unavailable',
+ message: 'the fixture host serves the browse capability',
+ details: { capability: 'browse' },
+ }),
+ listDirectory: (request) => {
+ const target = request.payload.path ?? FIXTURE_HOME
+ const children = childrenOf(target)
+ if (children === undefined) {
+ return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } })
+ }
+ return ok(request, {
+ path: target,
+ home: FIXTURE_HOME,
+ crumbs: crumbsOf(target),
+ entries: [...children].sort((a, b) => a.localeCompare(b))
+ .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
+ })
+ },
+ createDirectory: (request) => {
+ const parent = request.payload.path
+ const children = childrenOf(parent)
+ if (children === undefined) {
+ return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
+ }
+ const target = `${parent}/${request.payload.name}`
+ if (children.includes(request.payload.name)) {
+ return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
+ }
+ directoryTree.set(parent, [...children, request.payload.name])
+ directoryTree.set(target, [])
+ return ok(request, { path: target })
+ },
},
workspace: {
list: request => ok(request, { items: workspaces.map(w => ({ ...w })) }),
@@ -1027,6 +1090,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
+ case 'host.listDirectory': return this.api.host.listDirectory(request)
+ case 'host.createDirectory': return this.api.host.createDirectory(request)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts
index 0e50d8617f..8e29b0b02e 100644
--- a/packages/client/connection/src/client/index.ts
+++ b/packages/client/connection/src/client/index.ts
@@ -13,6 +13,7 @@ import { WebApiClient } from './web-api-client.ts'
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
+ DirectoryEntry, DirectoryListing, DirectoryPickerKind,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, CommandExecuteResult, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts
index 4de4a31f25..bce6fce2ce 100644
--- a/packages/client/connection/tests/connection.spec.ts
+++ b/packages/client/connection/tests/connection.spec.ts
@@ -75,7 +75,7 @@ describe('connection lifecycle', () => {
try {
await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff
expect(connected).toBe(0) // never announced during the failed generation
- gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
+ gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
await vi.waitFor(() => { expect(connected).toBe(1) })
} finally {
controller.stop()
@@ -199,7 +199,7 @@ describe('connection lifecycle', () => {
controller.start()
try {
await vi.waitFor(() => { expect(describeCalls).toBe(3) })
- gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 }))
+ gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
await vi.waitFor(() => { expect(connected).toBe(1) })
expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission
} finally {
diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts
index 1bb49b19fb..06e62cf7a4 100644
--- a/packages/client/connection/tests/fake-api.ts
+++ b/packages/client/connection/tests/fake-api.ts
@@ -62,11 +62,22 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const }))
- onDescribe: (payload: unknown) => Promise> =
- () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
+ onDescribe: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
onPickDirectory: (payload: unknown) => Promise> =
() => Promise.resolve(ok({ path: null }))
+ onListDirectory: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }))
+
+ onCreateDirectory: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ path: '/home/fake/new' }))
+
private readonly muxConns: StreamConn[] = []
private readonly hostConns: StreamConn[] = []
@@ -88,6 +99,8 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
+ listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
+ createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
}
readonly workspace: IApiClient['workspace'] = {
diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts
index ec39ce9e1c..8e693c3514 100644
--- a/packages/client/runtime/src/client/index.ts
+++ b/packages/client/runtime/src/client/index.ts
@@ -13,7 +13,7 @@ export type { RootOwnerProps } from './slots.ts'
export { SessionCreateError, SessionsService, scopeOf, workspaceTitleOf } from './sessions/service.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
-export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
+export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
@@ -21,7 +21,9 @@ export type {
export type { SessionListPhase } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
-export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
+export type {
+ DirectoryEntry, DirectoryListing, DirectoryPickerKind, WorkspaceId, WorkspaceView,
+} from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {
diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts
index fe345801c6..14ecace381 100644
--- a/packages/client/runtime/src/client/workspaces/service.ts
+++ b/packages/client/runtime/src/client/workspaces/service.ts
@@ -2,7 +2,8 @@
import type { Context } from 'cordis'
import type {
- IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
+ DirectoryListing, DirectoryPickerKind, IApiClient, RpcError,
+ SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
@@ -29,6 +30,14 @@ export class WorkspaceCreateError extends Error {
}
}
+/** Structured browse failure so the directory browser can branch on Host business codes. */
+export class DirectoryBrowseError extends Error {
+ constructor(readonly rpcError: RpcError) {
+ super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
+ this.name = 'DirectoryBrowseError'
+ }
+}
+
/** Real Workspace object layer and Host actions. */
export class WorkspacesService {
/** UI-facing immutable projection; the manager remains wire truth. */
@@ -171,7 +180,7 @@ export class WorkspacesService {
}
/**
- * Open the Host's native directory picker.
+ * Open the Host's native directory picker (the `dialog` capability).
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise {
@@ -182,6 +191,44 @@ export class WorkspacesService {
return response.result.value.path
}
+ /**
+ * The directory-picking interaction the Host composed — the fact the picker
+ * UI branches on (`dialog` opens the native chooser; `browse` opens the
+ * in-app browser). Read per flow open: one describe round trip, no cache to
+ * go stale across reconnects.
+ * @returns the Host's advertised picker kind.
+ */
+ async directoryPickerKind(): Promise {
+ const response = await this.api.host.describe({})
+ if (!response.result.ok) {
+ throw new Error(`host describe failed: ${response.result.error.message}`)
+ }
+ return response.result.value.directoryPicker
+ }
+
+ /**
+ * List one directory level through the Host's `browse` capability.
+ * @param path - absolute directory to list; absent lists the Host home directory.
+ * @returns the level's listing with breadcrumb ancestry.
+ */
+ async listDirectory(path?: string): Promise {
+ const response = await this.api.host.listDirectory(path === undefined ? {} : { path })
+ if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
+ return response.result.value
+ }
+
+ /**
+ * Create one child directory through the Host's `browse` capability.
+ * @param path - absolute existing parent directory.
+ * @param name - single non-blank path segment.
+ * @returns the created directory's absolute path.
+ */
+ async createDirectory(path: string, name: string): Promise {
+ const response = await this.api.host.createDirectory({ path, name })
+ if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
+ return response.result.value.path
+ }
+
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts
index a5eecd0cf5..98a222d9ae 100644
--- a/packages/client/runtime/tests/fake-api.ts
+++ b/packages/client/runtime/tests/fake-api.ts
@@ -80,11 +80,22 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const }))
- onDescribe: (payload: unknown) => Promise> =
- () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
+ onDescribe: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
onPickDirectory: (payload: unknown) => Promise> =
() => Promise.resolve(ok({ path: null }))
+ onListDirectory: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }))
+
+ onCreateDirectory: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ path: '/home/fake/new' }))
+
private readonly muxConns: StreamConn[] = []
private readonly hostConns: StreamConn[] = []
@@ -106,6 +117,8 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
+ listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
+ createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
}
onWorkspaceList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] }))
diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts
index 5327066651..7fda11f54f 100644
--- a/packages/client/runtime/tests/workspaces-service.spec.ts
+++ b/packages/client/runtime/tests/workspaces-service.spec.ts
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
-import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
+import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -234,6 +234,38 @@ describe('WorkspacesService', () => {
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(workspaces.pickDirectory()).resolves.toBeNull()
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
+ api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
+ await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
+ })
+
+ it('reads the picker kind from describe per call, failing loud on an unreachable host', async () => {
+ const ctx = new Context()
+ const api = new FakeApiClient()
+ const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
+ await expect(workspaces.directoryPickerKind()).resolves.toBe('browse')
+ api.onDescribe = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} }))
+ await expect(workspaces.directoryPickerKind()).rejects.toThrow(/host describe failed/)
+ })
+
+ it('passes listings and creation through the browse wire, wrapping business failures', async () => {
+ const ctx = new Context()
+ const api = new FakeApiClient()
+ const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
+ const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }] }
+ api.onListDirectory = () => Promise.resolve(ok(listing))
+ await expect(workspaces.listDirectory()).resolves.toEqual(listing)
+ await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
+ // The optional path is omitted from the payload, not sent as undefined.
+ expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
+ api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
+ const listFailure = workspaces.listDirectory('/x')
+ await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
+ await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
+
+ await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
+ expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
+ api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
+ await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 084652ea26..04b2e60ef0 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -260,6 +260,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'directoryPicker',
+ summary: 'Abstract directory-picking service.',
+ methods: [
+ {
+ signature: 'abstract capability(): DirectoryPickerCapability',
+ jsDoc: '/**\n * The backend\'s interaction capability.\n * @returns the discriminated capability consumers switch on.\n */',
+ },
+ ],
+ },
{
key: 'fs',
summary: 'Abstract filesystem provider.',
@@ -1581,6 +1591,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DiffResultView',
declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}',
},
+ {
+ name: 'DirectoryEntry',
+ declaration: 'export interface DirectoryEntry {\n name: string;\n path: string;\n hidden: boolean;\n}',
+ },
+ {
+ name: 'DirectoryListing',
+ declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n}',
+ },
+ {
+ name: 'DirectoryPickerBrowseCapability',
+ declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}',
+ },
+ {
+ name: 'DirectoryPickerCapability',
+ declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;',
+ },
+ {
+ name: 'DirectoryPickerDialogCapability',
+ declaration: 'export interface DirectoryPickerDialogCapability {\n kind: \'dialog\';\n pick(signal: AbortSignal): Promise;\n}',
+ },
{
name: 'Domain',
declaration: 'export interface Domain {\n readonly name: string;\n readonly global: DomainGlobalHandleOf;\n table(name: N): KvTable, TableValueOf>;\n close(): Promise;\n}',
diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml
index b406dd394e..9421dce1c5 100644
--- a/packages/host/README.i18n.yaml
+++ b/packages/host/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/README.md
-README.md: 61e50cb64b95932085342b01a22d029cf8d5a228
-README.zh.md: 3109eccf89ee4c2d4d01be546e3ee9ead9084edc
+README.md: 81c483674c0d30847318b8fd9014bd8bb7d341c2
+README.zh.md: 8b5ecd89ff4b1407cfa8c2bad63c77412b5fd16c
diff --git a/packages/host/README.md b/packages/host/README.md
index 61e50cb64b..81c483674c 100644
--- a/packages/host/README.md
+++ b/packages/host/README.md
@@ -8,5 +8,8 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
|---|---|---|
| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` |
| `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` |
+| `directory-picker/` | Workspace-directory picking seam: discriminated `dialog`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` |
+| `directory-picker-dialog/` | Native-OS-chooser backend (osascript / PowerShell / Zenity+KDialog); host-display only | (registers `ctx.directoryPicker`) |
+| `directory-picker-browse/` | In-app browsing backend: listing/creation primitives over Node stdlib; remote-capable | (registers `ctx.directoryPicker`) |
`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.
diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md
index 3109eccf89..8b5ecd89ff 100644
--- a/packages/host/README.zh.md
+++ b/packages/host/README.zh.md
@@ -8,5 +8,8 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承
|---|---|---|
| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` |
| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` |
+| `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `dialog`/`browse` 能力 | `ctx.directoryPicker` |
+| `directory-picker-dialog/` | 原生 OS 选择器后端(osascript/PowerShell/Zenity+KDialog);仅宿主屏幕可用 | (注册 `ctx.directoryPicker`) |
+| `directory-picker-browse/` | 应用内浏览后端:基于 Node 标准库的列举/创建原语;支持远程 | (注册 `ctx.directoryPicker`) |
`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。
diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml
index bfda016b16..55fe86c794 100644
--- a/packages/host/apiproxy/README.i18n.yaml
+++ b/packages/host/apiproxy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: 2f51aa23e2639e2e98dfdd8aaf71e807d641c3dc
-README.zh.md: 687e60879702a762d9e85295789b77daea4bd4ac
+README.md: 7c53e6dc9ac5758fce91d8b39abbfab6641384d1
+README.zh.md: 5089935fe545bfc6ac3ddb39b9a2a25b50e1ceab
diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md
index 2f51aa23e2..7c53e6dc9a 100644
--- a/packages/host/apiproxy/README.md
+++ b/packages/host/apiproxy/README.md
@@ -16,7 +16,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
-`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers this method like every other `/api` request.
+Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); `host.describe.directoryPicker` advertises the capability kind the client renders for, and a method called outside the advertised kind fails with `directory-picker-unavailable`. Under `dialog`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state.
@@ -39,4 +39,4 @@ None; this package neither assembles nor sends a provider request.
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
-- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser.
+- **Linux native picker requires desktop tooling** — under the `dialog` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [dialog backend README](../directory-picker-dialog/README.md)).
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md
index 687e608797..5089935fe5 100644
--- a/packages/host/apiproxy/README.zh.md
+++ b/packages/host/apiproxy/README.zh.md
@@ -16,7 +16,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
-`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity,并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用;调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖该方法。
+目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));`host.describe.directoryPicker` 广播客户端应按其渲染的能力 kind,调用广播之外的方法会以 `directory-picker-unavailable` 失败。在 `dialog` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。
@@ -39,4 +39,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
- **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
-- **Linux 原生选择器依赖桌面工具**:Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径。
+- **Linux 原生选择器依赖桌面工具**:在 `dialog` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [dialog 后端 README](../directory-picker-dialog/README.md))。
diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json
index 0c7107a1f9..676f3ac319 100644
--- a/packages/host/apiproxy/package.json
+++ b/packages/host/apiproxy/package.json
@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
+ "@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts
index 58922284be..6e302e3e57 100644
--- a/packages/host/apiproxy/src/api-proxy.ts
+++ b/packages/host/apiproxy/src/api-proxy.ts
@@ -39,7 +39,7 @@ import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
-import { pickNativeDirectory } from './native-directory-picker.ts'
+import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -193,6 +193,14 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
}
}
+/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
+function directoryError(error: unknown): RpcError {
+ if (error instanceof DirectoryPickerError) {
+ return { code: error.code, message: error.message, details: { path: error.path } }
+ }
+ return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
+}
+
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
export interface ApiProxyDefaults {
provider: string
@@ -201,8 +209,6 @@ export interface ApiProxyDefaults {
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
- /** Native single-directory picker; injectable for carrier tests. */
- pickDirectory?: (signal: AbortSignal) => Promise
}
/** The tool/call payload fields the presenter path reads. */
@@ -990,12 +996,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
provider: defaults.provider,
model: defaults.model,
attachedSessions: ctx.agents.list().length,
+ directoryPicker: ctx.directoryPicker.capability().kind,
}))
},
async pickDirectory(request, signal) {
+ const capability = ctx.directoryPicker.capability()
+ if (capability.kind !== 'dialog') {
+ return err(request, {
+ code: 'directory-picker-unavailable',
+ message: `host.pickDirectory needs the dialog capability; the composed picker serves "${capability.kind}"`,
+ details: { capability: capability.kind },
+ })
+ }
try {
- const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal)
+ const path = await capability.pick(signal)
return ok(request, { path })
} catch (error: unknown) {
if (signal.aborted) {
@@ -1012,6 +1027,38 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
},
+
+ async listDirectory(request) {
+ const capability = ctx.directoryPicker.capability()
+ if (capability.kind !== 'browse') {
+ return err(request, {
+ code: 'directory-picker-unavailable',
+ message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
+ details: { capability: capability.kind },
+ })
+ }
+ try {
+ return ok(request, await capability.list(request.payload.path))
+ } catch (error: unknown) {
+ return err(request, directoryError(error))
+ }
+ },
+
+ async createDirectory(request) {
+ const capability = ctx.directoryPicker.capability()
+ if (capability.kind !== 'browse') {
+ return err(request, {
+ code: 'directory-picker-unavailable',
+ message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
+ details: { capability: capability.kind },
+ })
+ }
+ try {
+ return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) })
+ } catch (error: unknown) {
+ return err(request, directoryError(error))
+ }
+ },
},
commands: {
diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts
index c7353c4360..a73b659970 100644
--- a/packages/host/apiproxy/src/api/host.schema.ts
+++ b/packages/host/apiproxy/src/api/host.schema.ts
@@ -3,6 +3,7 @@
*/
import { z } from 'zod'
+import type { DirectoryEntry } from './host.ts'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
@@ -16,6 +17,7 @@ export const hostDescribeValueSchema = z.object({
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
+ directoryPicker: z.union([z.literal('dialog'), z.literal('browse')]),
}) satisfies z.ZodType>>
/** host.pickDirectory request payload (empty object literal). */
@@ -25,3 +27,38 @@ export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType>>
+
+/** Directory row shared by listing entries and breadcrumb crumbs. */
+export const directoryEntrySchema = z.object({
+ name: z.string(),
+ path: z.string(),
+ hidden: z.boolean(),
+}) satisfies z.ZodType>
+
+/** host.listDirectory request payload; an absent path lists the home directory. */
+export const hostListDirectoryRequestSchema = z.object({
+ path: z.string().optional(),
+}) satisfies z.ZodType>>
+
+/** host.listDirectory response value. */
+export const hostListDirectoryValueSchema = z.object({
+ path: z.string(),
+ home: z.string(),
+ crumbs: z.array(directoryEntrySchema),
+ entries: z.array(directoryEntrySchema),
+}) satisfies z.ZodType>>
+
+/** host.createDirectory request payload: name must be one plain path segment. */
+export const hostCreateDirectoryRequestSchema = z.object({
+ path: z.string(),
+ name: z.string(),
+}).refine(
+ payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..'
+ && !/[/\\]/.test(payload.name),
+ { message: 'host.createDirectory requires a single non-blank path segment name' },
+) satisfies z.ZodType>>
+
+/** host.createDirectory response value: the created directory's absolute path. */
+export const hostCreateDirectoryValueSchema = z.object({
+ path: z.string(),
+}) satisfies z.ZodType>>
diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts
index e3573346f4..abdb3c468b 100644
--- a/packages/host/apiproxy/src/api/host.ts
+++ b/packages/host/apiproxy/src/api/host.ts
@@ -5,6 +5,40 @@
import type { RpcRequest, RpcResponse } from './rpc.ts'
+/**
+ * The composed directory-picker interaction the host serves (mirror of the
+ * `ctx.directoryPicker` capability kind): `dialog` = one native OS chooser on
+ * the host display (`host.pickDirectory`); `browse` = in-app listing/creation
+ * primitives (`host.listDirectory`/`host.createDirectory`). Calling a method
+ * outside the advertised kind fails with `directory-picker-unavailable`.
+ */
+export type DirectoryPickerKind = 'dialog' | 'browse'
+
+/** One directory row of a listing: a child entry or a breadcrumb ancestor. */
+export interface DirectoryEntry {
+ /** Base name shown in a browser row (a root crumb carries its full path). */
+ name: string
+ /** Absolute host path — the client never joins path segments itself. */
+ path: string
+ /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */
+ hidden: boolean
+}
+
+/** host.listDirectory response value: one directory level plus its ancestry. */
+export interface DirectoryListing {
+ /** Absolute path of the listed directory. */
+ path: string
+ /** The host account's home directory (breadcrumb "Home" rooting). */
+ home: string
+ /**
+ * Ancestor chain from the filesystem root to the listed directory
+ * inclusive; every crumb is a jump target (crumb `hidden` is always false).
+ */
+ crumbs: DirectoryEntry[]
+ /** Direct child directories, name-sorted; symlinks to directories included. */
+ entries: DirectoryEntry[]
+}
+
/** Host-level unary methods. */
export interface HostApi {
/**
@@ -13,7 +47,8 @@ export interface HostApi {
* directory (root for session persistence and tool execution); provider/model = the defaults
* applied when a new agent doesn't specify them explicitly, absent when the host configures
* no explicit default (the adapter falls back internally);
- * attachedSessions = count of currently attached sessions (those with a live agent).
+ * attachedSessions = count of currently attached sessions (those with a live agent);
+ * directoryPicker = the composed picker interaction the client renders for.
*/
describe(request: RpcRequest<{}>): Promise>
- /** Open the operating system's single-directory picker; cancellation returns null. */
+ /**
+ * Open the operating system's single-directory picker; cancellation returns
+ * null. Only served under the `dialog` capability.
+ */
pickDirectory(
request: RpcRequest<{}>,
signal: AbortSignal,
): Promise>
+
+ /**
+ * List one directory level for the in-app browser; an absent path lists the
+ * host account's home directory. Only served under the `browse` capability;
+ * unreadable or missing targets fail with `directory-unreadable`.
+ */
+ listDirectory(
+ request: RpcRequest<{ path?: string }>,
+ ): Promise>
+
+ /**
+ * Create one child directory under an existing parent (the browser's
+ * "New folder"). Only served under the `browse` capability; an existing
+ * child fails with `directory-exists`, every other filesystem failure with
+ * `directory-create-failed`.
+ */
+ createDirectory(
+ request: RpcRequest<{ path: string; name: string }>,
+ ): Promise>
}
diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts
index ad5fbd3bf0..84cd8f8252 100644
--- a/packages/host/apiproxy/src/api/index.ts
+++ b/packages/host/apiproxy/src/api/index.ts
@@ -29,7 +29,7 @@ export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionsApi, SessionSummary,
} from './sessions.ts'
-export type { HostApi } from './host.ts'
+export type { DirectoryEntry, DirectoryListing, DirectoryPickerKind, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts
index d98a7c01b3..668ecfc5c7 100644
--- a/packages/host/apiproxy/src/api/rpc-map.ts
+++ b/packages/host/apiproxy/src/api/rpc-map.ts
@@ -26,6 +26,8 @@ export interface RpcMethodMap {
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
+ 'host.listDirectory': HostApi['listDirectory']
+ 'host.createDirectory': HostApi['createDirectory']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts
index 300cc210b6..b3632798fb 100644
--- a/packages/host/apiproxy/src/api/rpc.schema.ts
+++ b/packages/host/apiproxy/src/api/rpc.schema.ts
@@ -42,6 +42,10 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code',
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
+ z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }),
+ z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
+ z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
+ z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType
diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts
index 52b2f503dd..c1eb4c06f0 100644
--- a/packages/host/apiproxy/src/api/rpc.ts
+++ b/packages/host/apiproxy/src/api/rpc.ts
@@ -39,6 +39,10 @@ export interface RpcErrorDetailsMap {
'workspace-invalid-path': { path: string }
'workspace-name-conflict': { name: string }
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
+ 'directory-unreadable': { path: string }
+ 'directory-exists': { path: string }
+ 'directory-create-failed': { path: string }
+ 'directory-picker-unavailable': { capability: string }
'agent-busy': { reason: string }
'internal': {}
}
diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts
index 62bef32b29..912d98c477 100644
--- a/packages/host/apiproxy/src/fetch/client.ts
+++ b/packages/host/apiproxy/src/fetch/client.ts
@@ -13,7 +13,10 @@ import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
-import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts'
+import {
+ hostCreateDirectoryValueSchema, hostDescribeValueSchema,
+ hostListDirectoryValueSchema, hostPickDirectoryValueSchema,
+} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
sessionCreateValueSchema,
@@ -61,6 +64,8 @@ export interface IApiClient {
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise>>
+ listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise>>
+ createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise>>
@@ -98,6 +103,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('host.pickDirectory', payload, signal, false),
+ listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal),
+ createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal),
}
readonly workspace: IApiClient['workspace'] = {
diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts
index 8160f9d69a..29d57b3b55 100644
--- a/packages/host/apiproxy/src/fetch/handler.ts
+++ b/packages/host/apiproxy/src/fetch/handler.ts
@@ -23,7 +23,10 @@ import {
sessionPromptRequestSchema,
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
-import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts'
+import {
+ hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
+ hostListDirectoryRequestSchema, hostPickDirectoryRequestSchema,
+} from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
@@ -60,6 +63,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
+ 'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r) => api.host.listDirectory(r) },
+ 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts
index e7ef6c7332..44944e539a 100644
--- a/packages/host/apiproxy/src/index.ts
+++ b/packages/host/apiproxy/src/index.ts
@@ -45,7 +45,7 @@ export interface Config {
* project directory and the fallback parent for name-created Workspaces.
*/
export class ApiProxyService extends Service implements ApiProxy {
- static inject = ['agents', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
+ static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static Config: z = z.object({
provider: z.string().required(),
diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
index c6354db928..9572c76b23 100644
--- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
@@ -10,6 +10,8 @@ import type { Session } from '@deepseek-ai/dsh-session'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
+import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
+import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -57,7 +59,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
- pickDirectory?: (signal: AbortSignal) => Promise,
+ picker: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null },
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -92,36 +94,114 @@ async function harness(
},
}
ctx.agents.setFactory(factory)
+ // Structural picker fake: the gateway only reads capability(); a stable
+ // object per harness mirrors the seam's stability contract.
+ ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
provider: 'test',
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
- ...pickDirectory === undefined ? {} : { pickDirectory },
})
return { api, ctx, storageDomain, workspaceRoot }
}
describe('host.pickDirectory', () => {
- it('returns a selected path or explicit cancellation from the injected native boundary', async () => {
- const selected = await harness(undefined, async () => '/tmp/project')
+ it('returns a selected path or explicit cancellation from the dialog capability', async () => {
+ const selected = await harness(undefined, { kind: 'dialog', pick: async () => '/tmp/project' })
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: '/tmp/project' } })
- const cancelled = await harness(undefined, async () => null)
+ const cancelled = await harness(undefined, { kind: 'dialog', pick: async () => null })
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: null } })
})
- it('propagates abort into the native boundary as a cancelled RPC error', async () => {
- const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => {
- signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
- }))
+ it('propagates abort into the dialog capability as a cancelled RPC error', async () => {
+ const { api } = await harness(undefined, {
+ kind: 'dialog',
+ pick: signal => new Promise((_resolve, reject) => {
+ signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
+ }),
+ })
const abort = new AbortController()
const pending = api.host.pickDirectory(request({}), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
+
+ it('folds a non-abort dialog failure into an internal error', async () => {
+ const { api } = await harness(undefined, { kind: 'dialog', pick: async () => { throw new Error('no chooser installed') } })
+ const response = await api.host.pickDirectory(request({}), new AbortController().signal)
+ expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
+ })
+
+ it('refuses the dialog RPC under a browse composition', async () => {
+ const { api } = await harness(undefined, BROWSE_STUB)
+ const response = await api.host.pickDirectory(request({}), new AbortController().signal)
+ expect(response.result).toMatchObject({
+ ok: false,
+ error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
+ })
+ })
+})
+
+/** Canned browse capability: one listing, one created path, typed failures on demand. */
+const BROWSE_STUB: DirectoryPickerCapability = {
+ kind: 'browse',
+ list: async (path) => {
+ if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
+ const target = path ?? '/home/user'
+ return {
+ path: target,
+ home: '/home/user',
+ crumbs: [{ name: '/', path: '/', hidden: false }],
+ entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
+ }
+ },
+ createDirectory: async (path, name) => {
+ if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
+ if (name === 'unwritable') throw new Error('disk detached')
+ return `${path}/${name}`
+ },
+}
+
+describe('host.listDirectory / host.createDirectory', () => {
+ it('serves listings and creation through the browse capability, defaulting to home', async () => {
+ const { api } = await harness(undefined, BROWSE_STUB)
+ const home = await api.host.listDirectory(request({}))
+ expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
+ const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }))
+ expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
+ const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
+ expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
+ })
+
+ it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => {
+ const { api } = await harness(undefined, BROWSE_STUB)
+ expect((await api.host.listDirectory(request({ path: '/denied' }))).result).toMatchObject({
+ ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
+ })
+ expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
+ ok: false, error: { code: 'directory-exists' },
+ })
+ expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({
+ ok: false, error: { code: 'internal' },
+ })
+ })
+
+ it('refuses the browse RPCs under a dialog composition and advertises the kind in describe', async () => {
+ const { api } = await harness()
+ expect((await api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'dialog' } })
+ expect((await api.host.listDirectory(request({}))).result).toMatchObject({
+ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } },
+ })
+ expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
+ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } },
+ })
+ const browse = await harness(undefined, BROWSE_STUB)
+ expect((await browse.api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'browse' } })
+ })
})
describe('workspace.create', () => {
diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts
index 0885b7b896..dc92128487 100644
--- a/packages/host/apiproxy/tests/client-handler.spec.ts
+++ b/packages/host/apiproxy/tests/client-handler.spec.ts
@@ -48,8 +48,10 @@ function scriptedApi(overrides: {
...overrides.sessions,
},
host: {
- describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
+ describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, directoryPicker: 'browse' as const }),
pickDirectory: r => ok(r, { path: null }),
+ listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }),
+ createDirectory: r => ok(r, { path: '/t/new' }),
...overrides.host,
},
workspace: {
diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
index 318dbc970e..a0645f4750 100644
--- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts
+++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts
@@ -75,11 +75,17 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
host: {
async describe(request) {
- return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
+ return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, directoryPicker: 'dialog' as const } } }
},
async pickDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
},
+ async listDirectory(request) {
+ return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] } } }
+ },
+ async createDirectory(request) {
+ return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
+ },
},
workspace: {
async list(request) {
diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
index 9f1309b476..f64883c85b 100644
--- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts
+++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
@@ -12,7 +12,11 @@ import {
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
-import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
+import {
+ hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
+ hostDescribeRequestSchema, hostDescribeValueSchema,
+ hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
+} from '../src/api/host.schema.ts'
import {
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
@@ -204,9 +208,27 @@ describe('sessions domain schemas', () => {
describe('host domain schemas', () => {
it('validates describe request/value', () => {
expect(hostDescribeRequestSchema.parse({})).toEqual({})
- const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 })
+ const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' })
expect(value.attachedSessions).toBe(2)
- expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
+ expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined()
+ expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow()
+ })
+
+ it('validates the browse listing/creation payloads', () => {
+ expect(hostListDirectoryRequestSchema.parse({})).toEqual({})
+ expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' })
+ const listing = hostListDirectoryValueSchema.parse({
+ path: '/home/u/p',
+ home: '/home/u',
+ crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }],
+ entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }],
+ })
+ expect(listing.entries[0]?.hidden).toBe(true)
+ expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' })
+ for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
+ expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow()
+ }
+ expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' })
})
})
diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json
index f5aabb1cf8..100cbe2893 100644
--- a/packages/host/apiproxy/tsconfig.json
+++ b/packages/host/apiproxy/tsconfig.json
@@ -50,6 +50,9 @@
{
"path": "../../workspace/workspace"
},
+ {
+ "path": "../directory-picker"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml
new file mode 100644
index 0000000000..fea6c71d41
--- /dev/null
+++ b/packages/host/directory-picker-browse/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
+README.md: f86f74acf4922d490b23c033a281436f1a428f13
+README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92
diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md
new file mode 100644
index 0000000000..f86f74acf4
--- /dev/null
+++ b/packages/host/directory-picker-browse/README.md
@@ -0,0 +1,21 @@
+# @deepseek-ai/dsh-host-directory-picker-browse
+
+English | [中文](README.zh.md)
+
+The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot.
+
+Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
+
+## Model Experience
+
+None, as the backend serves the GUI host's directory selection; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost.
+- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here.
+- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it.
diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md
new file mode 100644
index 0000000000..0d240630a8
--- /dev/null
+++ b/packages/host/directory-picker-browse/README.zh.md
@@ -0,0 +1,21 @@
+# @deepseek-ai/dsh-host-directory-picker-browse
+
+[English](README.md) | 中文
+
+[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。
+
+行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
+
+## 模型体验
+
+无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
+
+#### KV 缓存影响
+
+无;该包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。
+- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
+- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。
diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json
new file mode 100644
index 0000000000..c1e8626bed
--- /dev/null
+++ b/packages/host/directory-picker-browse/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@deepseek-ai/dsh-host-directory-picker-browse",
+ "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@deepseek-ai/dsh-host-directory-picker": "workspace:^"
+ },
+ "peerDependencies": {
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts
new file mode 100644
index 0000000000..5f2a9429c0
--- /dev/null
+++ b/packages/host/directory-picker-browse/src/index.ts
@@ -0,0 +1,122 @@
+/**
+ * Browse backend of the directory-picker seam: registers `ctx.directoryPicker`
+ * with the `browse` capability — one-level directory listing and child-directory
+ * creation over the host filesystem via Node's stdlib (which already carries
+ * the per-OS adaptation). Nothing renders on the host display, so this backend
+ * serves remote clients the dialog backend cannot. Policy decisions (hidden
+ * entries flagged but returned, symlinks followed, whole-filesystem scope) are
+ * recorded in the directory-picker seam Agent Note.
+ * @module @deepseek-ai/dsh-host-directory-picker-browse
+ */
+
+import { mkdir, readdir, stat } from 'node:fs/promises'
+import { homedir } from 'node:os'
+import { basename, dirname, join, resolve } from 'node:path'
+import {
+ DirectoryPicker, DirectoryPickerError,
+} from '@deepseek-ai/dsh-host-directory-picker'
+import type {
+ DirectoryEntry, DirectoryListing, DirectoryPickerCapability,
+} from '@deepseek-ai/dsh-host-directory-picker'
+
+/**
+ * Ancestor chain from the filesystem root to `target` inclusive — the
+ * breadcrumb rows of a listing, every one a jump target.
+ */
+function ancestryCrumbs(target: string): DirectoryEntry[] {
+ const crumbs: DirectoryEntry[] = []
+ let current = target
+ for (;;) {
+ const parent = dirname(current)
+ // basename of a root is '' — label the root crumb by its full path ('/', 'C:\').
+ crumbs.unshift({ name: parent === current ? current : basename(current), path: current, hidden: false })
+ if (parent === current) return crumbs
+ current = parent
+ }
+}
+
+/** Message text of an unknown thrown value. */
+function messageOf(error: unknown): string {
+ /* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
+ return error instanceof Error ? error.message : String(error)
+}
+
+/**
+ * One listing row for a dirent, following symlinks to directories; null for
+ * non-directories and broken/cyclic links (skipped silently — the browser
+ * shows what can be entered, and a broken link cannot).
+ */
+async function directoryRow(parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean): Promise {
+ const path = join(parent, name)
+ let enterable = isDirectory
+ if (!enterable && isSymbolicLink) {
+ try {
+ enterable = (await stat(path)).isDirectory()
+ } catch {
+ // Broken or cyclic symlink: stat is the probe, failure means "not enterable".
+ return null
+ }
+ }
+ if (!enterable) return null
+ // POSIX hidden convention; Windows' hidden attribute is not exposed by
+ // dirents (Known Limitations). The client owns whether hidden rows show.
+ return { name, path, hidden: name.startsWith('.') }
+}
+
+/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
+export default class BrowseDirectoryPicker extends DirectoryPicker {
+ private readonly browseCapability: DirectoryPickerCapability = {
+ kind: 'browse',
+ list: path => this.list(path),
+ createDirectory: (path, name) => this.createDirectory(path, name),
+ }
+
+ /**
+ * The browse interaction capability.
+ * @returns the stable `browse` capability object.
+ */
+ capability(): DirectoryPickerCapability {
+ return this.browseCapability
+ }
+
+ private async list(path?: string): Promise {
+ const home = homedir()
+ const target = resolve(path ?? home)
+ let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
+ try {
+ const dirents = await readdir(target, { withFileTypes: true })
+ names = dirents.map(dirent => ({
+ name: dirent.name,
+ isDirectory: dirent.isDirectory(),
+ isSymbolicLink: dirent.isSymbolicLink(),
+ }))
+ } catch (error: unknown) {
+ throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`)
+ }
+ const rows = await Promise.all(names.map(entry => directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink)))
+ const entries = rows.filter((row): row is DirectoryEntry => row !== null)
+ .sort((a, b) => a.name.localeCompare(b.name))
+ return { path: target, home, crumbs: ancestryCrumbs(target), entries }
+ }
+
+ private async createDirectory(path: string, name: string): Promise {
+ const parent = resolve(path)
+ // The backend owns segment validation (the wire schema also refuses these,
+ // but direct service consumers must hit the same fence).
+ if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
+ throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`)
+ }
+ const target = join(parent, name)
+ try {
+ // Non-recursive: the parent is the directory the browser is showing, so
+ // a missing parent is a real failure, not a level to invent.
+ await mkdir(target)
+ return target
+ } catch (error: unknown) {
+ if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
+ throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
+ }
+ throw new DirectoryPickerError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`)
+ }
+ }
+}
diff --git a/packages/host/directory-picker-browse/src/invariant.ts b/packages/host/directory-picker-browse/src/invariant.ts
new file mode 100644
index 0000000000..ba4bfe7b13
--- /dev/null
+++ b/packages/host/directory-picker-browse/src/invariant.ts
@@ -0,0 +1,25 @@
+/**
+ * Package-owned invariant companion for the browse directory-picker backend.
+ * @module @deepseek-ai/dsh-host-directory-picker-browse/invariant
+ */
+
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse'
+
+/** Cordis companion plugin name. */
+export const name = 'host-directory-picker-browse-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: each list/create is one stateless filesystem round trip; the filesystem itself is the authoritative state. */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register the browse directory-picker invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts
new file mode 100644
index 0000000000..3833395c11
--- /dev/null
+++ b/packages/host/directory-picker-browse/tests/service.spec.ts
@@ -0,0 +1,96 @@
+/** Behavior of the browse backend over a real temporary directory tree. */
+
+import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
+import { homedir, tmpdir } from 'node:os'
+import { basename, join } from 'node:path'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
+import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker'
+import BrowseDirectoryPicker from '../src/index.ts'
+
+let root: string
+let capability: DirectoryPickerBrowseCapability
+let dispose: () => Promise
+
+beforeAll(async () => {
+ root = await mkdtemp(join(tmpdir(), 'dsh-browse-'))
+ await mkdir(join(root, 'projects'))
+ await mkdir(join(root, 'projects', 'harness'))
+ await mkdir(join(root, '.hidden-dir'))
+ await writeFile(join(root, 'notes.txt'), 'not a directory')
+ await symlink(join(root, 'projects'), join(root, 'linked'), 'junction')
+ await symlink(join(root, 'gone'), join(root, 'broken'), 'junction')
+
+ const ctx = new Context()
+ const fiber = ctx.plugin(BrowseDirectoryPicker)
+ await fiber.await()
+ const picked = ctx.get('directoryPicker')!.capability()
+ if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
+ capability = picked
+ dispose = () => fiber.dispose()
+})
+
+afterAll(async () => {
+ await dispose()
+ await rm(root, { recursive: true, force: true })
+})
+
+describe('BrowseDirectoryPicker', () => {
+ it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => {
+ const listing = await capability.list(root)
+ expect(listing.path).toBe(root)
+ expect(listing.home).toBe(homedir())
+ expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects'])
+ expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false])
+ // Every entry path is absolute and host-joined — clients never join segments.
+ expect(listing.entries.every(entry => entry.path === join(root, entry.name))).toBe(true)
+ })
+
+ it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => {
+ const listing = await capability.list(join(root, 'projects'))
+ const tail = listing.crumbs.at(-1)!
+ expect(tail).toMatchObject({ name: 'projects', path: join(root, 'projects'), hidden: false })
+ expect(listing.crumbs.at(-2)!.path).toBe(root)
+ expect(listing.crumbs.at(-2)!.name).toBe(basename(root))
+ // The chain starts at the filesystem root, whose crumb is labeled by its full path.
+ expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path)
+ })
+
+ it('lists the home directory when no path is given', async () => {
+ const listing = await capability.list()
+ expect(listing.path).toBe(homedir())
+ })
+
+ it('throws directory-unreadable for a missing target', async () => {
+ const missing = join(root, 'no-such-dir')
+ const failure = await capability.list(missing).catch((error: unknown) => error)
+ expect(failure).toBeInstanceOf(DirectoryPickerError)
+ expect((failure as DirectoryPickerError).code).toBe('directory-unreadable')
+ expect((failure as DirectoryPickerError).path).toBe(missing)
+ })
+
+ it('creates one child directory and surfaces it in the next listing', async () => {
+ const created = await capability.createDirectory(root, 'fresh')
+ expect(created).toBe(join(root, 'fresh'))
+ const listing = await capability.list(root)
+ expect(listing.entries.map(entry => entry.name)).toContain('fresh')
+ })
+
+ it('refuses an existing child with directory-exists', async () => {
+ const failure = await capability.createDirectory(root, 'projects').catch((error: unknown) => error)
+ expect(failure).toBeInstanceOf(DirectoryPickerError)
+ expect((failure as DirectoryPickerError).code).toBe('directory-exists')
+ })
+
+ it('refuses non-segment names and other filesystem failures with directory-create-failed', async () => {
+ for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
+ const failure = await capability.createDirectory(root, name).catch((error: unknown) => error)
+ expect(failure).toBeInstanceOf(DirectoryPickerError)
+ expect((failure as DirectoryPickerError).code).toBe('directory-create-failed')
+ }
+ // Missing parent is a real failure, not a level to invent.
+ const missingParent = await capability.createDirectory(join(root, 'no-such-dir'), 'child').catch((error: unknown) => error)
+ expect((missingParent as DirectoryPickerError).code).toBe('directory-create-failed')
+ })
+})
diff --git a/packages/host/directory-picker-browse/tsconfig.json b/packages/host/directory-picker-browse/tsconfig.json
new file mode 100644
index 0000000000..99ca673189
--- /dev/null
+++ b/packages/host/directory-picker-browse/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../directory-picker"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/host/directory-picker-dialog/README.i18n.yaml b/packages/host/directory-picker-dialog/README.i18n.yaml
new file mode 100644
index 0000000000..3cd3d36702
--- /dev/null
+++ b/packages/host/directory-picker-dialog/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/host/directory-picker-dialog/README.md
+README.md: fe07303557da6b27bb89eb761efba5555c4308c0
+README.zh.md: 214259264d5385ddad1ea6425149c53e31de55d0
diff --git a/packages/host/directory-picker-dialog/README.md b/packages/host/directory-picker-dialog/README.md
new file mode 100644
index 0000000000..fe07303557
--- /dev/null
+++ b/packages/host/directory-picker-dialog/README.md
@@ -0,0 +1,17 @@
+# @deepseek-ai/dsh-host-directory-picker-dialog
+
+English | [中文](README.zh.md)
+
+The **native-OS-dialog backend** of the [directory-picker seam](../directory-picker/README.md): `DialogDirectoryPicker` registers `ctx.directoryPicker` with the `dialog` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests.
+
+## Model Experience
+
+None, as the backend serves the GUI host's directory selection; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
diff --git a/packages/host/directory-picker-dialog/README.zh.md b/packages/host/directory-picker-dialog/README.zh.md
new file mode 100644
index 0000000000..214259264d
--- /dev/null
+++ b/packages/host/directory-picker-dialog/README.zh.md
@@ -0,0 +1,17 @@
+# @deepseek-ai/dsh-host-directory-picker-dialog
+
+[English](README.md) | 中文
+
+[目录选择 seam](../directory-picker/README.md) 的**原生 OS 对话框后端**:`DialogDirectoryPicker` 以 `dialog` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。
+
+## 模型体验
+
+无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
+
+#### KV 缓存影响
+
+无;该包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
diff --git a/packages/host/directory-picker-dialog/package.json b/packages/host/directory-picker-dialog/package.json
new file mode 100644
index 0000000000..94b22e6feb
--- /dev/null
+++ b/packages/host/directory-picker-dialog/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@deepseek-ai/dsh-host-directory-picker-dialog",
+ "description": "Native-OS-dialog backend of the directory-picker seam for the DeepSeek Harness web GUI host",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@deepseek-ai/dsh-host-directory-picker": "workspace:^"
+ },
+ "peerDependencies": {
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/host/directory-picker-dialog/src/index.ts b/packages/host/directory-picker-dialog/src/index.ts
new file mode 100644
index 0000000000..bc9814a5e7
--- /dev/null
+++ b/packages/host/directory-picker-dialog/src/index.ts
@@ -0,0 +1,33 @@
+/**
+ * Dialog backend of the directory-picker seam: registers `ctx.directoryPicker`
+ * with the `dialog` capability, opening one native OS chooser on the host
+ * display per pick (macOS `osascript`, Windows STA PowerShell
+ * `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable
+ * when the operator sits at the host's screen; remote deployments compose the
+ * browse backend instead.
+ * @module @deepseek-ai/dsh-host-directory-picker-dialog
+ */
+
+import { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
+import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
+import { pickNativeDirectory } from './native-picker.ts'
+
+export type { DirectoryPickerInternals, DirectoryPickerRunner } from './native-picker.ts'
+export { pickNativeDirectory } from './native-picker.ts'
+
+/** The `ctx.directoryPicker` dialog implementation (stable capability object per service life). */
+export default class DialogDirectoryPicker extends DirectoryPicker {
+ private readonly dialogCapability: DirectoryPickerCapability = {
+ kind: 'dialog',
+ /* v8 ignore next -- pure forward to pickNativeDirectory (its spec owns behavior); invoking here opens a real chooser. */
+ pick: signal => pickNativeDirectory(signal),
+ }
+
+ /**
+ * The dialog interaction capability.
+ * @returns the stable `dialog` capability object.
+ */
+ capability(): DirectoryPickerCapability {
+ return this.dialogCapability
+ }
+}
diff --git a/packages/host/directory-picker-dialog/src/invariant.ts b/packages/host/directory-picker-dialog/src/invariant.ts
new file mode 100644
index 0000000000..cbd3e63517
--- /dev/null
+++ b/packages/host/directory-picker-dialog/src/invariant.ts
@@ -0,0 +1,25 @@
+/**
+ * Package-owned invariant companion for the dialog directory-picker backend.
+ * @module @deepseek-ai/dsh-host-directory-picker-dialog/invariant
+ */
+
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-dialog'
+
+/** Cordis companion plugin name. */
+export const name = 'host-directory-picker-dialog-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: each pick is one stateless subprocess round trip; the dialog outcome is only the returned path. */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register the dialog directory-picker invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/host/apiproxy/src/native-directory-picker.ts b/packages/host/directory-picker-dialog/src/native-picker.ts
similarity index 97%
rename from packages/host/apiproxy/src/native-directory-picker.ts
rename to packages/host/directory-picker-dialog/src/native-picker.ts
index 8bc0bc99ce..a9602dc020 100644
--- a/packages/host/apiproxy/src/native-directory-picker.ts
+++ b/packages/host/directory-picker-dialog/src/native-picker.ts
@@ -1,4 +1,4 @@
-/** Cross-platform native single-directory picker used by the local GUI carrier. */
+/** Cross-platform native single-directory chooser behind the dialog backend's capability. */
import { execFile } from 'node:child_process'
diff --git a/packages/host/apiproxy/tests/native-directory-picker.spec.ts b/packages/host/directory-picker-dialog/tests/native-picker.spec.ts
similarity index 99%
rename from packages/host/apiproxy/tests/native-directory-picker.spec.ts
rename to packages/host/directory-picker-dialog/tests/native-picker.spec.ts
index 783a67a04b..87e25ff877 100644
--- a/packages/host/apiproxy/tests/native-directory-picker.spec.ts
+++ b/packages/host/directory-picker-dialog/tests/native-picker.spec.ts
@@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn()
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
-import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
+import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-picker.ts'
function failure(code: string | number, stderr = ''): Error {
return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr })
diff --git a/packages/host/directory-picker-dialog/tests/service.spec.ts b/packages/host/directory-picker-dialog/tests/service.spec.ts
new file mode 100644
index 0000000000..f56f93ec41
--- /dev/null
+++ b/packages/host/directory-picker-dialog/tests/service.spec.ts
@@ -0,0 +1,21 @@
+/** Registration/capability behavior of the dialog backend (the seam's cordis half). */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import DialogDirectoryPicker from '../src/index.ts'
+
+describe('DialogDirectoryPicker', () => {
+ it('registers ctx.directoryPicker with a stable dialog capability and leaves with its fiber', async () => {
+ const ctx = new Context()
+ const fiber = ctx.plugin(DialogDirectoryPicker)
+ await fiber.await()
+ const picker = ctx.get('directoryPicker')
+ expect(picker).toBeInstanceOf(DialogDirectoryPicker)
+ const capability = picker!.capability()
+ expect(capability.kind).toBe('dialog')
+ // Stability: consumers may capture the capability object across calls.
+ expect(picker!.capability()).toBe(capability)
+ await fiber.dispose()
+ expect(ctx.get('directoryPicker')).toBeUndefined()
+ })
+})
diff --git a/packages/host/directory-picker-dialog/tsconfig.json b/packages/host/directory-picker-dialog/tsconfig.json
new file mode 100644
index 0000000000..99ca673189
--- /dev/null
+++ b/packages/host/directory-picker-dialog/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../directory-picker"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml
new file mode 100644
index 0000000000..0d5c753e92
--- /dev/null
+++ b/packages/host/directory-picker/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md
+README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868
+README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42
diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md
new file mode 100644
index 0000000000..c1a801cf72
--- /dev/null
+++ b/packages/host/directory-picker/README.md
@@ -0,0 +1,19 @@
+# @deepseek-ai/dsh-host-directory-picker
+
+English | [中文](README.zh.md)
+
+The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union is merge-extensible and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime.
+
+Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
+
+## Model Experience
+
+None, as the seam serves the GUI host's directory selection; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note.
diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md
new file mode 100644
index 0000000000..c352b35b70
--- /dev/null
+++ b/packages/host/directory-picker/README.zh.md
@@ -0,0 +1,19 @@
+# @deepseek-ai/dsh-host-directory-picker
+
+[English](README.md) | 中文
+
+web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。
+
+浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
+
+## 模型体验
+
+无。该 seam 服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
+
+#### KV 缓存影响
+
+无;该包既不组装也不发送提供方请求。
+
+## 已知限制与延期工作
+
+- **没有多根词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。
diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json
new file mode 100644
index 0000000000..17d68c141b
--- /dev/null
+++ b/packages/host/directory-picker/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@deepseek-ai/dsh-host-directory-picker",
+ "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts",
+ "lib/types/**/*.d.ts.map",
+ "src"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts
new file mode 100644
index 0000000000..5e184b36e1
--- /dev/null
+++ b/packages/host/directory-picker/src/index.ts
@@ -0,0 +1,118 @@
+/**
+ * The `ctx.directoryPicker` seam: how the web-GUI host lets an operator
+ * select a workspace directory. Backends differ in interaction shape, not
+ * just mechanism, so the service exposes a discriminated capability instead
+ * of one method set: a `dialog` backend opens one native OS chooser on the
+ * host's display, while a `browse` backend serves listing/creation primitives
+ * for an in-app browser (and thereby works for remote clients no OS dialog
+ * can reach). Consumers switch on `capability().kind`; the union is
+ * merge-extensible, and the documented default for an unknown kind is to
+ * hide the picking affordance rather than fail.
+ * @module @deepseek-ai/dsh-host-directory-picker
+ */
+
+import { Context, Service } from 'cordis'
+
+/** The dialog interaction: one native OS directory chooser on the host display. */
+export interface DirectoryPickerDialogCapability {
+ kind: 'dialog'
+ /**
+ * Open the chooser and wait for the operator.
+ * @param signal - caller/connection lifetime; abort terminates the chooser.
+ * @returns the chosen absolute path, or null when the operator cancels.
+ */
+ pick(signal: AbortSignal): Promise
+}
+
+/** One directory row: a listing child or a breadcrumb ancestor. */
+export interface DirectoryEntry {
+ /** Base name shown in a browser row (a root crumb carries its full path). */
+ name: string
+ /** Absolute host path — clients never join path segments themselves. */
+ path: string
+ /** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */
+ hidden: boolean
+}
+
+/** One directory level plus its ancestry, as a browse backend reports it. */
+export interface DirectoryListing {
+ /** Absolute path of the listed directory. */
+ path: string
+ /** The host account's home directory (breadcrumb "Home" rooting). */
+ home: string
+ /**
+ * Ancestor chain from the filesystem root to the listed directory
+ * inclusive; every crumb is a jump target (crumb `hidden` is always false).
+ */
+ crumbs: DirectoryEntry[]
+ /** Direct child directories, name-sorted; symlinks to directories included. */
+ entries: DirectoryEntry[]
+}
+
+/**
+ * The browse interaction: listing/creation primitives an in-app browser
+ * drives one level at a time. Works for remote clients — nothing renders on
+ * the host display.
+ */
+export interface DirectoryPickerBrowseCapability {
+ kind: 'browse'
+ /**
+ * List one directory level.
+ * @param path - absolute directory to list; absent lists the home directory.
+ * @returns the level's listing with ancestry.
+ * @throws {DirectoryPickerError} `directory-unreadable` when the target cannot be listed.
+ */
+ list(path?: string): Promise
+ /**
+ * Create one child directory under an existing parent.
+ * @param path - absolute existing parent directory.
+ * @param name - single non-blank path segment (no separators, not `.`/`..`).
+ * @returns the created directory's absolute path.
+ * @throws {DirectoryPickerError} `directory-exists` for an existing child, `directory-create-failed` otherwise.
+ */
+ createDirectory(path: string, name: string): Promise
+}
+
+/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */
+export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability
+
+/** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */
+export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed'
+
+/** Typed failure thrown by browse primitives so consumers can map business codes without string matching. */
+export class DirectoryPickerError extends Error {
+ /**
+ * @param code - closed business code of the failure.
+ * @param path - the absolute path the failure is about.
+ * @param message - operator-facing description.
+ */
+ constructor(readonly code: DirectoryPickerErrorCode, readonly path: string, message: string) {
+ super(message)
+ this.name = 'DirectoryPickerError'
+ }
+}
+
+declare module 'cordis' {
+ interface Context {
+ directoryPicker: DirectoryPicker
+ }
+}
+
+/**
+ * Abstract directory-picking service. Subclass, implement `capability()`, and
+ * load the subclass as a plugin — it registers as `ctx.directoryPicker` (one
+ * implementation per context; loading a second throws, cordis' standard
+ * duplicate-service behavior). The capability object must be stable for the
+ * service lifetime: consumers may capture it across calls.
+ */
+export abstract class DirectoryPicker extends Service {
+ constructor(ctx: Context) {
+ super(ctx, 'directoryPicker')
+ }
+
+ /**
+ * The backend's interaction capability.
+ * @returns the discriminated capability consumers switch on.
+ */
+ abstract capability(): DirectoryPickerCapability
+}
diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts
new file mode 100644
index 0000000000..9128850f3b
--- /dev/null
+++ b/packages/host/directory-picker/src/invariant.ts
@@ -0,0 +1,22 @@
+/** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */
+
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker'
+
+/** Cordis companion plugin name. */
+export const name = 'host-directory-picker-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: this stateless seam owns the capability vocabulary, while backends and the RPC consumer own observations. */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register the directory-picker invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
diff --git a/packages/host/directory-picker/tests/seam.spec.ts b/packages/host/directory-picker/tests/seam.spec.ts
new file mode 100644
index 0000000000..4e7a799fa4
--- /dev/null
+++ b/packages/host/directory-picker/tests/seam.spec.ts
@@ -0,0 +1,35 @@
+/** Contract behavior the seam itself owns: registration identity and typed failures. */
+
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts'
+import type { DirectoryPickerCapability } from '../src/index.ts'
+
+/** Minimal concrete backend: all a subclass owes the abstract class is capability(). */
+class StubPicker extends DirectoryPicker {
+ private readonly stub: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null }
+ capability(): DirectoryPickerCapability {
+ return this.stub
+ }
+}
+
+describe('DirectoryPicker seam', () => {
+ it('registers a subclass as ctx.directoryPicker and leaves with its fiber', async () => {
+ const ctx = new Context()
+ const fiber = ctx.plugin(StubPicker)
+ await fiber.await()
+ expect(ctx.get('directoryPicker')).toBeInstanceOf(StubPicker)
+ expect(ctx.get('directoryPicker')!.capability().kind).toBe('dialog')
+ await fiber.dispose()
+ expect(ctx.get('directoryPicker')).toBeUndefined()
+ })
+
+ it('carries the business code and subject path on DirectoryPickerError', () => {
+ const failure = new DirectoryPickerError('directory-exists', '/home/u/x', '/home/u/x already exists')
+ expect(failure.name).toBe('DirectoryPickerError')
+ expect(failure.code).toBe('directory-exists')
+ expect(failure.path).toBe('/home/u/x')
+ expect(failure.message).toContain('already exists')
+ expect(failure).toBeInstanceOf(Error)
+ })
+})
diff --git a/packages/host/directory-picker/tsconfig.json b/packages/host/directory-picker/tsconfig.json
new file mode 100644
index 0000000000..9966c8ca8a
--- /dev/null
+++ b/packages/host/directory-picker/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../../vendor/cosmokit"
+ },
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f8343b2404..a47a1ea140 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -212,6 +212,9 @@ importers:
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../packages/host/apiproxy
+ '@deepseek-ai/dsh-host-directory-picker-dialog':
+ specifier: workspace:^
+ version: link:../../packages/host/directory-picker-dialog
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../../packages/host/webserver
@@ -2652,6 +2655,9 @@ importers:
'@deepseek-ai/dsh-commands':
specifier: workspace:^
version: link:../../ui/commands
+ '@deepseek-ai/dsh-host-directory-picker':
+ specifier: workspace:^
+ version: link:../directory-picker
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
@@ -2699,6 +2705,41 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+ packages/host/directory-picker:
+ devDependencies:
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
+ packages/host/directory-picker-browse:
+ dependencies:
+ '@deepseek-ai/dsh-host-directory-picker':
+ specifier: workspace:^
+ version: link:../directory-picker
+ devDependencies:
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
+ packages/host/directory-picker-dialog:
+ dependencies:
+ '@deepseek-ai/dsh-host-directory-picker':
+ specifier: workspace:^
+ version: link:../directory-picker
+ devDependencies:
+ '@deepseek-ai/dsh-invariants':
+ specifier: workspace:^
+ version: link:../../support/invariants
+ cordis:
+ specifier: ^4.0.0-rc.7
+ version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
packages/host/webserver:
dependencies:
schemastery:
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 86ef6d8477..8b4f0cf6fd 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -226,6 +226,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = {
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
+ DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md',
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 1d4a7e447d..1348ba425a 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -408,6 +408,15 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['spill-policy'],
note: 'The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill.',
},
+ {
+ key: 'directoryPicker',
+ pkg: 'directory-picker',
+ title: 'Workspace-directory picking seam',
+ mode: 'seam',
+ implementations: ['directory-picker-dialog', 'directory-picker-browse'],
+ consumers: ['apiproxy'],
+ note: 'Discriminated interaction capability: the dialog backend opens one native OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe.',
+ },
{
key: 'httpServer',
pkg: 'webserver',
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 13dc2def8d..79100a4c9e 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -73,6 +73,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
+ 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' },
+ 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
+ 'packages/host/directory-picker-dialog': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' },
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index d4beec7afa..51b6e938dd 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -99,6 +99,12 @@
// tsconfig.client.json) stay explicit — TS project references have no
// wildcard form.
"@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"],
+ "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"],
+ "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"],
+ "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"],
+ "@deepseek-ai/dsh-host-directory-picker-browse/*": ["./packages/host/directory-picker-browse/src/*"],
+ "@deepseek-ai/dsh-host-directory-picker-dialog": ["./packages/host/directory-picker-dialog/src"],
+ "@deepseek-ai/dsh-host-directory-picker-dialog/*": ["./packages/host/directory-picker-dialog/src/*"],
"@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"],
"@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"],
"@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 9cf2a86bda..98a51dda19 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -163,6 +163,9 @@
{ "path": "./packages/hooks/hooks-codex" },
{ "path": "./packages/mcp/mcp-client" },
{ "path": "./packages/host/apiproxy" },
+ { "path": "./packages/host/directory-picker" },
+ { "path": "./packages/host/directory-picker-browse" },
+ { "path": "./packages/host/directory-picker-dialog" },
{ "path": "./packages/host/webserver" },
{ "path": "./packages/sdk/sdk-client" },
{ "path": "./packages/sdk/helper" },