refactor(host): rename the directory-picker dialog backend and kind to native

The browse interaction also presents a dialog (the in-app modal), so 'dialog'
failed to discriminate the two capability kinds; 'native' names where the
chooser runs. Package directory-picker-dialog -> directory-picker-native, kind
'dialog' -> 'native', with every seam/gateway/client/doc reference updated and
the seam Agent Note's naming rationale rewritten to match.
This commit is contained in:
creatixchu
2026-07-28 21:07:28 +08:00
parent 987ecc2ec2
commit 5579b13503
53 changed files with 149 additions and 149 deletions

View File

@@ -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 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
2026-07-28-directory-picker-capability-seam.md: a30675f2d84b6ae68b95ab96df9e32106d6fbf5d
2026-07-28-directory-picker-capability-seam.zh.md: 5560aba07424d7307e386e2e8ae6b724486d6068
2026-07-28-directory-picker-capability-seam.md: 78ae05e0da67bff791c0b4f315451aa02e1fa6f4
2026-07-28-directory-picker-capability-seam.zh.md: bd527e2a1dba6934300a50877d4777f7f9fa24b1

View File

@@ -10,7 +10,7 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p
## 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.
A three-package capability seam in `packages/host/``directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, 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:
@@ -19,18 +19,18 @@ Placement and policy rulings folded into this decision:
- **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`).
- **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs.
## 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.
- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant.
- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
- **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires.
## Consequences
- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior). The GUI already gates its dialog affordance on `describe.directoryPicker` (non-`dialog` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI.
- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The GUI already gates its picking affordance on `describe.directoryPicker` (non-`native` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI.
- 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.
- A future interaction (or an Electron provider of the `native` interaction) 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.

View File

@@ -10,7 +10,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
## 决策
`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`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。
`packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native``directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**`{ kind: 'native', pick(signal) }``{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,经 `host.describe.directoryPicker` 广播 kind提供对应的 RPC另一种 kind 的调用以 `directory-picker-unavailable` 应答;客户端按广播的 kind 分支,未知 kind 隐藏入口(可合并扩展的默认分支)。组合(`cordis.yml`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。
并入本决策的位置与策略裁决:
@@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
- **隐藏条目:返回并打标。** 宿主标注 `hidden`POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。
- **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。
- **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。
- **dialog 后端保留。** 插件化正是目的:多方都能提供该 seamElectron 壳可以原生提供 `dialog`)。后端命名从机制(`native``local`——两者都在本机运行)改为交互(`-dialog``-browse`
- **native 后端保留。** 插件化正是目的:多方都能提供该 seamElectron 壳可以经自己的对话框 API 提供 `native` 交互。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置
## 曾考虑的替代方案
@@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
## 后果
- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`行为不变。GUI 已按 `describe.directoryPicker` 门控其对话框入口(非 `dialog` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。
- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`行为不变。GUI 已按 `describe.directoryPicker` 门控其选目录入口(非 `native` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。
- 协议新增 `host.listDirectory``host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段connection fixture 提供确定性浏览树供无密钥组装测试使用。
- 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。
- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个后端包加一个客户端分支——无需网关手术。
- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`

View File

@@ -236,7 +236,7 @@
# 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'
name: '@deepseek-ai/dsh-host-directory-picker-native'
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'

View File

@@ -48,7 +48,7 @@
"@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-directory-picker-native": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",

View File

@@ -138,7 +138,7 @@ flowchart LR
pkg_spill_policy["spill-policy"]
pkg_directory_picker["directory-picker"]
svc_directoryPicker["ctx.directoryPicker<br/>Workspace-directory picking seam"]
pkg_directory_picker_dialog["directory-picker-dialog"]
pkg_directory_picker_native["directory-picker-native"]
pkg_directory_picker_browse["directory-picker-browse"]
pkg_webserver["webserver"]
svc_httpServer["ctx.httpServer<br/>HTTP route registration"]
@@ -165,7 +165,7 @@ flowchart LR
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_directory_picker_native --> svc_directoryPicker
pkg_fs --> svc_fs
pkg_fs_local --> svc_fs
pkg_fs_sandbox --> svc_fs
@@ -356,7 +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.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one 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. |

View File

@@ -2187,7 +2187,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@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-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/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))

View File

@@ -184,7 +184,7 @@ flowchart TD
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_directory_picker_native["host-directory-picker-native"]
pkg_host_webserver["host-webserver"]
end
subgraph group_lsp["packages/lsp"]
@@ -262,7 +262,7 @@ flowchart TD
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_directory_picker_native --> pkg_invariants
pkg_host_webserver --> pkg_invariants
pkg_storage --> pkg_invariants
pkg_subprocess --> pkg_invariants
@@ -932,7 +932,7 @@ flowchart TD
| [`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-directory-picker-native`](../packages/host/directory-picker-native) | `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) |

View File

@@ -62,7 +62,7 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'dialog' | 'browse' }>> =
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'native' | 'browse' }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))

View File

@@ -180,7 +180,7 @@ export class WorkspacesService {
}
/**
* Open the Host's native directory picker (the `dialog` capability).
* Open the Host's native directory picker (the `native` capability).
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
@@ -193,7 +193,7 @@ export class WorkspacesService {
/**
* The directory-picking interaction the Host composed — the fact the picker
* UI branches on (`dialog` opens the native chooser; `browse` opens the
* UI branches on (`native` 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.

View File

@@ -80,7 +80,7 @@ export class FakeApiClient implements IApiClient {
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'dialog' | 'browse' }>> =
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'native' | 'browse' }>> =
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))

View File

@@ -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/client/ui-workspace/README.md
README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81
README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5
README.md: deaa25184f5ddbfc5980033ce60cef43577ff33c
README.zh.md: e14e8ca6a2e3d65ce5fc403291e45ebc03598e04

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `dialog` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `native` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.

View File

@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot因此两个表层使用同一菜单和创建流程。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `dialog` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `native` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。

View File

@@ -72,31 +72,31 @@ export function WorkspaceCreateFlow({
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The advertised interaction gates the picking affordance: 'dialog' is the
// The advertised interaction gates the picking affordance: 'native' is the
// only kind pickDirectory() can serve, so its entry renders under that kind
// alone; 'browse' (until the in-app browser UI lands) and unknown kinds
// hide the entry, the seam's documented unknown-kind default. Re-read per
// flow open — no cache to go stale across reconnects.
const [dialogPicker, setDialogPicker] = useState(false)
const [nativePicker, setNativePicker] = useState(false)
useEffect(() => {
if (!open) {
// Close discards the answer: a reconnect or HMR can swap the composed
// backend while the menu is closed, and the reopened menu must never
// paint the previous host's entry before the fresh read lands.
setDialogPicker(false)
setNativePicker(false)
return
}
// Reset before each read: the injected reader can also change identity
// while the flow stays open, and that prior answer must not leak either;
// a settlement from a superseded read is discarded via the
// cleanup-toggled flag.
setDialogPicker(false)
setNativePicker(false)
let stale = false
void directoryPickerKind()
.then((kind) => { if (!stale) setDialogPicker(kind === 'dialog') })
.then((kind) => { if (!stale) setNativePicker(kind === 'native') })
// A failed describe hides the entry too: the same Host that cannot
// answer describe cannot serve pickDirectory.
.catch(() => { if (!stale) setDialogPicker(false) })
.catch(() => { if (!stale) setNativePicker(false) })
return () => { stale = true }
}, [open, directoryPickerKind])
@@ -108,7 +108,7 @@ export function WorkspaceCreateFlow({
disabled: pickingFolder,
})),
...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []),
...(dialogPicker
...(nativePicker
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },

View File

@@ -15,7 +15,7 @@ async function bench() {
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const directoryPickerKind = vi.fn(async () => 'dialog' as const)
const directoryPickerKind = vi.fn(async () => 'native' as const)
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))

View File

@@ -60,7 +60,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
pickDirectory: vi.fn(async () => null),
directoryPickerKind: vi.fn(async () => 'dialog' as const),
directoryPickerKind: vi.fn(async () => 'native' as const),
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)

View File

@@ -39,7 +39,7 @@ function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
directoryPickerKind = vi.fn(async () => 'dialog'),
directoryPickerKind = vi.fn(async () => 'native'),
) {
const onPick = vi.fn()
const onClose = vi.fn()
@@ -221,7 +221,7 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={vi.fn(async () => 'dialog')}
directoryPickerKind={vi.fn(async () => 'native')}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -235,7 +235,7 @@ describe('WorkspacePicker', () => {
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
directoryPickerKind={vi.fn(async () => 'dialog')}
directoryPickerKind={vi.fn(async () => 'native')}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
@@ -258,7 +258,7 @@ describe('WorkspacePicker', () => {
})
it('does not read the picker kind while the flow is closed', () => {
const directoryPickerKind = vi.fn(async () => 'dialog')
const directoryPickerKind = vi.fn(async () => 'native')
render(
<WorkspacePicker
open={false} anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
@@ -290,10 +290,10 @@ describe('WorkspacePicker', () => {
.mockImplementationOnce(() => first)
.mockImplementation(async () => 'browse')
const t = togglable(directoryPickerKind)
// Close while the first read is in flight, then let it answer 'dialog':
// Close while the first read is in flight, then let it answer 'native':
// the settlement is stale and must not leak into the next open.
t.setOpen(false)
await act(async () => { resolveFirst('dialog') })
await act(async () => { resolveFirst('native') })
t.setOpen(true)
await screen.findByRole('menuitem', { name: 'Create a new workspace' })
await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) })
@@ -302,7 +302,7 @@ describe('WorkspacePicker', () => {
it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => {
const directoryPickerKind = vi.fn<() => Promise<string>>()
.mockImplementationOnce(async () => 'dialog')
.mockImplementationOnce(async () => 'native')
// The reopened read never settles: the assertion below sees the paint
// that precedes any fresh answer.
.mockImplementation(() => new Promise<string>(() => {}))
@@ -319,7 +319,7 @@ describe('WorkspacePicker', () => {
const first = new Promise<string>((_settle, reject) => { rejectFirst = reject })
const directoryPickerKind = vi.fn<() => Promise<string>>()
.mockImplementationOnce(() => first)
.mockImplementation(async () => 'dialog')
.mockImplementation(async () => 'native')
const t = togglable(directoryPickerKind)
t.setOpen(false)
t.setOpen(true)

View File

@@ -1605,15 +1605,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'DirectoryPickerCapabilities',
declaration: 'export interface DirectoryPickerCapabilities {\n dialog: DirectoryPickerDialogCapability;\n browse: DirectoryPickerBrowseCapability;\n}',
declaration: 'export interface DirectoryPickerCapabilities {\n native: DirectoryPickerNativeCapability;\n browse: DirectoryPickerBrowseCapability;\n}',
},
{
name: 'DirectoryPickerCapability',
declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];',
},
{
name: 'DirectoryPickerDialogCapability',
declaration: 'export interface DirectoryPickerDialogCapability {\n kind: \'dialog\';\n pick(signal: AbortSignal): Promise<string | null>;\n}',
name: 'DirectoryPickerNativeCapability',
declaration: 'export interface DirectoryPickerNativeCapability {\n kind: \'native\';\n pick(signal: AbortSignal): Promise<string | null>;\n}',
},
{
name: 'Domain',

View File

@@ -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: 81c483674c0d30847318b8fd9014bd8bb7d341c2
README.zh.md: 8b5ecd89ff4b1407cfa8c2bad63c77412b5fd16c
README.md: 0810be58fc773a241528656d7f6e826e9c3aabda
README.zh.md: f9133eee8498594d913b2fe0814ac51d712b678d

View File

@@ -8,8 +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/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` |
| `directory-picker-native/` | 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.

View File

@@ -8,8 +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 选择器后端osascriptPowerShellZenity+KDialog仅宿主屏幕可用 | (注册 `ctx.directoryPicker` |
| `directory-picker/` | 工作区目录选择 seam网关的 picker RPC 委托的可辨识 `native``browse` 能力 | `ctx.directoryPicker` |
| `directory-picker-native/` | 原生 OS 选择器后端osascriptPowerShellZenity+KDialog仅宿主屏幕可用 | (注册 `ctx.directoryPicker` |
| `directory-picker-browse/` | 应用内浏览后端:基于 Node 标准库的列举/创建原语;支持远程 | (注册 `ctx.directoryPicker` |
`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。

View File

@@ -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: 7c53e6dc9ac5758fce91d8b39abbfab6641384d1
README.zh.md: 5089935fe545bfc6ac3ddb39b9a2a25b50e1ceab
README.md: 8db8a1b77913677d88dbbbfbdbfc28a90c0d0415
README.zh.md: 1f957223bb98afb65ab8a313c49b58c701e42be0

View File

@@ -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()`.
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.
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 `native`, `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** — 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)).
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).

View File

@@ -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()` 中。
目录选择委托给组合的 `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` 请求一样覆盖上述全部方法。
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md)`host.describe.directoryPicker` 广播客户端应按其渲染的能力 kind调用广播之外的方法会以 `directory-picker-unavailable` 失败。在 `native` 下,`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 原生选择器依赖桌面工具**:在 `dialog` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [dialog 后端 README](../directory-picker-dialog/README.md))。
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。

View File

@@ -1002,10 +1002,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async pickDirectory(request, signal) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'dialog') {
if (capability.kind !== 'native') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.pickDirectory needs the dialog capability; the composed picker serves "${capability.kind}"`,
message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`,
details: { capability: capability.kind },
})
}

View File

@@ -7,7 +7,7 @@ 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
* `ctx.directoryPicker` capability kind): `native` = one 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`.
@@ -15,7 +15,7 @@ import type { RpcRequest, RpcResponse } from './rpc.ts'
* capability advertises before its RPCs exist); the client's documented
* default for a kind it does not recognize is to hide the picking affordance.
*/
export type DirectoryPickerKind = 'dialog' | 'browse' | (string & {})
export type DirectoryPickerKind = 'native' | 'browse' | (string & {})
/** One directory row of a listing: a child entry or a breadcrumb ancestor. */
export interface DirectoryEntry {
@@ -64,7 +64,7 @@ export interface HostApi {
/**
* Open the operating system's single-directory picker; cancellation returns
* null. Only served under the `dialog` capability.
* null. Only served under the `native` capability.
*/
pickDirectory(
request: RpcRequest<{}>,

View File

@@ -59,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-'))),
picker: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null },
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -107,19 +107,19 @@ async function harness(
}
describe('host.pickDirectory', () => {
it('returns a selected path or explicit cancellation from the dialog capability', async () => {
const selected = await harness(undefined, { kind: 'dialog', pick: async () => '/tmp/project' })
it('returns a selected path or explicit cancellation from the native capability', async () => {
const selected = await harness(undefined, { kind: 'native', 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, { kind: 'dialog', pick: async () => null })
const cancelled = await harness(undefined, { kind: 'native', 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 dialog capability as a cancelled RPC error', async () => {
it('propagates abort into the native capability as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
kind: 'dialog',
kind: 'native',
pick: signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
@@ -130,13 +130,13 @@ describe('host.pickDirectory', () => {
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') } })
it('folds a non-abort native-chooser failure into an internal error', async () => {
const { api } = await harness(undefined, { kind: 'native', 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 () => {
it('refuses the native 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({
@@ -190,14 +190,14 @@ describe('host.listDirectory / host.createDirectory', () => {
})
})
it('refuses the browse RPCs under a dialog composition and advertises the kind in describe', async () => {
it('refuses the browse RPCs under a native 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.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'native' } })
expect((await api.host.listDirectory(request({}))).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } },
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } },
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
const browse = await harness(undefined, BROWSE_STUB)
expect((await browse.api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'browse' } })

View File

@@ -75,7 +75,7 @@ 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, directoryPicker: 'dialog' as const } } }
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, directoryPicker: 'native' as const } } }
},
async pickDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }

View File

@@ -208,7 +208,7 @@ 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, directoryPicker: 'dialog' })
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'native' })
expect(value.attachedSessions).toBe(2)
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined()
// A kind beyond the two with methods survives the wire (merge-added

View File

@@ -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/directory-picker-browse/README.md
README.md: 9655fdb538da1addb4d10dbee6210965400394c8
README.zh.md: 1c7b0c36cbb73286f3d4e6a48748c4415f9ff0e4
README.md: 688a60894cb0ab066a6d501e4df310f8d31bfb5f
README.zh.md: c19bccc2ff9268cb7a6c931da4671bebc9f76b0c

View File

@@ -2,7 +2,7 @@
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.
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 native 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. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. 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).

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**`BrowseDirectoryPicker``browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**`BrowseDirectoryPicker``browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo``/foo`)与不完整的 UNC 前缀(`\\``\\server`)——报 `directory-unreadable``directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。

View File

@@ -1,17 +0,0 @@
# @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).

View File

@@ -1,6 +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
# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
README.md: 8e9d6c7c558a6b33c2a37d504c571fd3eda579bb
README.zh.md: 68f23698ff0c1a5ee4456a1f121dcf244f09c72b

View File

@@ -0,0 +1,17 @@
# @deepseek-ai/dsh-host-directory-picker-native
English | [中文](README.zh.md)
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` 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).

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-host-directory-picker-dialog
# @deepseek-ai/dsh-host-directory-picker-native
[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`)与平台事实可注入,便于确定性测试。
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。
## 模型体验

View File

@@ -1,6 +1,6 @@
{
"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",
"name": "@deepseek-ai/dsh-host-directory-picker-native",
"description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -1,11 +1,11 @@
/**
* Dialog backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `dialog` capability, opening one native OS chooser on the host
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `native` 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
* @module @deepseek-ai/dsh-host-directory-picker-native
*/
import { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
@@ -15,19 +15,19 @@ 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',
/** The `ctx.directoryPicker` native implementation (stable capability object per service life). */
export default class NativeDirectoryPicker extends DirectoryPicker {
private readonly nativeCapability: DirectoryPickerCapability = {
kind: 'native',
/* 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.
* The native interaction capability.
* @returns the stable `native` capability object.
*/
capability(): DirectoryPickerCapability {
return this.dialogCapability
return this.nativeCapability
}
}

View File

@@ -1,23 +1,23 @@
/**
* Package-owned invariant companion for the dialog directory-picker backend.
* @module @deepseek-ai/dsh-host-directory-picker-dialog/invariant
* Package-owned invariant companion for the native directory-picker backend.
* @module @deepseek-ai/dsh-host-directory-picker-native/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-dialog'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native'
/** Cordis companion plugin name. */
export const name = 'host-directory-picker-dialog-invariant'
export const name = 'host-directory-picker-native-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. */
/** No runtime invariant: each pick is one stateless subprocess round trip; the chooser outcome is only the returned path. */
const install: InvariantInstaller = () => {}
/**
* Register the dialog directory-picker invariant companion.
* Register the native directory-picker invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/

View File

@@ -1,18 +1,18 @@
/** Registration/capability behavior of the dialog backend (the seam's cordis half). */
/** Registration/capability behavior of the native backend (the seam's cordis half). */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import DialogDirectoryPicker from '../src/index.ts'
import NativeDirectoryPicker from '../src/index.ts'
describe('DialogDirectoryPicker', () => {
it('registers ctx.directoryPicker with a stable dialog capability and leaves with its fiber', async () => {
describe('NativeDirectoryPicker', () => {
it('registers ctx.directoryPicker with a stable native capability and leaves with its fiber', async () => {
const ctx = new Context()
const fiber = ctx.plugin(DialogDirectoryPicker)
const fiber = ctx.plugin(NativeDirectoryPicker)
await fiber.await()
const picker = ctx.get('directoryPicker')
expect(picker).toBeInstanceOf(DialogDirectoryPicker)
expect(picker).toBeInstanceOf(NativeDirectoryPicker)
const capability = picker!.capability()
expect(capability.kind).toBe('dialog')
expect(capability.kind).toBe('native')
// Stability: consumers may capture the capability object across calls.
expect(picker!.capability()).toBe(capability)
await fiber.dispose()

View File

@@ -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/directory-picker/README.md
README.md: 0332f7df067bfa79c7505be948554e814a690c6c
README.zh.md: a9a782019d0badfba33a7d108113ff5323fde77a
README.md: dcac8903522d53a8dd5fd346f124071f0f24b38e
README.zh.md: 5b7fc15513bf19722bd71bcbb30c6d187d6a71f5

View File

@@ -2,7 +2,7 @@
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 derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), 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.
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: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/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 chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), 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).

View File

@@ -2,7 +2,7 @@
[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` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker``ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 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)。

View File

@@ -2,7 +2,7 @@
* 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
* of one method set: a `native` backend opens one 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
@@ -13,9 +13,9 @@
import { Context, Service } from 'cordis'
/** The dialog interaction: one native OS directory chooser on the host display. */
export interface DirectoryPickerDialogCapability {
kind: 'dialog'
/** The native interaction: one OS directory chooser on the host display. */
export interface DirectoryPickerNativeCapability {
kind: 'native'
/**
* Open the chooser and wait for the operator.
* @param signal - caller/connection lifetime; abort terminates the chooser.
@@ -82,7 +82,7 @@ export interface DirectoryPickerBrowseCapability {
* must equal its key) instead of editing this package.
*/
export interface DirectoryPickerCapabilities {
dialog: DirectoryPickerDialogCapability
native: DirectoryPickerNativeCapability
browse: DirectoryPickerBrowseCapability
}

View File

@@ -7,7 +7,7 @@ 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 }
private readonly stub: DirectoryPickerCapability = { kind: 'native', pick: async () => null }
capability(): DirectoryPickerCapability {
return this.stub
}
@@ -19,7 +19,7 @@ describe('DirectoryPicker seam', () => {
const fiber = ctx.plugin(StubPicker)
await fiber.await()
expect(ctx.get('directoryPicker')).toBeInstanceOf(StubPicker)
expect(ctx.get('directoryPicker')!.capability().kind).toBe('dialog')
expect(ctx.get('directoryPicker')!.capability().kind).toBe('native')
await fiber.dispose()
expect(ctx.get('directoryPicker')).toBeUndefined()
})

6
pnpm-lock.yaml generated
View File

@@ -212,9 +212,9 @@ importers:
'@deepseek-ai/dsh-host-apiproxy':
specifier: workspace:^
version: link:../../packages/host/apiproxy
'@deepseek-ai/dsh-host-directory-picker-dialog':
'@deepseek-ai/dsh-host-directory-picker-native':
specifier: workspace:^
version: link:../../packages/host/directory-picker-dialog
version: link:../../packages/host/directory-picker-native
'@deepseek-ai/dsh-host-webserver':
specifier: workspace:^
version: link:../../packages/host/webserver
@@ -2727,7 +2727,7 @@ 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-dialog:
packages/host/directory-picker-native:
dependencies:
'@deepseek-ai/dsh-host-directory-picker':
specifier: workspace:^

View File

@@ -413,9 +413,9 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'directory-picker',
title: 'Workspace-directory picking seam',
mode: 'seam',
implementations: ['directory-picker-dialog', 'directory-picker-browse'],
implementations: ['directory-picker-native', '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.',
note: 'Discriminated interaction capability: the native backend opens one 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',

View File

@@ -75,7 +75,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'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/directory-picker-native': { 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.' },

View File

@@ -165,7 +165,7 @@
{ "path": "./packages/host/apiproxy" },
{ "path": "./packages/host/directory-picker" },
{ "path": "./packages/host/directory-picker-browse" },
{ "path": "./packages/host/directory-picker-dialog" },
{ "path": "./packages/host/directory-picker-native" },
{ "path": "./packages/host/webserver" },
{ "path": "./packages/sdk/sdk-client" },
{ "path": "./packages/sdk/helper" },