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" },