From 7fd2abd8283cd6ca5eeac37de35fa287af6a4919 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 15:44:53 +0800 Subject: [PATCH 01/26] feat(host): directory-picker capability seam with dialog and browse backends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web GUI's folder picking was hardwired to one interaction: a native OS chooser compiled into the gateway, unusable for remote deployments and swappable only by editing apiproxy source. Directory picking becomes a three-package capability seam in packages/host: ctx.directoryPicker returns a discriminated capability — dialog (the extracted native chooser; host-display only) or browse (new: one-level listing + child creation over Node stdlib, hidden flags host-stamped, symlinks followed, ancestry crumbs; remote-capable). The gateway injects the seam, advertises the kind via host.describe.directoryPicker, serves host.listDirectory / host.createDirectory under browse, and answers directory-picker-unavailable across kinds. cordis.yml is the swap point; apps/cli keeps dialog mounted, so behavior is unchanged until the in-app browser PR flips the default. The connection fixture serves a deterministic browse tree; WorkspacesService gains the browse calls the browser UI will drive. Decision record: .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md --- ...directory-picker-capability-seam.i18n.yaml | 6 + ...-07-28-directory-picker-capability-seam.md | 36 ++++++ ...-28-directory-picker-capability-seam.zh.md | 36 ++++++ apps/cli/cordis.yml | 5 + apps/cli/package.json | 3 +- docs/capability-seams.md | 9 ++ docs/config-catalog.md | 5 +- docs/cordis-catalog/services.md | 14 ++ docs/module-graph.md | 9 ++ packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/fixture.ts | 69 +++++++++- .../client/connection/src/client/index.ts | 1 + .../connection/tests/connection.spec.ts | 4 +- packages/client/connection/tests/fake-api.ts | 17 ++- packages/client/runtime/src/client/index.ts | 6 +- .../runtime/src/client/workspaces/service.ts | 51 +++++++- packages/client/runtime/tests/fake-api.ts | 17 ++- .../runtime/tests/workspaces-service.spec.ts | 34 ++++- .../cordis/tool-cordis/src/api-catalog.ts | 30 +++++ packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 3 + packages/host/README.zh.md | 3 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 4 +- packages/host/apiproxy/README.zh.md | 4 +- packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 55 +++++++- packages/host/apiproxy/src/api/host.schema.ts | 37 ++++++ packages/host/apiproxy/src/api/host.ts | 62 ++++++++- packages/host/apiproxy/src/api/index.ts | 2 +- packages/host/apiproxy/src/api/rpc-map.ts | 2 + packages/host/apiproxy/src/api/rpc.schema.ts | 4 + packages/host/apiproxy/src/api/rpc.ts | 4 + packages/host/apiproxy/src/fetch/client.ts | 11 +- packages/host/apiproxy/src/fetch/handler.ts | 7 +- packages/host/apiproxy/src/index.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 98 ++++++++++++-- .../apiproxy/tests/client-handler.spec.ts | 4 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 8 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 28 +++- packages/host/apiproxy/tsconfig.json | 3 + .../directory-picker-browse/README.i18n.yaml | 6 + .../host/directory-picker-browse/README.md | 21 +++ .../host/directory-picker-browse/README.zh.md | 21 +++ .../host/directory-picker-browse/package.json | 40 ++++++ .../host/directory-picker-browse/src/index.ts | 122 ++++++++++++++++++ .../directory-picker-browse/src/invariant.ts | 25 ++++ .../tests/service.spec.ts | 96 ++++++++++++++ .../directory-picker-browse/tsconfig.json | 24 ++++ .../directory-picker-dialog/README.i18n.yaml | 6 + .../host/directory-picker-dialog/README.md | 17 +++ .../host/directory-picker-dialog/README.zh.md | 17 +++ .../host/directory-picker-dialog/package.json | 40 ++++++ .../host/directory-picker-dialog/src/index.ts | 33 +++++ .../directory-picker-dialog/src/invariant.ts | 25 ++++ .../src/native-picker.ts} | 2 +- .../tests/native-picker.spec.ts} | 2 +- .../tests/service.spec.ts | 21 +++ .../directory-picker-dialog/tsconfig.json | 24 ++++ .../host/directory-picker/README.i18n.yaml | 6 + packages/host/directory-picker/README.md | 19 +++ packages/host/directory-picker/README.zh.md | 19 +++ packages/host/directory-picker/package.json | 37 ++++++ packages/host/directory-picker/src/index.ts | 118 +++++++++++++++++ .../host/directory-picker/src/invariant.ts | 22 ++++ .../host/directory-picker/tests/seam.spec.ts | 35 +++++ packages/host/directory-picker/tsconfig.json | 21 +++ pnpm-lock.yaml | 41 ++++++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 9 ++ .../verify-package-readme-model-experience.ts | 3 + tsconfig.base.json | 6 + tsconfig.host.json | 3 + 73 files changed, 1536 insertions(+), 49 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md create mode 100644 packages/host/directory-picker-browse/README.i18n.yaml create mode 100644 packages/host/directory-picker-browse/README.md create mode 100644 packages/host/directory-picker-browse/README.zh.md create mode 100644 packages/host/directory-picker-browse/package.json create mode 100644 packages/host/directory-picker-browse/src/index.ts create mode 100644 packages/host/directory-picker-browse/src/invariant.ts create mode 100644 packages/host/directory-picker-browse/tests/service.spec.ts create mode 100644 packages/host/directory-picker-browse/tsconfig.json create mode 100644 packages/host/directory-picker-dialog/README.i18n.yaml create mode 100644 packages/host/directory-picker-dialog/README.md create mode 100644 packages/host/directory-picker-dialog/README.zh.md create mode 100644 packages/host/directory-picker-dialog/package.json create mode 100644 packages/host/directory-picker-dialog/src/index.ts create mode 100644 packages/host/directory-picker-dialog/src/invariant.ts rename packages/host/{apiproxy/src/native-directory-picker.ts => directory-picker-dialog/src/native-picker.ts} (97%) rename packages/host/{apiproxy/tests/native-directory-picker.spec.ts => directory-picker-dialog/tests/native-picker.spec.ts} (99%) create mode 100644 packages/host/directory-picker-dialog/tests/service.spec.ts create mode 100644 packages/host/directory-picker-dialog/tsconfig.json create mode 100644 packages/host/directory-picker/README.i18n.yaml create mode 100644 packages/host/directory-picker/README.md create mode 100644 packages/host/directory-picker/README.zh.md create mode 100644 packages/host/directory-picker/package.json create mode 100644 packages/host/directory-picker/src/index.ts create mode 100644 packages/host/directory-picker/src/invariant.ts create mode 100644 packages/host/directory-picker/tests/seam.spec.ts create mode 100644 packages/host/directory-picker/tsconfig.json 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" }, From 716d3ca6361a24df610d2a6840fecc8aea8f2cf4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 16:44:22 +0800 Subject: [PATCH 02/26] test(apiproxy): drive the browse RPCs through the fetch carrier The full-suite coverage gate found the new listDirectory/createDirectory client methods and handler routes unexecuted: the implementation and schema layers were tested directly, but nothing crossed the wire form. One round trip through InProcessApiClient covers both arrows on each side. --- packages/host/apiproxy/tests/fetch-carrier.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index a0645f4750..6340e4e2e7 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -211,6 +211,19 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } }) }) + it('round-trips the browse listing and creation calls through the wire form', async () => { + const c = client() + const listed = await c.host.listDirectory({ path: '/w' }) + expect(listed.result).toEqual({ + ok: true, + value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }, + }) + const home = await c.host.listDirectory({}) + expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) + const created = await c.host.createDirectory({ path: '/w', name: 'fresh' }) + expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } }) + }) + it('round-trips command.list / command.execute / skill.list through the wire form', async () => { const c = client() const list = await c.commands.list({ sessionId: 's' as never }) From c565022c8af85c5fd3b780c6459a6939aca8c4bf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:09:43 +0800 Subject: [PATCH 03/26] fix(host): derive the picker capability union from a merge-extensible map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 1: the seam documented a merge-extensible union but shipped a closed alias, and the gateway schema rejected any kind beyond dialog/browse — a third backend could neither implement the seam nor be advertised. The union now derives from an augmentable DirectoryPickerCapabilities map, host.describe.directoryPicker preserves unknown wire kinds, and the browse fixture applies listDirectory's root special case so creating under '/' no longer mints a '//name' identity. --- docs/cordis-catalog/services.md | 2 +- packages/client/connection/src/client/fixture.ts | 4 +++- packages/client/connection/tests/fixture.spec.ts | 16 ++++++++++++++++ packages/cordis/tool-cordis/src/api-catalog.ts | 6 +++++- packages/host/apiproxy/src/api/host.schema.ts | 4 +++- packages/host/apiproxy/src/api/host.ts | 5 ++++- packages/host/apiproxy/tests/rpc-schemas.spec.ts | 4 +++- packages/host/directory-picker/README.i18n.yaml | 4 ++-- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 14 ++++++++++++-- 11 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index eb212207b3..397e7320d5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:108`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:118`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a490f0d27d..fdc258beed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -831,7 +831,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { if (children === undefined) { return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } }) } - const target = `${parent}/${request.payload.name}` + // Same root special case as listDirectory's entry paths: a plain join + // under '/' would mint '//name' and fork the tree's identity. + const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}` if (children.includes(request.payload.name)) { return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } }) } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 1b413441df..3f07922f4d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -312,6 +312,22 @@ describe('createFixtureApi', () => { expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) }) + it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { + const api = createFixtureApi() + const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) + if (!created.result.ok) throw new Error('create failed') + expect(created.result.value.path).toBe('/srv') + const listed = await api.host.listDirectory(req({ path: '/srv' })) + if (!listed.result.ok) throw new Error('list failed') + expect(listed.result.value.crumbs).toEqual([ + { name: '/', path: '/', hidden: false }, + { name: 'srv', path: '/srv', hidden: false }, + ]) + const root = await api.host.listDirectory(req({ path: '/' })) + if (!root.result.ok) throw new Error('root list failed') + expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) + }) + it('workspace.list serves the resident account and create reuses on path collision', async () => { const api = createFixtureApi() const listed = await api.workspace.list(req({})) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 04b2e60ef0..698dd32201 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1603,9 +1603,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DirectoryPickerBrowseCapability', declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', }, + { + name: 'DirectoryPickerCapabilities', + declaration: 'export interface DirectoryPickerCapabilities {\n dialog: DirectoryPickerDialogCapability;\n browse: DirectoryPickerBrowseCapability;\n}', + }, { name: 'DirectoryPickerCapability', - declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;', + declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];', }, { name: 'DirectoryPickerDialogCapability', diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index a73b659970..d4a3a95754 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,7 +17,9 @@ 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')]), + // Open string, not a literal union: unknown kinds must survive the wire so + // a merge-added capability can advertise (the client hides the affordance). + directoryPicker: z.string(), }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index abdb3c468b..3de58b7bc7 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -11,8 +11,11 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' * 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`. + * The wire preserves kinds beyond the two with methods here (a merge-added + * capability advertises before its RPCs exist); the client's documented + * default for a kind it does not recognize is to hide the picking affordance. */ -export type DirectoryPickerKind = 'dialog' | 'browse' +export type DirectoryPickerKind = 'dialog' | 'browse' | (string & {}) /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index f64883c85b..939de7677e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -211,7 +211,9 @@ describe('host domain schemas', () => { 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, directoryPicker: 'browse' }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow() + // A kind beyond the two with methods survives the wire (merge-added + // capabilities advertise; the client hides the affordance). + expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' }).directoryPicker).toBe('other') }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 0d5c753e92..a331a2caeb 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/directory-picker/README.md -README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868 -README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42 +README.md: 0332f7df067bfa79c7505be948554e814a690c6c +README.zh.md: a9a782019d0badfba33a7d108113ff5323fde77a diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index c1a801cf72..0332f7df06 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union 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. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. 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). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index c352b35b70..a9a782019d 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 浏览原语以带类型的 `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)。 diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5e184b36e1..5a6040e51f 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -73,8 +73,18 @@ export interface DirectoryPickerBrowseCapability { createDirectory(path: string, name: string): Promise } -/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */ -export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability +/** + * Merge-extensible registry of interaction shapes keyed by capability kind: a + * new backend declaration-merges its shape here (the entry's `kind` literal + * must equal its key) instead of editing this package. + */ +export interface DirectoryPickerCapabilities { + dialog: DirectoryPickerDialogCapability + browse: DirectoryPickerBrowseCapability +} + +/** Union of interaction shapes a backend can provide, derived from the merge-extensible {@link DirectoryPickerCapabilities} map. */ +export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities] /** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */ export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed' From cd7aa3c7d879d1d65df1dafc3ad2a5688f2252f2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:39:15 +0800 Subject: [PATCH 04/26] fix(host,client): gate the picker affordance on the advertised kind; reject non-absolute browse paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot round 2. The workspace UI never consulted the advertised directoryPicker kind: under a browse (or merge-added) backend it still rendered 'Open local folder…' and called pickDirectory(), which the host answers with directory-picker-unavailable. The create flow now reads directoryPickerKind() per menu open and renders the dialog affordance only under 'dialog' — browse (until its in-app browser UI lands) and unknown kinds hide the entry, realizing the seam's documented default; a keyless workspace-flow snapshot pins the hidden entry over the browse fixture. The browse backend also resolved wire paths, silently rebasing '' or relative parents under the host process cwd; both primitives now reject non-absolute explicit paths with their business codes, and the seam JSDoc carries the contract. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 14 ++++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 2 + .../src/client/WorkspacePicker.tsx | 29 +++++++- .../ui-workspace/src/client/contract/slots.ts | 6 +- .../client/ui-workspace/src/client/index.ts | 2 + .../client/ui-workspace/tests/apply.spec.ts | 9 ++- .../tests/workspace-browser.spec.tsx | 1 + .../tests/workspace-picker.spec.tsx | 66 ++++++++++++++----- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 11 +++- .../tests/service.spec.ts | 13 ++++ packages/host/directory-picker/src/index.ts | 6 +- 20 files changed, 146 insertions(+), 37 deletions(-) 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 index ccef0728d9..c13d5e7a1c 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 8d9d34e7aed4525b243380a4a90801fe59bfc213 -2026-07-28-directory-picker-capability-seam.zh.md: 282f3905c3551912915088f70247260310f442cb +2026-07-28-directory-picker-capability-seam.md: a30675f2d84b6ae68b95ab96df9e32106d6fbf5d +2026-07-28-directory-picker-capability-seam.zh.md: 5560aba07424d7307e386e2e8ae6b724486d6068 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 index 8d9d34e7ae..a30675f2d8 100644 --- 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 @@ -30,7 +30,7 @@ Placement and policy rulings folded into this decision: ## 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`. +- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior). The GUI already gates its dialog affordance on `describe.directoryPicker` (non-`dialog` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI. - 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 index 282f3905c3..5560aba074 100644 --- 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 @@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变),应用内浏览器 PR 将把默认翻到 `-browse` 并让 GUI 按 `describe` 分支。 +- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其对话框入口(非 `dialog` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。 - 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 - 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e1fe183ef1..715be624e0 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -174,6 +174,20 @@ it('locks the composer in the New Session view state until a Workspace is chosen `) }) +it('hides the Open-local-folder entry under the fixture host\'s browse picker capability', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + const menu = await screen.findByRole('menu') + // Flush the advertised-kind read (fixture describe resolves in microtasks): + // the fixture serves `browse`, whose in-app UI is not wired yet, so the + // dialog affordance must not render — only the create action remains. + await act(async () => {}) + expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item))) + .toEqual(['Create a new workspace']) +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 0d78a8d648..35cd3c3702 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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/client/ui-workspace/README.md -README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9 -README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4 +README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81 +README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index edd6c2f937..58aaf56e19 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `dialog` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index f7b73dde95..7c7b33e0ea 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `dialog` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..bf3a41522e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -254,6 +254,7 @@ export function WorkspaceBrowser({ insertSessionBefore, createWorkspace, pickDirectory, + directoryPickerKind, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -372,6 +373,7 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + directoryPickerKind={directoryPickerKind} onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..8cef0af8f9 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,13 +5,13 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import { WorkspaceCreateError, - type WorkspaceId, type WorkspaceListState, type WorkspaceView, + type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -33,6 +33,8 @@ export interface WorkspaceCreateFlowProps { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Open the Host's native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -50,6 +52,7 @@ export function WorkspaceCreateFlow({ useWorkspaces, createWorkspace, pickDirectory, + directoryPickerKind, onPick, onClose, }: WorkspaceCreateFlowProps) { @@ -69,6 +72,22 @@ export function WorkspaceCreateFlow({ const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) + // The advertised interaction gates the picking affordance: 'dialog' is the + // only kind pickDirectory() can serve, so its entry renders under that kind + // alone; 'browse' (until the in-app browser UI lands) and unknown kinds + // hide the entry, the seam's documented unknown-kind default. Re-read per + // flow open — no cache to go stale across reconnects. + const [dialogPicker, setDialogPicker] = useState(false) + useEffect(() => { + if (!open) return + void directoryPickerKind() + .then((kind) => { setDialogPicker(kind === 'dialog') }) + // A failed describe hides the entry too: the same Host that cannot + // answer describe cannot serve pickDirectory. (Post-unmount settlement + // is safe: React 18 no-ops setState on unmounted components.) + .catch(() => { setDialogPicker(false) }) + }, [open, directoryPickerKind]) + const items: MenuEntry[] = [ ...workspaces.map(workspace => ({ id: workspace.workspaceId, @@ -77,7 +96,9 @@ export function WorkspaceCreateFlow({ disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + ...(dialogPicker + ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }] + : []), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, ] @@ -229,6 +250,7 @@ export function WorkspacePicker({ onClose, createWorkspace, pickDirectory, + directoryPickerKind, }: WorkspacePickerProps) { return ( diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fcdd0e304e..c20955b567 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -13,7 +13,7 @@ import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { createWorkspaceViewStore } from '../stores.ts' /** @@ -44,6 +44,8 @@ export type WorkspaceBrowserInjected = { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Ask the local Host to open its native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ @@ -62,6 +64,8 @@ export type WorkspacePickerInjected = { createWorkspace: (input: { name: string } | { path: string }) => Promise /** Ask the local Host to open its native single-directory picker. */ pickDirectory: () => Promise + /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ + directoryPickerKind: () => Promise } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index f27448f926..6b84680fbc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -45,10 +45,12 @@ export function apply(ctx: ClientContext): void { }, createWorkspace: input => ctx.workspaces.create(input), pickDirectory: () => ctx.workspaces.pickDirectory(), + directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), pickDirectory: () => ctx.workspaces.pickDirectory(), + directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), }) // Declaration-aware registration: each owner's declaring apply may activate // after this one (entry activation order is unconstrained), and a register diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 24434f22aa..f9a5884206 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -15,16 +15,17 @@ async function bench() { title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) const pickDirectory = vi.fn(async () => '/tmp/picked') + const directoryPickerKind = vi.fn(async () => 'dialog' as const) const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() ctx.provide('workspaces', { - create, pickDirectory, startSession, rename, insertSessionBefore, + create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear } + return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, open, clear } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -75,12 +76,16 @@ describe('ui-workspace apply', () => { expect(b.create).toHaveBeenCalledWith({ name: 'project' }) await browser.pickDirectory() expect(b.pickDirectory).toHaveBeenCalledOnce() + await browser.directoryPickerKind() + expect(b.directoryPickerKind).toHaveBeenCalledOnce() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() await picker.createWorkspace({ path: '/tmp/project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) await picker.pickDirectory() expect(b.pickDirectory).toHaveBeenCalledTimes(2) + await picker.directoryPickerKind() + expect(b.directoryPickerKind).toHaveBeenCalledTimes(2) }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..779ad0b37a 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -60,6 +60,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), pickDirectory: vi.fn(async () => null), + directoryPickerKind: vi.fn(async () => 'dialog' as const), ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..c7f8fd9765 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -39,6 +39,7 @@ function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), pickDirectory = vi.fn(async () => null as string | null), + directoryPickerKind = vi.fn(async () => 'dialog'), ) { const onPick = vi.fn() const onClose = vi.fn() @@ -53,19 +54,22 @@ function mount( onClose={onClose} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + directoryPickerKind={directoryPickerKind} /> ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, pickDirectory, + view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } -function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void { - fireEvent.click(screen.getByRole('menuitem', { name })) +// findByRole, not getByRole: the folder entry renders only after the advertised +// picker kind resolves, one microtask after the menu opens. +async function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): Promise { + fireEvent.click(await screen.findByRole('menuitem', { name })) } describe('WorkspacePicker', () => { @@ -79,7 +83,7 @@ describe('WorkspacePicker', () => { const created = workspace('new', 'New') const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.change(input, { target: { value: 'project-one' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -92,7 +96,7 @@ describe('WorkspacePicker', () => { const createWorkspace = vi.fn(async () => created) const pickDirectory = vi.fn(async () => '/tmp/project') const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') expect(pickDirectory).toHaveBeenCalledOnce() await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) @@ -101,7 +105,7 @@ describe('WorkspacePicker', () => { it('treats native picker cancellation as a silent no-op', async () => { const b = mount([], vi.fn(), vi.fn(async () => null)) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.onPick).not.toHaveBeenCalled() @@ -118,7 +122,7 @@ describe('WorkspacePicker', () => { }) }) const b = mount([], createWorkspace, pickDirectory) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() }) @@ -132,7 +136,7 @@ describe('WorkspacePicker', () => { let resolve!: (path: string | null) => void const pending = new Promise((settle) => { resolve = settle }) const b = mount([], vi.fn(), vi.fn(() => pending)) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) @@ -142,23 +146,23 @@ describe('WorkspacePicker', () => { it('reports non-Error native picker failures', async () => { const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) - chooseItem('Open local folder…') + await chooseItem('Open local folder…') await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('picker unavailable') }) expect(b.createWorkspace).not.toHaveBeenCalled() }) - it('closes a creation modal when the user cancels', () => { + it('closes a creation modal when the user cancels', async () => { mount([]) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('dialog')).toBeNull() }) - it('blocks a create-new name already present in the Workspace list', () => { + it('blocks a create-new name already present in the Workspace list', async () => { const b = mount([workspace('alpha', 'Alpha')]) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect(screen.getByRole('button', { name: 'Create workspace' }).disabled).toBe(true) @@ -171,7 +175,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((settle) => { resolve = settle }) const created = workspace('fresh', 'same-name') const b = mount([], vi.fn(() => pending)) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -187,7 +191,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) const createWorkspace = vi.fn(() => pending) const b = mount([], createWorkspace) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.keyDown(input, { key: 'ArrowRight' }) fireEvent.change(input, { target: { value: 'broken' } }) @@ -204,7 +208,7 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) - chooseItem('Create a new workspace') + await chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') @@ -217,6 +221,7 @@ describe('WorkspacePicker', () => { 'dialog')} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -230,8 +235,37 @@ describe('WorkspacePicker', () => { 'dialog')} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') }) + + it('hides the folder affordance unless the Host advertises the dialog interaction', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => 'browse')) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('hides the folder affordance when the Host cannot answer describe', async () => { + const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => { + throw new Error('host unreachable') + })) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('does not read the picker kind while the flow is closed', () => { + const directoryPickerKind = vi.fn(async () => 'dialog') + render( + , + ) + expect(directoryPickerKind).not.toHaveBeenCalled() + }) }) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index fea6c71d41..916db3c321 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: f86f74acf4922d490b23c033a281436f1a428f13 -README.zh.md: 0d240630a8b21003c5285bc75e93aac9adf36d92 +README.md: 81357269e1d4b075f7e31b5f3ac5d4721811024a +README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index f86f74acf4..81357269e1 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. -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). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject a non-absolute explicit path (`directory-unreadable`/`directory-create-failed`) instead of letting `resolve` rebase it under the host process cwd. 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 diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 0d240630a8..06a7f7651b 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 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)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非绝对的显式路径(`directory-unreadable`/`directory-create-failed`),而不是任由 `resolve` 把它重定位到宿主进程 cwd 之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 5f2a9429c0..83a3f6762e 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -11,7 +11,7 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, dirname, join, resolve } from 'node:path' +import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' @@ -81,6 +81,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private async list(path?: string): Promise { const home = homedir() + // The seam contract takes absolute paths only; resolve() would silently + // rebase a relative or empty wire value under the host process cwd. + if (path !== undefined && !isAbsolute(path)) { + throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`) + } const target = resolve(path ?? home) let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] try { @@ -100,6 +105,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async createDirectory(path: string, name: string): Promise { + // Same absolute-path fence as list: never rebase a parent under the cwd. + if (!isAbsolute(path)) { + throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`) + } const parent = resolve(path) // The backend owns segment validation (the wire schema also refuses these, // but direct service consumers must hit the same fence). diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 3833395c11..608fe89348 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => { expect((failure as DirectoryPickerError).path).toBe(missing) }) + it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => { + for (const relative of ['', 'projects', './projects', '..']) { + const listFailure = await capability.list(relative).catch((error: unknown) => error) + expect(listFailure).toBeInstanceOf(DirectoryPickerError) + expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable') + expect((listFailure as DirectoryPickerError).path).toBe(relative) + const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error) + expect(createFailure).toBeInstanceOf(DirectoryPickerError) + expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed') + expect((createFailure as DirectoryPickerError).path).toBe(relative) + } + }) + 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')) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 5a6040e51f..322dc4551c 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -60,7 +60,8 @@ export interface DirectoryPickerBrowseCapability { * 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. + * @throws {DirectoryPickerError} `directory-unreadable` when the target is not absolute + * (a wire value must never rebase under the host cwd) or cannot be listed. */ list(path?: string): Promise /** @@ -68,7 +69,8 @@ export interface DirectoryPickerBrowseCapability { * @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. + * @throws {DirectoryPickerError} `directory-exists` for an existing child, + * `directory-create-failed` for a non-absolute parent or any other failure. */ createDirectory(path: string, name: string): Promise } From c4bf919895ebe92a691fe50b3b5d3cc0d619c35f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 17:56:03 +0800 Subject: [PATCH 05/26] fix(host,client): default-export the picker seam; invalidate stale kind reads ds-review-bot round 3. The seam package broke the service-package export contract (named export only), so the config catalog filed it under Other libraries and default imports failed; it now default-exports DirectoryPicker like every abstract seam, and the regenerated catalog lists it as one. The picker-kind effect also let a settlement from a superseded flow open leak into the current one (close/reopen mid-describe, or a reconnect that swaps the backend): the read now resets the affordance on every open and a cleanup-toggled flag discards obsolete settlements, both directions pinned by jsdom races. --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspacePicker.tsx | 14 ++++-- .../tests/workspace-picker.spec.tsx | 46 +++++++++++++++++++ packages/host/directory-picker/src/index.ts | 2 + 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index f9b4e9f8bf..0697f56dfe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2211,6 +2211,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker` — abstract `DirectoryPicker` ([`packages/host/directory-picker/src/index.ts`](../packages/host/directory-picker/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) @@ -2234,7 +2235,6 @@ 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 397e7320d5..3b47e405f0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:118`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:120`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 8cef0af8f9..2e65fc6b72 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -80,12 +80,18 @@ export function WorkspaceCreateFlow({ const [dialogPicker, setDialogPicker] = useState(false) useEffect(() => { if (!open) return + // Reset before each read: a reconnect can change the composed backend, so + // a previous open's answer must not leak into this one; and a settlement + // from a superseded open (flow closed, or a newer read started) is + // discarded via the cleanup-toggled flag. + setDialogPicker(false) + let stale = false void directoryPickerKind() - .then((kind) => { setDialogPicker(kind === 'dialog') }) + .then((kind) => { if (!stale) setDialogPicker(kind === 'dialog') }) // A failed describe hides the entry too: the same Host that cannot - // answer describe cannot serve pickDirectory. (Post-unmount settlement - // is safe: React 18 no-ops setState on unmounted components.) - .catch(() => { setDialogPicker(false) }) + // answer describe cannot serve pickDirectory. + .catch(() => { if (!stale) setDialogPicker(false) }) + return () => { stale = true } }, [open, directoryPickerKind]) const items: MenuEntry[] = [ diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index c7f8fd9765..9ca5304fa4 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -268,4 +268,50 @@ describe('WorkspacePicker', () => { ) expect(directoryPickerKind).not.toHaveBeenCalled() }) + + /** Render the picker with an owner-controlled `open` and a scripted kind read. */ + function togglable(directoryPickerKind: () => Promise) { + const anchorRef = anchor() + const props = (open: boolean) => ( + + ) + const view = render(props(true)) + return { setOpen: (open: boolean) => { view.rerender(props(open)) } } + } + + it('discards a kind settlement from a superseded flow open', async () => { + let resolveFirst!: (kind: string) => void + const first = new Promise((settle) => { resolveFirst = settle }) + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(() => first) + .mockImplementation(async () => 'browse') + const t = togglable(directoryPickerKind) + // Close while the first read is in flight, then let it answer 'dialog': + // the settlement is stale and must not leak into the next open. + t.setOpen(false) + await act(async () => { resolveFirst('dialog') }) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + + it('discards a stale describe failure after a newer open already answered', async () => { + let rejectFirst!: (reason: Error) => void + const first = new Promise((_settle, reject) => { rejectFirst = reject }) + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(() => first) + .mockImplementation(async () => 'dialog') + const t = togglable(directoryPickerKind) + t.setOpen(false) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Open local folder…' }) + // The superseded read failing late must not hide the freshly shown entry. + await act(async () => { rejectFirst(new Error('late loss')); await first.catch(() => {}) }) + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + }) }) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 322dc4551c..1c4f5e32fb 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -128,3 +128,5 @@ export abstract class DirectoryPicker extends Service { */ abstract capability(): DirectoryPickerCapability } + +export default DirectoryPicker From b211a80b1fa8d3eccd1d0c5ab0cb2f7b5b1755d6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:13:20 +0800 Subject: [PATCH 06/26] fix(host,client): require fully qualified browse paths; clear the picker kind on close ds-review-bot round 4. On Windows, isAbsolute admits rooted drive-less forms (\foo, /foo) that resolve() then rebases onto the process's current drive; both browse primitives now gate on a fullyQualified check (drive letter or UNC on win32, POSIX-absolute elsewhere) with a platform test seam, per-platform unit cases, and the contract wording updated on the seam, the backend README pair, and the error messages. The picker-kind effect also kept a resolved 'dialog' across close, so a backend swapped while the menu was closed could paint the stale entry for one frame on reopen; the close arm now clears the state, pinned by a reopen-under-pending-read race test. --- docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspacePicker.tsx | 16 ++++++--- .../tests/workspace-picker.spec.tsx | 14 ++++++++ .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 34 ++++++++++++++----- .../tests/service.spec.ts | 15 +++++++- packages/host/directory-picker/src/index.ts | 7 ++-- 9 files changed, 74 insertions(+), 22 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3b47e405f0..ce24921fe8 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -481,7 +481,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:120`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:121`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 2e65fc6b72..786c5cf305 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -79,11 +79,17 @@ export function WorkspaceCreateFlow({ // flow open — no cache to go stale across reconnects. const [dialogPicker, setDialogPicker] = useState(false) useEffect(() => { - if (!open) return - // Reset before each read: a reconnect can change the composed backend, so - // a previous open's answer must not leak into this one; and a settlement - // from a superseded open (flow closed, or a newer read started) is - // discarded via the cleanup-toggled flag. + if (!open) { + // Close discards the answer: a reconnect or HMR can swap the composed + // backend while the menu is closed, and the reopened menu must never + // paint the previous host's entry before the fresh read lands. + setDialogPicker(false) + return + } + // Reset before each read: the injected reader can also change identity + // while the flow stays open, and that prior answer must not leak either; + // a settlement from a superseded read is discarded via the + // cleanup-toggled flag. setDialogPicker(false) let stale = false void directoryPickerKind() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 9ca5304fa4..d8b3ab5a54 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -300,6 +300,20 @@ describe('WorkspacePicker', () => { expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) + it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => { + const directoryPickerKind = vi.fn<() => Promise>() + .mockImplementationOnce(async () => 'dialog') + // The reopened read never settles: the assertion below sees the paint + // that precedes any fresh answer. + .mockImplementation(() => new Promise(() => {})) + const t = togglable(directoryPickerKind) + await screen.findByRole('menuitem', { name: 'Open local folder…' }) + t.setOpen(false) + t.setOpen(true) + await screen.findByRole('menuitem', { name: 'Create a new workspace' }) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) + it('discards a stale describe failure after a newer open already answered', async () => { let rejectFirst!: (reason: Error) => void const first = new Promise((_settle, reject) => { rejectFirst = reject }) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 916db3c321..4dfc363903 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 81357269e1d4b075f7e31b5f3ac5d4721811024a -README.zh.md: 06a7f7651b2abc0aa73eba41042f3d1a86661b76 +README.md: 160a12a8594400c9e6c565881d8e9ca0517b4e24 +README.zh.md: 4cfc4a611fb17cb30ac841c8af8498b51a6388b0 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 81357269e1..160a12a859 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. -Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject a non-absolute explicit path (`directory-unreadable`/`directory-create-failed`) instead of letting `resolve` rebase it under the host process cwd. 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). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 06a7f7651b..4cfc4a611f 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非绝对的显式路径(`directory-unreadable`/`directory-create-failed`),而不是任由 `resolve` 把它重定位到宿主进程 cwd 之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 83a3f6762e..d3566e9415 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -11,7 +11,7 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' -import { basename, dirname, isAbsolute, join, resolve } from 'node:path' +import { basename, dirname, join, posix, resolve, win32 } from 'node:path' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' @@ -35,6 +35,22 @@ function ancestryCrumbs(target: string): DirectoryEntry[] { } } +/** + * True when the path names one fixed filesystem location regardless of + * process state: POSIX-absolute on POSIX; on Windows only drive-qualified + * (`C:\…`) or UNC (`\\server\…`) forms — rooted drive-less forms (`\foo`, + * `/foo`) pass `isAbsolute` yet still resolve against the process's current + * drive. + * @param path - candidate path. + * @param platform - replaces `process.platform` for deterministic tests. + * @returns whether the path is fully qualified on the platform. + */ +export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean { + return platform === 'win32' + ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path) + : posix.isAbsolute(path) +} + /** 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. */ @@ -81,10 +97,11 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private async list(path?: string): Promise { const home = homedir() - // The seam contract takes absolute paths only; resolve() would silently - // rebase a relative or empty wire value under the host process cwd. - if (path !== undefined && !isAbsolute(path)) { - throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not an absolute path`) + // The seam contract takes fully qualified paths only; resolve() would + // silently rebase a relative or empty wire value under the host process + // cwd (or, for rooted drive-less Windows forms, its current drive). + if (path !== undefined && !fullyQualified(path)) { + throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`) } const target = resolve(path ?? home) let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] @@ -105,9 +122,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async createDirectory(path: string, name: string): Promise { - // Same absolute-path fence as list: never rebase a parent under the cwd. - if (!isAbsolute(path)) { - throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not an absolute parent path`) + // Same fully-qualified fence as list: never rebase a parent under the + // cwd or the current drive. + if (!fullyQualified(path)) { + throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`) } const parent = resolve(path) // The backend owns segment validation (the wire schema also refuses these, diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 608fe89348..4cf16107f8 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -7,7 +7,7 @@ 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' +import BrowseDirectoryPicker, { fullyQualified } from '../src/index.ts' let root: string let capability: DirectoryPickerBrowseCapability @@ -70,6 +70,19 @@ describe('BrowseDirectoryPicker', () => { expect((failure as DirectoryPickerError).path).toBe(missing) }) + it('classifies fully qualified paths per platform (drive-less rooted Windows forms rejected)', () => { + expect(fullyQualified('/home/x', 'linux')).toBe(true) + expect(fullyQualified('x/y', 'darwin')).toBe(false) + expect(fullyQualified('C:\\projects', 'win32')).toBe(true) + expect(fullyQualified('C:/projects', 'win32')).toBe(true) + expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true) + // Rooted but drive-less: isAbsolute accepts these, yet resolve() would + // inject the process's current drive. + expect(fullyQualified('\\foo', 'win32')).toBe(false) + expect(fullyQualified('/foo', 'win32')).toBe(false) + expect(fullyQualified('C:relative', 'win32')).toBe(false) + }) + it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => { for (const relative of ['', 'projects', './projects', '..']) { const listFailure = await capability.list(relative).catch((error: unknown) => error) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 1c4f5e32fb..77a63bb568 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -60,8 +60,9 @@ export interface DirectoryPickerBrowseCapability { * 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 is not absolute - * (a wire value must never rebase under the host cwd) or cannot be listed. + * @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully + * qualified (a wire value must never resolve against the host cwd or, on + * Windows, its current drive) or cannot be listed. */ list(path?: string): Promise /** @@ -70,7 +71,7 @@ export interface DirectoryPickerBrowseCapability { * @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` for a non-absolute parent or any other failure. + * `directory-create-failed` for a parent that is not fully qualified or any other failure. */ createDirectory(path: string, name: string): Promise } From 987ecc2ec2ab51737a57b8f49d9f233dc0d1b69b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 18:27:26 +0800 Subject: [PATCH 07/26] fix(host): require complete UNC forms in the fully-qualified path check ds-review-bot round 5: '\\' and '\\server' satisfy win32.isAbsolute and the previous two-separator test, yet resolve() collapses them to drive-relative roots. The UNC arm now requires server and share components; incomplete prefixes reject with the business codes, covered per-platform. --- packages/host/directory-picker-browse/README.i18n.yaml | 4 ++-- packages/host/directory-picker-browse/README.md | 2 +- packages/host/directory-picker-browse/README.zh.md | 2 +- packages/host/directory-picker-browse/src/index.ts | 8 ++++---- .../host/directory-picker-browse/tests/service.spec.ts | 5 +++++ 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4dfc363903..e41ef9a0ae 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 160a12a8594400c9e6c565881d8e9ca0517b4e24 -README.zh.md: 4cfc4a611fb17cb30ac841c8af8498b51a6388b0 +README.md: 9655fdb538da1addb4d10dbee6210965400394c8 +README.zh.md: 1c7b0c36cbb73286f3d4e6a48748c4415f9ff0e4 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 160a12a859..9655fdb538 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. -Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 4cfc4a611f..1c7b0c36cb 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index d3566e9415..561e168421 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -38,16 +38,16 @@ function ancestryCrumbs(target: string): DirectoryEntry[] { /** * True when the path names one fixed filesystem location regardless of * process state: POSIX-absolute on POSIX; on Windows only drive-qualified - * (`C:\…`) or UNC (`\\server\…`) forms — rooted drive-less forms (`\foo`, - * `/foo`) pass `isAbsolute` yet still resolve against the process's current - * drive. + * (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less + * forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) + * pass `isAbsolute` yet still resolve against the process's current drive. * @param path - candidate path. * @param platform - replaces `process.platform` for deterministic tests. * @returns whether the path is fully qualified on the platform. */ export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean { return platform === 'win32' - ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2})/.test(path) + ? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path) : posix.isAbsolute(path) } diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 4cf16107f8..3f214760b7 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -76,11 +76,16 @@ describe('BrowseDirectoryPicker', () => { expect(fullyQualified('C:\\projects', 'win32')).toBe(true) expect(fullyQualified('C:/projects', 'win32')).toBe(true) expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true) + expect(fullyQualified('//server/share/deep', 'win32')).toBe(true) // Rooted but drive-less: isAbsolute accepts these, yet resolve() would // inject the process's current drive. expect(fullyQualified('\\foo', 'win32')).toBe(false) expect(fullyQualified('/foo', 'win32')).toBe(false) expect(fullyQualified('C:relative', 'win32')).toBe(false) + // Incomplete UNC prefixes collapse to drive-relative roots under resolve(). + expect(fullyQualified('\\\\', 'win32')).toBe(false) + expect(fullyQualified('\\\\server', 'win32')).toBe(false) + expect(fullyQualified('\\\\server\\', 'win32')).toBe(false) }) it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => { From 5579b13503af657bd59c174e9392936ae3b3fa8e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 21:07:28 +0800 Subject: [PATCH 08/26] refactor(host): rename the directory-picker dialog backend and kind to native The browse interaction also presents a dialog (the in-app modal), so 'dialog' failed to discriminate the two capability kinds; 'native' names where the chooser runs. Package directory-picker-dialog -> directory-picker-native, kind 'dialog' -> 'native', with every seam/gateway/client/doc reference updated and the seam Agent Note's naming rationale rewritten to match. --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 10 +++---- ...-28-directory-picker-capability-seam.zh.md | 8 +++--- apps/cli/cordis.yml | 2 +- apps/cli/package.json | 2 +- docs/capability-seams.md | 6 ++--- docs/config-catalog.md | 2 +- docs/module-graph.md | 6 ++--- packages/client/connection/tests/fake-api.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 4 +-- packages/client/runtime/tests/fake-api.ts | 2 +- packages/client/ui-workspace/README.i18n.yaml | 4 +-- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../src/client/WorkspacePicker.tsx | 14 +++++----- .../client/ui-workspace/tests/apply.spec.ts | 2 +- .../tests/workspace-browser.spec.tsx | 2 +- .../tests/workspace-picker.spec.tsx | 16 ++++++------ .../cordis/tool-cordis/src/api-catalog.ts | 6 ++--- packages/host/README.i18n.yaml | 4 +-- packages/host/README.md | 4 +-- packages/host/README.zh.md | 4 +-- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 4 +-- packages/host/apiproxy/README.zh.md | 4 +-- packages/host/apiproxy/src/api-proxy.ts | 4 +-- packages/host/apiproxy/src/api/host.ts | 6 ++--- .../tests/api-proxy-workspace.spec.ts | 26 +++++++++---------- .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 2 +- .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-dialog/README.md | 17 ------------ .../README.i18n.yaml | 6 ++--- .../host/directory-picker-native/README.md | 17 ++++++++++++ .../README.zh.md | 4 +-- .../package.json | 4 +-- .../src/index.ts | 20 +++++++------- .../src/invariant.ts | 12 ++++----- .../src/native-picker.ts | 0 .../tests/native-picker.spec.ts | 0 .../tests/service.spec.ts | 14 +++++----- .../tsconfig.json | 0 .../host/directory-picker/README.i18n.yaml | 4 +-- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 10 +++---- .../host/directory-picker/tests/seam.spec.ts | 4 +-- pnpm-lock.yaml | 6 ++--- scripts/gen-doc-graphs.ts | 4 +-- .../verify-package-readme-model-experience.ts | 2 +- tsconfig.host.json | 2 +- 53 files changed, 149 insertions(+), 149 deletions(-) delete mode 100644 packages/host/directory-picker-dialog/README.md rename packages/host/{directory-picker-dialog => directory-picker-native}/README.i18n.yaml (68%) create mode 100644 packages/host/directory-picker-native/README.md rename packages/host/{directory-picker-dialog => directory-picker-native}/README.zh.md (90%) rename packages/host/{directory-picker-dialog => directory-picker-native}/package.json (83%) rename packages/host/{directory-picker-dialog => directory-picker-native}/src/index.ts (64%) rename packages/host/{directory-picker-dialog => directory-picker-native}/src/invariant.ts (63%) rename packages/host/{directory-picker-dialog => directory-picker-native}/src/native-picker.ts (100%) rename packages/host/{directory-picker-dialog => directory-picker-native}/tests/native-picker.spec.ts (100%) rename packages/host/{directory-picker-dialog => directory-picker-native}/tests/service.spec.ts (57%) rename packages/host/{directory-picker-dialog => directory-picker-native}/tsconfig.json (100%) 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 index c13d5e7a1c..1c7ea3556f 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: a30675f2d84b6ae68b95ab96df9e32106d6fbf5d -2026-07-28-directory-picker-capability-seam.zh.md: 5560aba07424d7307e386e2e8ae6b724486d6068 +2026-07-28-directory-picker-capability-seam.md: 78ae05e0da67bff791c0b4f315451aa02e1fa6f4 +2026-07-28-directory-picker-capability-seam.zh.md: bd527e2a1dba6934300a50877d4777f7f9fa24b1 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 index a30675f2d8..78ae05e0da 100644 --- 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 @@ -10,7 +10,7 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p ## Decision -A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-dialog`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'dialog', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, advertises the kind through `host.describe.directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind; the client branches on the advertised kind and hides the affordance for unknown kinds (merge-extensible default). Composition (`cordis.yml`) is the swap point; the union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. +A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, advertises the kind through `host.describe.directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind; the client branches on the advertised kind and hides the affordance for unknown kinds (merge-extensible default). Composition (`cordis.yml`) is the swap point; the union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. Placement and policy rulings folded into this decision: @@ -19,18 +19,18 @@ Placement and policy rulings folded into this decision: - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. -- **The dialog backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide `dialog` natively). The backend names changed from mechanism (`native`/`local` — both run locally) to interaction (`-dialog`/`-browse`). +- **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. ## Alternatives considered - **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. -- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the dialog cannot implement primitives. The interaction difference is irreducible, hence the discriminant. +- **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. - **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-dialog` (unchanged behavior). The GUI already gates its dialog affordance on `describe.directoryPicker` (non-`dialog` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI. +- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The GUI already gates its picking affordance on `describe.directoryPicker` (non-`native` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI. - The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests. -- A future interaction (or an Electron `dialog` provider) is one backend package plus a client branch — no gateway surgery. +- A future interaction (or an Electron provider of the `native` interaction) is one backend package plus a client branch — no gateway surgery. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. 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 index 5560aba074..bd527e2a1d 100644 --- 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 @@ -10,7 +10,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 决策 -在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-dialog`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'dialog', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,经 `host.describe.directoryPicker` 广播 kind,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答;客户端按广播的 kind 分支,未知 kind 隐藏入口(可合并扩展的默认分支)。组合(`cordis.yml`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,经 `host.describe.directoryPicker` 广播 kind,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答;客户端按广播的 kind 分支,未知 kind 隐藏入口(可合并扩展的默认分支)。组合(`cordis.yml`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 并入本决策的位置与策略裁决: @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 -- **dialog 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以原生提供 `dialog`)。后端命名从机制(`native`/`local`——两者都在本机运行)改为交互(`-dialog`/`-browse`)。 +- **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 ## 曾考虑的替代方案 @@ -30,7 +30,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-dialog`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其对话框入口(非 `dialog` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。 +- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其选目录入口(非 `native` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。 - 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 -- 未来的新交互(或 Electron 的 `dialog` 提供方)只是一个后端包加一个客户端分支——无需网关手术。 +- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个后端包加一个客户端分支——无需网关手术。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 1ca28ecdaa..47ab16da51 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -236,7 +236,7 @@ # Directory-picking backend consumed by the gateway's host.* picker RPCs. # Swap point: mount '-browse' instead for the in-app browser (remote-capable). - id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-dialog' + name: '@deepseek-ai/dsh-host-directory-picker-native' - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' diff --git a/apps/cli/package.json b/apps/cli/package.json index 90b51e0397..9244cac610 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -48,7 +48,7 @@ "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-dialog": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 5cedd97dba..fb523a0c70 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -138,7 +138,7 @@ flowchart LR 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_native["directory-picker-native"] pkg_directory_picker_browse["directory-picker-browse"] pkg_webserver["webserver"] svc_httpServer["ctx.httpServer
HTTP route registration"] @@ -165,7 +165,7 @@ flowchart LR pkg_compact_tool_result_prune --> svc_toolResultPrune pkg_directory_picker --> svc_directoryPicker pkg_directory_picker_browse --> svc_directoryPicker - pkg_directory_picker_dialog --> svc_directoryPicker + pkg_directory_picker_native --> svc_directoryPicker pkg_fs --> svc_fs pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs @@ -356,7 +356,7 @@ flowchart LR | `ctx.tasks` | `seam` | [`tasks`](../packages/tasks/tasks) | [`tasks-local`](../packages/tasks/tasks-local) | [`tool-bash`](../packages/bash/tool-bash), [`tool-pty`](../packages/pty/tool-pty), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it; tasks-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | -| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-dialog`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the dialog backend opens one native OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe. | +| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe. | | `ctx.httpServer` | `core` | `webserver` | - | `connection`, `modules`, `hmr` | - | Plain node:http carrier: named-route registry, index transform taps, and the static dist fallback; web-transport plugins register their own routes. | | `ctx.clientModuleHost` | `core` | `modules` | - | `hmr` | - | Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers. | | `ctx.workflows` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context (bash shape, no named-provider registry); the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0697f56dfe..3344bb8aac 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2187,7 +2187,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts)) - `@deepseek-ai/dsh-host-directory-picker-browse` ([`packages/host/directory-picker-browse/src/index.ts`](../packages/host/directory-picker-browse/src/index.ts)) -- `@deepseek-ai/dsh-host-directory-picker-dialog` ([`packages/host/directory-picker-dialog/src/index.ts`](../packages/host/directory-picker-dialog/src/index.ts)) +- `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index 03f4bee072..40ab39a8a2 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -184,7 +184,7 @@ flowchart TD pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_browse["host-directory-picker-browse"] - pkg_host_directory_picker_dialog["host-directory-picker-dialog"] + pkg_host_directory_picker_native["host-directory-picker-native"] pkg_host_webserver["host-webserver"] end subgraph group_lsp["packages/lsp"] @@ -262,7 +262,7 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_dialog --> pkg_invariants + pkg_host_directory_picker_native --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants @@ -932,7 +932,7 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-dialog`](../packages/host/directory-picker-dialog) | `host` | [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 06e62cf7a4..c268c73a90 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -62,7 +62,7 @@ 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> = + 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 })) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 14ecace381..95a0e1c897 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -180,7 +180,7 @@ export class WorkspacesService { } /** - * Open the Host's native directory picker (the `dialog` capability). + * Open the Host's native directory picker (the `native` capability). * @returns the selected path, or null when the user cancelled. */ async pickDirectory(): Promise { @@ -193,7 +193,7 @@ export class WorkspacesService { /** * The directory-picking interaction the Host composed — the fact the picker - * UI branches on (`dialog` opens the native chooser; `browse` opens the + * UI branches on (`native` opens the native chooser; `browse` opens the * in-app browser). Read per flow open: one describe round trip, no cache to * go stale across reconnects. * @returns the Host's advertised picker kind. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 98a222d9ae..760f0f88a1 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -80,7 +80,7 @@ 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> = + 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 })) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 35cd3c3702..7e0cd9380e 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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/client/ui-workspace/README.md -README.md: 58aaf56e1953f00417492d766d8f4ae4a0081c81 -README.zh.md: 7c7b33e0ea68fefee8f857cb5e67a36ec5a854f5 +README.md: deaa25184f5ddbfc5980033ce60cef43577ff33c +README.zh.md: e14e8ca6a2e3d65ce5fc403291e45ebc03598e04 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 58aaf56e19..deaa25184f 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `dialog` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `native` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 7c7b33e0ea..e14e8ca6a2 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `dialog` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `native` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 786c5cf305..7acb958845 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -72,31 +72,31 @@ export function WorkspaceCreateFlow({ const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) - // The advertised interaction gates the picking affordance: 'dialog' is the + // The advertised interaction gates the picking affordance: 'native' is the // only kind pickDirectory() can serve, so its entry renders under that kind // alone; 'browse' (until the in-app browser UI lands) and unknown kinds // hide the entry, the seam's documented unknown-kind default. Re-read per // flow open — no cache to go stale across reconnects. - const [dialogPicker, setDialogPicker] = useState(false) + const [nativePicker, setNativePicker] = useState(false) useEffect(() => { if (!open) { // Close discards the answer: a reconnect or HMR can swap the composed // backend while the menu is closed, and the reopened menu must never // paint the previous host's entry before the fresh read lands. - setDialogPicker(false) + setNativePicker(false) return } // Reset before each read: the injected reader can also change identity // while the flow stays open, and that prior answer must not leak either; // a settlement from a superseded read is discarded via the // cleanup-toggled flag. - setDialogPicker(false) + setNativePicker(false) let stale = false void directoryPickerKind() - .then((kind) => { if (!stale) setDialogPicker(kind === 'dialog') }) + .then((kind) => { if (!stale) setNativePicker(kind === 'native') }) // A failed describe hides the entry too: the same Host that cannot // answer describe cannot serve pickDirectory. - .catch(() => { if (!stale) setDialogPicker(false) }) + .catch(() => { if (!stale) setNativePicker(false) }) return () => { stale = true } }, [open, directoryPickerKind]) @@ -108,7 +108,7 @@ export function WorkspaceCreateFlow({ disabled: pickingFolder, })), ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - ...(dialogPicker + ...(nativePicker ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }] : []), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index f9a5884206..e670a05e9b 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -15,7 +15,7 @@ async function bench() { title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) const pickDirectory = vi.fn(async () => '/tmp/picked') - const directoryPickerKind = vi.fn(async () => 'dialog' as const) + const directoryPickerKind = vi.fn(async () => 'native' as const) const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 779ad0b37a..6079416d30 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -60,7 +60,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), pickDirectory: vi.fn(async () => null), - directoryPickerKind: vi.fn(async () => 'dialog' as const), + directoryPickerKind: vi.fn(async () => 'native' as const), ...overrides, } const view = render() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index d8b3ab5a54..fadf1cdebf 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -39,7 +39,7 @@ function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), pickDirectory = vi.fn(async () => null as string | null), - directoryPickerKind = vi.fn(async () => 'dialog'), + directoryPickerKind = vi.fn(async () => 'native'), ) { const onPick = vi.fn() const onClose = vi.fn() @@ -221,7 +221,7 @@ describe('WorkspacePicker', () => { 'dialog')} + directoryPickerKind={vi.fn(async () => 'native')} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -235,7 +235,7 @@ describe('WorkspacePicker', () => { 'dialog')} + directoryPickerKind={vi.fn(async () => 'native')} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') @@ -258,7 +258,7 @@ describe('WorkspacePicker', () => { }) it('does not read the picker kind while the flow is closed', () => { - const directoryPickerKind = vi.fn(async () => 'dialog') + const directoryPickerKind = vi.fn(async () => 'native') render( { .mockImplementationOnce(() => first) .mockImplementation(async () => 'browse') const t = togglable(directoryPickerKind) - // Close while the first read is in flight, then let it answer 'dialog': + // Close while the first read is in flight, then let it answer 'native': // the settlement is stale and must not leak into the next open. t.setOpen(false) - await act(async () => { resolveFirst('dialog') }) + await act(async () => { resolveFirst('native') }) t.setOpen(true) await screen.findByRole('menuitem', { name: 'Create a new workspace' }) await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) }) @@ -302,7 +302,7 @@ describe('WorkspacePicker', () => { it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => { const directoryPickerKind = vi.fn<() => Promise>() - .mockImplementationOnce(async () => 'dialog') + .mockImplementationOnce(async () => 'native') // The reopened read never settles: the assertion below sees the paint // that precedes any fresh answer. .mockImplementation(() => new Promise(() => {})) @@ -319,7 +319,7 @@ describe('WorkspacePicker', () => { const first = new Promise((_settle, reject) => { rejectFirst = reject }) const directoryPickerKind = vi.fn<() => Promise>() .mockImplementationOnce(() => first) - .mockImplementation(async () => 'dialog') + .mockImplementation(async () => 'native') const t = togglable(directoryPickerKind) t.setOpen(false) t.setOpen(true) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 698dd32201..148727968c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1605,15 +1605,15 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DirectoryPickerCapabilities', - declaration: 'export interface DirectoryPickerCapabilities {\n dialog: DirectoryPickerDialogCapability;\n browse: DirectoryPickerBrowseCapability;\n}', + declaration: 'export interface DirectoryPickerCapabilities {\n native: DirectoryPickerNativeCapability;\n browse: DirectoryPickerBrowseCapability;\n}', }, { name: 'DirectoryPickerCapability', declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];', }, { - name: 'DirectoryPickerDialogCapability', - declaration: 'export interface DirectoryPickerDialogCapability {\n kind: \'dialog\';\n pick(signal: AbortSignal): Promise;\n}', + name: 'DirectoryPickerNativeCapability', + declaration: 'export interface DirectoryPickerNativeCapability {\n kind: \'native\';\n pick(signal: AbortSignal): Promise;\n}', }, { name: 'Domain', diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 9421dce1c5..f50948eb6d 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: 81c483674c0d30847318b8fd9014bd8bb7d341c2 -README.zh.md: 8b5ecd89ff4b1407cfa8c2bad63c77412b5fd16c +README.md: 0810be58fc773a241528656d7f6e826e9c3aabda +README.zh.md: f9133eee8498594d913b2fe0814ac51d712b678d diff --git a/packages/host/README.md b/packages/host/README.md index 81c483674c..0810be58fc 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -8,8 +8,8 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and |---|---|---| | `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` | | `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` | -| `directory-picker/` | Workspace-directory picking seam: discriminated `dialog`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | -| `directory-picker-dialog/` | Native-OS-chooser backend (osascript / PowerShell / Zenity+KDialog); host-display only | (registers `ctx.directoryPicker`) | +| `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | +| `directory-picker-native/` | Native-OS-chooser backend (osascript / PowerShell / Zenity+KDialog); host-display only | (registers `ctx.directoryPicker`) | | `directory-picker-browse/` | In-app browsing backend: listing/creation primitives over Node stdlib; remote-capable | (registers `ctx.directoryPicker`) | `apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire. diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 8b5ecd89ff..f9133eee84 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -8,8 +8,8 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承 |---|---|---| | `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`)、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents`/`ctx.workspace` 的宿主实现 | `ctx.apiProxy` | | `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` | -| `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `dialog`/`browse` 能力 | `ctx.directoryPicker` | -| `directory-picker-dialog/` | 原生 OS 选择器后端(osascript/PowerShell/Zenity+KDialog);仅宿主屏幕可用 | (注册 `ctx.directoryPicker`) | +| `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` | +| `directory-picker-native/` | 原生 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 55fe86c794..7bc4e88049 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: 7c53e6dc9ac5758fce91d8b39abbfab6641384d1 -README.zh.md: 5089935fe545bfc6ac3ddb39b9a2a25b50e1ceab +README.md: 8db8a1b77913677d88dbbbfbdbfc28a90c0d0415 +README.zh.md: 1f957223bb98afb65ab8a313c49b58c701e42be0 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 7c53e6dc9a..8db8a1b779 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()`. -Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); `host.describe.directoryPicker` advertises the capability kind the client renders for, and a method called outside the advertised kind fails with `directory-picker-unavailable`. Under `dialog`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. +Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); `host.describe.directoryPicker` advertises the capability kind the client renders for, and a method called outside the advertised kind fails with `directory-picker-unavailable`. Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. `session.history` pages on message boundaries, and its tail page (no `beforeSeq`) carries two session-level extras the page window cannot supply: the in-flight partial's chunk events, and `todos` — the latest `todo/write` whole-list projection over the full log. Older pages omit `todos` because the projection is session-level, not per-page; a tail response that omits it means the whole log holds no `todo/write`, so clients read the absent field as the empty plan rather than as unchanged state. @@ -39,4 +39,4 @@ None; this package neither assembles nor sends a provider request. - **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals). - **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. -- **Linux native picker requires desktop tooling** — under the `dialog` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [dialog backend README](../directory-picker-dialog/README.md)). +- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)). diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 5089935fe5..1f957223bb 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()` 中。 -目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));`host.describe.directoryPicker` 广播客户端应按其渲染的能力 kind,调用广播之外的方法会以 `directory-picker-unavailable` 失败。在 `dialog` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 +目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));`host.describe.directoryPicker` 广播客户端应按其渲染的能力 kind,调用广播之外的方法会以 `directory-picker-unavailable` 失败。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 `session.history` 按消息边界分页,其尾页(不带 `beforeSeq`)额外携带两项页窗口本身无法提供的会话级数据:进行中局部消息的 chunk 事件,以及 `todos`——整份日志上最后一次 `todo/write` 的整表投影。较早的页面不带 `todos`,因为该投影是会话级而非分页级的;尾页响应缺少该字段意味着整份日志中没有任何 `todo/write`,因此客户端要把缺失字段读作空计划,而不是读作「状态未变」。 @@ -39,4 +39,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr - **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**:协议形状(POST `/api/respond`、`RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。 - **预留 seam 不进入 `RpcMethodMap`**:`session.fork`、`prompt.mode: 'inject'`、`task.list`、`host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 -- **Linux 原生选择器依赖桌面工具**:在 `dialog` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [dialog 后端 README](../directory-picker-dialog/README.md))。 +- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 6e302e3e57..29874f10f8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1002,10 +1002,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async pickDirectory(request, signal) { const capability = ctx.directoryPicker.capability() - if (capability.kind !== 'dialog') { + if (capability.kind !== 'native') { return err(request, { code: 'directory-picker-unavailable', - message: `host.pickDirectory needs the dialog capability; the composed picker serves "${capability.kind}"`, + message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`, details: { capability: capability.kind }, }) } diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 3de58b7bc7..872c3ae3c3 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -7,7 +7,7 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' /** * The composed directory-picker interaction the host serves (mirror of the - * `ctx.directoryPicker` capability kind): `dialog` = one native OS chooser on + * `ctx.directoryPicker` capability kind): `native` = one OS chooser on * the host display (`host.pickDirectory`); `browse` = in-app listing/creation * primitives (`host.listDirectory`/`host.createDirectory`). Calling a method * outside the advertised kind fails with `directory-picker-unavailable`. @@ -15,7 +15,7 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' * capability advertises before its RPCs exist); the client's documented * default for a kind it does not recognize is to hide the picking affordance. */ -export type DirectoryPickerKind = 'dialog' | 'browse' | (string & {}) +export type DirectoryPickerKind = 'native' | 'browse' | (string & {}) /** One directory row of a listing: a child entry or a breadcrumb ancestor. */ export interface DirectoryEntry { @@ -64,7 +64,7 @@ export interface HostApi { /** * Open the operating system's single-directory picker; cancellation returns - * null. Only served under the `dialog` capability. + * null. Only served under the `native` capability. */ pickDirectory( request: RpcRequest<{}>, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 9572c76b23..653c0b2398 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), - picker: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null }, + picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -107,19 +107,19 @@ async function harness( } describe('host.pickDirectory', () => { - it('returns a selected path or explicit cancellation from the dialog capability', async () => { - const selected = await harness(undefined, { kind: 'dialog', pick: async () => '/tmp/project' }) + it('returns a selected path or explicit cancellation from the native capability', async () => { + const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' }) expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: '/tmp/project' } }) - const cancelled = await harness(undefined, { kind: 'dialog', pick: async () => null }) + const cancelled = await harness(undefined, { kind: 'native', pick: async () => null }) expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result) .toEqual({ ok: true, value: { path: null } }) }) - it('propagates abort into the dialog capability as a cancelled RPC error', async () => { + it('propagates abort into the native capability as a cancelled RPC error', async () => { const { api } = await harness(undefined, { - kind: 'dialog', + kind: 'native', pick: signal => new Promise((_resolve, reject) => { signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }), @@ -130,13 +130,13 @@ describe('host.pickDirectory', () => { expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) - it('folds a non-abort dialog failure into an internal error', async () => { - const { api } = await harness(undefined, { kind: 'dialog', pick: async () => { throw new Error('no chooser installed') } }) + it('folds a non-abort native-chooser failure into an internal error', async () => { + const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } }) const response = await api.host.pickDirectory(request({}), new AbortController().signal) expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } }) }) - it('refuses the dialog RPC under a browse composition', async () => { + it('refuses the native RPC under a browse composition', async () => { const { api } = await harness(undefined, BROWSE_STUB) const response = await api.host.pickDirectory(request({}), new AbortController().signal) expect(response.result).toMatchObject({ @@ -190,14 +190,14 @@ describe('host.listDirectory / host.createDirectory', () => { }) }) - it('refuses the browse RPCs under a dialog composition and advertises the kind in describe', async () => { + it('refuses the browse RPCs under a native composition and advertises the kind in describe', async () => { const { api } = await harness() - expect((await api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'dialog' } }) + expect((await api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'native' } }) expect((await api.host.listDirectory(request({}))).result).toMatchObject({ - ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } }, + ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ - ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } }, + ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) const browse = await harness(undefined, BROWSE_STUB) expect((await browse.api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'browse' } }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 6340e4e2e7..4211c5cb9c 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -75,7 +75,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, host: { async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, directoryPicker: 'dialog' as const } } } + return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, directoryPicker: 'native' as const } } } }, async pickDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 939de7677e..2923b4d739 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -208,7 +208,7 @@ describe('sessions domain schemas', () => { describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' }) + const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'native' }) expect(value.attachedSessions).toBe(2) expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined() // A kind beyond the two with methods survives the wire (merge-added diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index e41ef9a0ae..34c3e35a79 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 9655fdb538da1addb4d10dbee6210965400394c8 -README.zh.md: 1c7b0c36cbb73286f3d4e6a48748c4415f9ff0e4 +README.md: 688a60894cb0ab066a6d501e4df310f8d31bfb5f +README.zh.md: c19bccc2ff9268cb7a6c931da4671bebc9f76b0c diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 9655fdb538..688a60894c 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the dialog backend cannot. +The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 1c7b0c36cb..c19bccc2ff 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 dialog 后端无法触及的远程客户端。 +[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 diff --git a/packages/host/directory-picker-dialog/README.md b/packages/host/directory-picker-dialog/README.md deleted file mode 100644 index fe07303557..0000000000 --- a/packages/host/directory-picker-dialog/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# @deepseek-ai/dsh-host-directory-picker-dialog - -English | [中文](README.zh.md) - -The **native-OS-dialog backend** of the [directory-picker seam](../directory-picker/README.md): `DialogDirectoryPicker` registers `ctx.directoryPicker` with the `dialog` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. - -## Model Experience - -None, as the backend serves the GUI host's directory selection; nothing here reaches a model request. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - -- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). diff --git a/packages/host/directory-picker-dialog/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml similarity index 68% rename from packages/host/directory-picker-dialog/README.i18n.yaml rename to packages/host/directory-picker-native/README.i18n.yaml index 3cd3d36702..c67ecdce91 100644 --- a/packages/host/directory-picker-dialog/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/host/directory-picker-dialog/README.md -README.md: fe07303557da6b27bb89eb761efba5555c4308c0 -README.zh.md: 214259264d5385ddad1ea6425149c53e31de55d0 +# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md +README.md: 8e9d6c7c558a6b33c2a37d504c571fd3eda579bb +README.zh.md: 68f23698ff0c1a5ee4456a1f121dcf244f09c72b diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md new file mode 100644 index 0000000000..8e9d6c7c55 --- /dev/null +++ b/packages/host/directory-picker-native/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-host-directory-picker-native + +English | [中文](README.zh.md) + +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. + +## Model Experience + +None, as the backend serves the GUI host's directory selection; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). diff --git a/packages/host/directory-picker-dialog/README.zh.md b/packages/host/directory-picker-native/README.zh.md similarity index 90% rename from packages/host/directory-picker-dialog/README.zh.md rename to packages/host/directory-picker-native/README.zh.md index 214259264d..68f23698ff 100644 --- a/packages/host/directory-picker-dialog/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -1,8 +1,8 @@ -# @deepseek-ai/dsh-host-directory-picker-dialog +# @deepseek-ai/dsh-host-directory-picker-native [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 对话框后端**:`DialogDirectoryPicker` 以 `dialog` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。 ## 模型体验 diff --git a/packages/host/directory-picker-dialog/package.json b/packages/host/directory-picker-native/package.json similarity index 83% rename from packages/host/directory-picker-dialog/package.json rename to packages/host/directory-picker-native/package.json index 94b22e6feb..8db5d3a9df 100644 --- a/packages/host/directory-picker-dialog/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,6 +1,6 @@ { - "name": "@deepseek-ai/dsh-host-directory-picker-dialog", - "description": "Native-OS-dialog backend of the directory-picker seam for the DeepSeek Harness web GUI host", + "name": "@deepseek-ai/dsh-host-directory-picker-native", + "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/host/directory-picker-dialog/src/index.ts b/packages/host/directory-picker-native/src/index.ts similarity index 64% rename from packages/host/directory-picker-dialog/src/index.ts rename to packages/host/directory-picker-native/src/index.ts index bc9814a5e7..f131c8bb0c 100644 --- a/packages/host/directory-picker-dialog/src/index.ts +++ b/packages/host/directory-picker-native/src/index.ts @@ -1,11 +1,11 @@ /** - * Dialog backend of the directory-picker seam: registers `ctx.directoryPicker` - * with the `dialog` capability, opening one native OS chooser on the host + * Native backend of the directory-picker seam: registers `ctx.directoryPicker` + * with the `native` capability, opening one native OS chooser on the host * display per pick (macOS `osascript`, Windows STA PowerShell * `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable * when the operator sits at the host's screen; remote deployments compose the * browse backend instead. - * @module @deepseek-ai/dsh-host-directory-picker-dialog + * @module @deepseek-ai/dsh-host-directory-picker-native */ import { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' @@ -15,19 +15,19 @@ import { pickNativeDirectory } from './native-picker.ts' export type { DirectoryPickerInternals, DirectoryPickerRunner } from './native-picker.ts' export { pickNativeDirectory } from './native-picker.ts' -/** The `ctx.directoryPicker` dialog implementation (stable capability object per service life). */ -export default class DialogDirectoryPicker extends DirectoryPicker { - private readonly dialogCapability: DirectoryPickerCapability = { - kind: 'dialog', +/** The `ctx.directoryPicker` native implementation (stable capability object per service life). */ +export default class NativeDirectoryPicker extends DirectoryPicker { + private readonly nativeCapability: DirectoryPickerCapability = { + kind: 'native', /* v8 ignore next -- pure forward to pickNativeDirectory (its spec owns behavior); invoking here opens a real chooser. */ pick: signal => pickNativeDirectory(signal), } /** - * The dialog interaction capability. - * @returns the stable `dialog` capability object. + * The native interaction capability. + * @returns the stable `native` capability object. */ capability(): DirectoryPickerCapability { - return this.dialogCapability + return this.nativeCapability } } diff --git a/packages/host/directory-picker-dialog/src/invariant.ts b/packages/host/directory-picker-native/src/invariant.ts similarity index 63% rename from packages/host/directory-picker-dialog/src/invariant.ts rename to packages/host/directory-picker-native/src/invariant.ts index cbd3e63517..777acd57dd 100644 --- a/packages/host/directory-picker-dialog/src/invariant.ts +++ b/packages/host/directory-picker-native/src/invariant.ts @@ -1,23 +1,23 @@ /** - * Package-owned invariant companion for the dialog directory-picker backend. - * @module @deepseek-ai/dsh-host-directory-picker-dialog/invariant + * Package-owned invariant companion for the native directory-picker backend. + * @module @deepseek-ai/dsh-host-directory-picker-native/invariant */ import type { Context } from 'cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-dialog' +const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native' /** Cordis companion plugin name. */ -export const name = 'host-directory-picker-dialog-invariant' +export const name = 'host-directory-picker-native-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** No runtime invariant: each pick is one stateless subprocess round trip; the dialog outcome is only the returned path. */ +/** No runtime invariant: each pick is one stateless subprocess round trip; the chooser outcome is only the returned path. */ const install: InvariantInstaller = () => {} /** - * Register the dialog directory-picker invariant companion. + * Register the native directory-picker invariant companion. * @param ctx - Cordis context carrying the invariant service. * @returns the installed registration's disposer after setup succeeds. */ diff --git a/packages/host/directory-picker-dialog/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts similarity index 100% rename from packages/host/directory-picker-dialog/src/native-picker.ts rename to packages/host/directory-picker-native/src/native-picker.ts diff --git a/packages/host/directory-picker-dialog/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts similarity index 100% rename from packages/host/directory-picker-dialog/tests/native-picker.spec.ts rename to packages/host/directory-picker-native/tests/native-picker.spec.ts diff --git a/packages/host/directory-picker-dialog/tests/service.spec.ts b/packages/host/directory-picker-native/tests/service.spec.ts similarity index 57% rename from packages/host/directory-picker-dialog/tests/service.spec.ts rename to packages/host/directory-picker-native/tests/service.spec.ts index f56f93ec41..61b5adddaf 100644 --- a/packages/host/directory-picker-dialog/tests/service.spec.ts +++ b/packages/host/directory-picker-native/tests/service.spec.ts @@ -1,18 +1,18 @@ -/** Registration/capability behavior of the dialog backend (the seam's cordis half). */ +/** Registration/capability behavior of the native backend (the seam's cordis half). */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import DialogDirectoryPicker from '../src/index.ts' +import NativeDirectoryPicker from '../src/index.ts' -describe('DialogDirectoryPicker', () => { - it('registers ctx.directoryPicker with a stable dialog capability and leaves with its fiber', async () => { +describe('NativeDirectoryPicker', () => { + it('registers ctx.directoryPicker with a stable native capability and leaves with its fiber', async () => { const ctx = new Context() - const fiber = ctx.plugin(DialogDirectoryPicker) + const fiber = ctx.plugin(NativeDirectoryPicker) await fiber.await() const picker = ctx.get('directoryPicker') - expect(picker).toBeInstanceOf(DialogDirectoryPicker) + expect(picker).toBeInstanceOf(NativeDirectoryPicker) const capability = picker!.capability() - expect(capability.kind).toBe('dialog') + expect(capability.kind).toBe('native') // Stability: consumers may capture the capability object across calls. expect(picker!.capability()).toBe(capability) await fiber.dispose() diff --git a/packages/host/directory-picker-dialog/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json similarity index 100% rename from packages/host/directory-picker-dialog/tsconfig.json rename to packages/host/directory-picker-native/tsconfig.json diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index a331a2caeb..7ba7c09de0 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/directory-picker/README.md -README.md: 0332f7df067bfa79c7505be948554e814a690c6c -README.zh.md: a9a782019d0badfba33a7d108113ff5323fde77a +README.md: dcac8903522d53a8dd5fd346f124071f0f24b38e +README.zh.md: 5b7fc15513bf19722bd71bcbb30c6d187d6a71f5 diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 0332f7df06..dcac890352 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index a9a782019d..5b7fc15513 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 77a63bb568..c96f21d9a6 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -2,7 +2,7 @@ * The `ctx.directoryPicker` seam: how the web-GUI host lets an operator * select a workspace directory. Backends differ in interaction shape, not * just mechanism, so the service exposes a discriminated capability instead - * of one method set: a `dialog` backend opens one native OS chooser on the + * of one method set: a `native` backend opens one OS chooser on the * host's display, while a `browse` backend serves listing/creation primitives * for an in-app browser (and thereby works for remote clients no OS dialog * can reach). Consumers switch on `capability().kind`; the union is @@ -13,9 +13,9 @@ import { Context, Service } from 'cordis' -/** The dialog interaction: one native OS directory chooser on the host display. */ -export interface DirectoryPickerDialogCapability { - kind: 'dialog' +/** The native interaction: one OS directory chooser on the host display. */ +export interface DirectoryPickerNativeCapability { + kind: 'native' /** * Open the chooser and wait for the operator. * @param signal - caller/connection lifetime; abort terminates the chooser. @@ -82,7 +82,7 @@ export interface DirectoryPickerBrowseCapability { * must equal its key) instead of editing this package. */ export interface DirectoryPickerCapabilities { - dialog: DirectoryPickerDialogCapability + native: DirectoryPickerNativeCapability browse: DirectoryPickerBrowseCapability } diff --git a/packages/host/directory-picker/tests/seam.spec.ts b/packages/host/directory-picker/tests/seam.spec.ts index 4e7a799fa4..52722b9b0d 100644 --- a/packages/host/directory-picker/tests/seam.spec.ts +++ b/packages/host/directory-picker/tests/seam.spec.ts @@ -7,7 +7,7 @@ import type { DirectoryPickerCapability } from '../src/index.ts' /** Minimal concrete backend: all a subclass owes the abstract class is capability(). */ class StubPicker extends DirectoryPicker { - private readonly stub: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null } + private readonly stub: DirectoryPickerCapability = { kind: 'native', pick: async () => null } capability(): DirectoryPickerCapability { return this.stub } @@ -19,7 +19,7 @@ describe('DirectoryPicker seam', () => { const fiber = ctx.plugin(StubPicker) await fiber.await() expect(ctx.get('directoryPicker')).toBeInstanceOf(StubPicker) - expect(ctx.get('directoryPicker')!.capability().kind).toBe('dialog') + expect(ctx.get('directoryPicker')!.capability().kind).toBe('native') await fiber.dispose() expect(ctx.get('directoryPicker')).toBeUndefined() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a47a1ea140..317233f440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,9 +212,9 @@ importers: '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-dialog': + '@deepseek-ai/dsh-host-directory-picker-native': specifier: workspace:^ - version: link:../../packages/host/directory-picker-dialog + version: link:../../packages/host/directory-picker-native '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -2727,7 +2727,7 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/host/directory-picker-dialog: + packages/host/directory-picker-native: dependencies: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1348ba425a..6f96c0c5f8 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -413,9 +413,9 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'directory-picker', title: 'Workspace-directory picking seam', mode: 'seam', - implementations: ['directory-picker-dialog', 'directory-picker-browse'], + implementations: ['directory-picker-native', 'directory-picker-browse'], consumers: ['apiproxy'], - note: 'Discriminated interaction capability: the dialog backend opens one native OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe.', + note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe.', }, { key: 'httpServer', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 79100a4c9e..2f621291bc 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -75,7 +75,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' }, 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, - 'packages/host/directory-picker-dialog': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, + 'packages/host/directory-picker-native': { kind: 'none', reason: 'The GUI-host picking backend registers no model surface.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 98a51dda19..eb2609ed4a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -165,7 +165,7 @@ { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker-browse" }, - { "path": "./packages/host/directory-picker-dialog" }, + { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, From 51402ac7af148d25c2ffce7fa807c852622d67c5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 21:25:19 +0800 Subject: [PATCH 09/26] refactor(util): extract the shared no-shell native-command runner to dsh-native-command master's toolcall-open extracted runNativeCommand inside apiproxy for the openPath opener while the picker seam had moved the native chooser (its other consumer) into directory-picker-native; after the merge the two packages each carried a verbatim copy. The runner now lives in packages/util/native-command (zero-dependency library, per the util-group contract) and both native integrations depend on it. --- docs/module-graph.md | 3 ++ packages/host/apiproxy/package.json | 1 + .../host/apiproxy/src/native-path-opener.ts | 2 +- packages/host/apiproxy/tsconfig.json | 3 ++ .../host/directory-picker-native/package.json | 3 +- .../src/native-command.ts | 38 ---------------- .../src/native-picker.ts | 2 +- .../directory-picker-native/tsconfig.json | 3 ++ packages/util/README.i18n.yaml | 6 +-- packages/util/README.md | 1 + packages/util/README.zh.md | 1 + packages/util/native-command/README.i18n.yaml | 6 +++ packages/util/native-command/README.md | 27 ++++++++++++ packages/util/native-command/README.zh.md | 27 ++++++++++++ packages/util/native-command/package.json | 37 ++++++++++++++++ .../native-command/src/index.ts} | 8 +++- packages/util/native-command/src/invariant.ts | 31 +++++++++++++ .../tests/native-command.spec.ts | 43 +++++++++++++++++++ packages/util/native-command/tsconfig.json | 15 +++++++ pnpm-lock.yaml | 15 +++++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 22 files changed, 229 insertions(+), 45 deletions(-) delete mode 100644 packages/host/directory-picker-native/src/native-command.ts create mode 100644 packages/util/native-command/README.i18n.yaml create mode 100644 packages/util/native-command/README.md create mode 100644 packages/util/native-command/README.zh.md create mode 100644 packages/util/native-command/package.json rename packages/{host/apiproxy/src/native-command.ts => util/native-command/src/index.ts} (77%) create mode 100644 packages/util/native-command/src/invariant.ts create mode 100644 packages/util/native-command/tests/native-command.spec.ts create mode 100644 packages/util/native-command/tsconfig.json diff --git a/docs/module-graph.md b/docs/module-graph.md index ddc77f1f21..c11ca86378 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri flowchart TD subgraph group_util["packages/util"] pkg_brand["brand"] + pkg_native_command["native-command"] pkg_paths["paths"] pkg_retention["retention"] pkg_timeout["timeout"] @@ -244,6 +245,7 @@ flowchart TD pkg_workspace["workspace"] end pkg_brand --> pkg_invariants + pkg_native_command --> pkg_invariants pkg_paths --> pkg_invariants pkg_retention --> pkg_invariants pkg_timeout --> pkg_invariants @@ -920,6 +922,7 @@ flowchart TD | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) | +| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) | | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) | | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) | | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index d90c52816d..cc9ed2b493 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -45,6 +45,7 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index 15a6d7a73b..a4fbbaa72e 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,6 +1,6 @@ /** Cross-platform open-with-default-application used by the local GUI carrier. */ -import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ export type PathOpenerRunner = NativeCommandRunner diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index d3ba26038a..f5f5931602 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -55,6 +55,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../util/native-command" } ] } diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 8db5d3a9df..72cb8a3e8a 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -27,7 +27,8 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-host-directory-picker": "workspace:^" + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/host/directory-picker-native/src/native-command.ts b/packages/host/directory-picker-native/src/native-command.ts deleted file mode 100644 index 0efe9679d8..0000000000 --- a/packages/host/directory-picker-native/src/native-command.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** Shared no-shell `execFile` runner for native host dialogs and openers. */ - -import { execFile } from 'node:child_process' - -/** Testable command boundary; native implementations never invoke a shell. */ -export type NativeCommandRunner = ( - command: string, - args: readonly string[], - signal: AbortSignal, -) => Promise<{ stdout: string; stderr: string }> - -/** - * Run a host command with utf8 stdio, abort propagation, and Windows hide. - * @param command - executable path or PATH name. - * @param args - argv (never a shell string). - * @param signal - caller/connection lifetime; abort terminates the child. - * @returns captured stdout/stderr on exit 0. - */ -export const runNativeCommand: NativeCommandRunner = (command, args, signal) => - new Promise((resolve, reject) => { - execFile( - command, - [...args], - { encoding: 'utf8', signal, windowsHide: true }, - (error, stdout, stderr) => { - if (error !== null) { - const failure = Object.assign(new Error(error.message, { cause: error }), { - code: error.code, - stdout, - stderr, - }) - reject(failure) - return - } - resolve({ stdout, stderr }) - }, - ) - }) diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 0ae95dfe74..ee3d3075e5 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -1,6 +1,6 @@ /** Cross-platform native single-directory chooser behind the dialog backend's capability. */ -import { runNativeCommand, type NativeCommandRunner } from './native-command.ts' +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ export type DirectoryPickerRunner = NativeCommandRunner diff --git a/packages/host/directory-picker-native/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json index 99ca673189..64a32c3441 100644 --- a/packages/host/directory-picker-native/tsconfig.json +++ b/packages/host/directory-picker-native/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../util/native-command" } ] } diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 1fc811bc8b..8bd1ff35b2 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: 140df90571d84320fb4eb888508c67e60aa29a22 -README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273 +# pnpm run verify-translation-pairing --write packages/util/README.md +README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0 +README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0 diff --git a/packages/util/README.md b/packages/util/README.md index 140df90571..605c3dd0be 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) | | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability | | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool | +| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller | `dsh-brand` is the canonical case: it owns ONLY the `Branded` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`. diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 4c16df2a56..5c66ded33a 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -10,6 +10,7 @@ | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) | | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 | | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 | +| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 | `dsh-brand` 是规范示例:它只负责 `Branded` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。 diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml new file mode 100644 index 0000000000..7d711e9e28 --- /dev/null +++ b/packages/util/native-command/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/util/native-command/README.md +README.md: 7fc8b1f4640ef87ada62b6656854feb37080e4e6 +README.zh.md: 7c1cacb06d1d58601cc9e63c2ebd9c5f0ccb8159 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md new file mode 100644 index 0000000000..7fc8b1f464 --- /dev/null +++ b/packages/util/native-command/README.md @@ -0,0 +1,27 @@ +# dsh-native-command + +English | [中文](README.zh.md) + +A **zero-dependency no-shell `execFile` runner** shared by host-native OS integrations: one `runNativeCommand(command, args, signal)` call spawns the executable directly (never a shell string), captures utf8 stdout/stderr, propagates the caller's abort into child termination, and hides the transient console window on Windows. Failures reject with the exit `code` and both captured streams attached, so callers classify (missing tool, cancelled, real failure) without re-running anything. + +Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is the injectable command boundary those callers expose for deterministic tests. + +It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events. + +## Surface + +```ts +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +``` + +## Model Experience + +None, as this is host-side subprocess plumbing; 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 output bounding** — both streams buffer unbounded in memory; every current caller invokes small native tools whose output is a path or an error line. Adopt `dsh-retention` bounding before pointing this at commands with meaningful output volume. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md new file mode 100644 index 0000000000..7c1cacb06d --- /dev/null +++ b/packages/util/native-command/README.zh.md @@ -0,0 +1,27 @@ +# dsh-native-command + +[English](README.md) | 中文 + +宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接派生可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时以附带退出 `code` 与两路已捕获输出的错误拒绝,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。 + +它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.zh.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。 + +它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。 + +## Surface + +```ts +import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +``` + +## Model Experience + +无;这是宿主侧子进程管道,这里没有任何东西进入模型请求。 + +#### KV Cache effect + +无;该包既不组装也不发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **不做输出限量**——两路流在内存中无界缓冲;当前每个调用方只运行输出为一个路径或一行错误的小型原生工具。把它指向输出量可观的命令之前,先接入 `dsh-retention` 限量。 diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json new file mode 100644 index 0000000000..a20e61c33d --- /dev/null +++ b/packages/util/native-command/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-native-command", + "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide", + "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/apiproxy/src/native-command.ts b/packages/util/native-command/src/index.ts similarity index 77% rename from packages/host/apiproxy/src/native-command.ts rename to packages/util/native-command/src/index.ts index 0efe9679d8..8ad8b81749 100644 --- a/packages/host/apiproxy/src/native-command.ts +++ b/packages/util/native-command/src/index.ts @@ -1,4 +1,10 @@ -/** Shared no-shell `execFile` runner for native host dialogs and openers. */ +/** + * Shared no-shell `execFile` runner for host-native OS integrations (the + * native directory chooser, the open-with-default-application hand-off): + * utf8 stdio capture, abort propagation, Windows console hide. A library, + * not a plugin — no ctx, no state, no events. + * @module @deepseek-ai/dsh-native-command + */ import { execFile } from 'node:child_process' diff --git a/packages/util/native-command/src/invariant.ts b/packages/util/native-command/src/invariant.ts new file mode 100644 index 0000000000..bec1d4b774 --- /dev/null +++ b/packages/util/native-command/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-native-command`. + * @module @deepseek-ai/dsh-native-command/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-native-command' + +/** Cordis companion plugin name. */ +export const name = 'native-command-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: each run is one stateless child-process round trip + * with no owned event stream or mutable runtime data; behavior is enforced by + * unit tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's 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)) +/* jscpd:ignore-end */ diff --git a/packages/util/native-command/tests/native-command.spec.ts b/packages/util/native-command/tests/native-command.spec.ts new file mode 100644 index 0000000000..4c0adc40f4 --- /dev/null +++ b/packages/util/native-command/tests/native-command.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { runNativeCommand } from '@deepseek-ai/dsh-native-command' + +const node = process.execPath + +describe('runNativeCommand', () => { + it('captures utf8 stdout and stderr on exit 0', async () => { + const result = await runNativeCommand( + node, + ['-e', 'process.stdout.write("out✓"); process.stderr.write("err")'], + new AbortController().signal, + ) + expect(result).toEqual({ stdout: 'out✓', stderr: 'err' }) + }) + + it('rejects a non-zero exit with code, stdout, and stderr attached', async () => { + const failure = await runNativeCommand( + node, + ['-e', 'process.stdout.write("partial"); process.stderr.write("boom"); process.exit(3)'], + new AbortController().signal, + ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) + expect(failure).toMatchObject({ code: 3, stdout: 'partial', stderr: 'boom' }) + expect((failure as Error).cause).toBeInstanceOf(Error) + }) + + it('rejects a missing executable with the spawn ENOENT code', async () => { + const failure = await runNativeCommand( + 'dsh-definitely-missing-command', + [], + new AbortController().signal, + ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) + expect(failure).toMatchObject({ code: 'ENOENT' }) + }) + + it('terminates the child when the signal aborts', async () => { + const abort = new AbortController() + const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal) + abort.abort() + const failure = await pending.then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error) + expect(failure).toBeInstanceOf(Error) + expect((failure as { code?: unknown }).code).toBe('ABORT_ERR') + }) +}) diff --git a/packages/util/native-command/tsconfig.json b/packages/util/native-command/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/util/native-command/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1059d6b02b..9b06cc5b19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2682,6 +2682,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2753,6 +2756,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4797,6 +4803,15 @@ 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/util/native-command: + 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/util/paths: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 930b99ce7f..2184f90d89 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -116,6 +116,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, 'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' }, 'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' }, + 'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers no model surface.' }, 'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' }, 'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, 'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 6867ce6492..effb110c07 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -46,6 +46,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/native-command" }, { "path": "./packages/util/paths" }, { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, From 85ca8be104fe7c623f5af817083dffb15798afe2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 21:51:01 +0800 Subject: [PATCH 10/26] =?UTF-8?q?feat(host,client):=20compose=20directory?= =?UTF-8?q?=20picking=20through=20slots=20=E2=80=94=20dual-face=20-native,?= =?UTF-8?q?=20no=20wire=20advertisement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui-workspace's two trigger surfaces each declare a single-kind directory-flow hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow, same owner contract) and keep only the trigger and the adoption: the Open-local- folder entry renders while the surface's hole is occupied, and the occupant reports one picked path per open through the hole's owner conversation (open/busy/onPicked/onCancel/onError). directory-picker-native becomes dual-face: its browser half fills both holes with a renderless occupant driving host.pickDirectory, so the cordis.yml row that mounts the backend also composes the client interaction — a mismatch is impossible and a second flow package fails at client load. With composition wiring both sides, the host.describe.directoryPicker advertisement and the client's kind branching lose their last consumer: the field, WorkspacesService.directoryPickerKind(), the DirectoryPickerKind wire type, and the picker's per-open describe read are deleted. The connection fixture now serves a deterministic pickDirectory path so the keyless snapshot drives the full pick-then-adopt flow. ui-workspace's hand-rolled declaration deferral is replaced by the deferRegistration helper it duplicated. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 10 +- ...-28-directory-picker-capability-seam.zh.md | 10 +- apps/cli/cordis.yml | 6 +- apps/web/tests/workspace-flow.snapshot.ts | 27 ++- docs/capability-seams.md | 2 +- docs/config-catalog.md | 1 + docs/module-graph.md | 7 +- packages/client/connection/src/client/api.ts | 2 +- .../client/connection/src/client/fixture.ts | 11 +- .../client/connection/src/client/index.ts | 2 +- .../connection/tests/connection.spec.ts | 4 +- packages/client/connection/tests/fake-api.ts | 4 +- packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/workspaces/service.ts | 17 +- packages/client/runtime/tests/fake-api.ts | 4 +- .../runtime/tests/workspaces-service.spec.ts | 9 - packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 4 +- packages/client/ui-workspace/README.zh.md | 4 +- .../src/client/WorkspaceBrowser.tsx | 8 +- .../src/client/WorkspacePicker.tsx | 108 ++++----- .../ui-workspace/src/client/contract/slots.ts | 74 ++++-- .../client/ui-workspace/src/client/index.ts | 70 +++--- .../client/ui-workspace/tests/apply.spec.ts | 34 ++- .../tests/workspace-browser.spec.tsx | 8 +- .../tests/workspace-picker.spec.tsx | 216 +++++++----------- packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 1 - packages/host/apiproxy/src/api/host.schema.ts | 1 - packages/host/apiproxy/src/api/host.ts | 14 -- packages/host/apiproxy/src/api/index.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 5 +- .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 7 +- .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.md | 4 +- .../host/directory-picker-native/README.zh.md | 4 +- .../host/directory-picker-native/package.json | 25 +- .../src/client/index.ts | 73 ++++++ .../src/native-picker.ts | 2 +- .../tests/client-flow.spec.tsx | 123 ++++++++++ .../directory-picker-native/tsconfig.json | 22 +- .../directory-picker-native/tsdown.config.ts | 3 + .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/util/native-command/README.i18n.yaml | 2 +- packages/util/native-command/README.zh.md | 2 +- pnpm-lock.yaml | 15 ++ scripts/gen-doc-graphs.ts | 2 +- tsconfig.client.json | 6 + tsconfig.host.json | 2 +- 59 files changed, 614 insertions(+), 385 deletions(-) create mode 100644 packages/host/directory-picker-native/src/client/index.ts create mode 100644 packages/host/directory-picker-native/tests/client-flow.spec.tsx create mode 100644 packages/host/directory-picker-native/tsdown.config.ts 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 index 1c7ea3556f..b49544ddfb 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 78ae05e0da67bff791c0b4f315451aa02e1fa6f4 -2026-07-28-directory-picker-capability-seam.zh.md: bd527e2a1dba6934300a50877d4777f7f9fa24b1 +2026-07-28-directory-picker-capability-seam.md: ce5a2695345e29db5739df206965720559783ce3 +2026-07-28-directory-picker-capability-seam.zh.md: 5b73c3c48493d4f178a523db19bc124eda9c7cca 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 index 78ae05e0da..ce5a269534 100644 --- 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 @@ -10,7 +10,9 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p ## Decision -A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, advertises the kind through `host.describe.directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind; the client branches on the advertised kind and hides the affordance for unknown kinds (merge-extensible default). Composition (`cordis.yml`) is the swap point; the union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. +A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. + +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Each backend package is **dual-face**: its browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app browsing dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: @@ -30,7 +32,7 @@ Placement and policy rulings folded into this decision: ## Consequences -- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The GUI already gates its picking affordance on `describe.directoryPicker` (non-`native` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI. -- The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests. -- A future interaction (or an Electron provider of the `native` interaction) is one backend package plus a client branch — no gateway surgery. +- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The in-app browser PR flips that one row to `-browse`, swapping backend and UI together. +- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. +- A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. - `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 index bd527e2a1d..5b73c3c484 100644 --- 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 @@ -10,7 +10,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 决策 -在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,经 `host.describe.directoryPicker` 广播 kind,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答;客户端按广播的 kind 分支,未知 kind 隐藏入口(可合并扩展的默认分支)。组合(`cordis.yml`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 + +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。每个后端包都是**双面包**:其 browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内浏览对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 并入本决策的位置与策略裁决: @@ -30,7 +32,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick ## 后果 -- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其选目录入口(非 `native` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。 -- 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。 -- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个后端包加一个客户端分支——无需网关手术。 +- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`(行为不变)。应用内浏览器 PR 只翻这一行到 `-browse`,后端与 UI 同时切换。 +- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 +- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index de0af79b59..c6058890d8 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -240,8 +240,10 @@ # 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). +# Directory-picking package, dual-face: the node half serves the gateway's +# host.* picker RPCs, the browser half fills ui-workspace's directory-flow +# slots — one row composes the whole interaction. Swap point: mount +# '-browse' instead for the in-app browser (remote-capable). - id: directory-picker name: '@deepseek-ai/dsh-host-directory-picker-native' diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 37ba56055e..ca98e7b176 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -37,6 +37,15 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [ ], }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, + // Dual-face host package: its browser half fills the directory-flow holes + // (the same composition row apps/cli mounts for the node-side backend). + { + id: '@deepseek-ai/dsh-host-directory-picker-native', + dir: '../host/directory-picker-native', + url: '/plugins/directory-picker-native.js', + rev: 'fx', + inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace'], + }, ] const bundles = new Map(PLUGINS.map(plugin => [ @@ -174,18 +183,24 @@ it('locks the composer in the New Session view state until a Workspace is chosen `) }) -it('hides the Open-local-folder entry under the fixture host\'s browse picker capability', async () => { +it('adopts a directory through the composed native flow and lands in its blank session', async () => { boot('?fixture=empty') await findLockedComposer() fireEvent.click(workspaceChip()) const menu = await screen.findByRole('menu') - // Flush the advertised-kind read (fixture describe resolves in microtasks): - // the fixture serves `browse`, whose in-app UI is not wired yet, so the - // dialog affordance must not render — only the create action remains. - await act(async () => {}) + // The composed flow package occupies the directory-flow hole, so the + // picking affordance is present (no advertised-kind read exists anymore). expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item))) - .toEqual(['Create a new workspace']) + .toEqual(['Open local folder…', 'Create a new workspace']) + fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' })) + // The renderless native flow drives the fixture's deterministic pick and + // the owner adopts the returned path into a real Workspace. + await act(async () => {}) + await findHeroComposer() + await waitFor(() => { + expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project') + }) }) it('selects the recent Workspace and opens its blank Session on first load', async () => { diff --git a/docs/capability-seams.md b/docs/capability-seams.md index b5c0dd6bec..6f934f96e3 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -364,7 +364,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-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe. | +| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; each backend is dual-face, its browser half filling ui-workspace directory-flow slots (no wire advertisement). | | `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 15339efefd..2018c38f5b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2241,6 +2241,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@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)) +- `@deepseek-ai/dsh-native-command` ([`packages/util/native-command/src/index.ts`](../packages/util/native-command/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) diff --git a/docs/module-graph.md b/docs/module-graph.md index c11ca86378..320c6c85e4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -267,7 +267,6 @@ flowchart TD pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_invariants pkg_host_webserver --> pkg_invariants pkg_storage --> pkg_invariants pkg_subprocess --> pkg_invariants @@ -355,6 +354,10 @@ flowchart TD pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots pkg_client_ui_theme --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm @@ -944,7 +947,6 @@ flowchart TD | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/support/invariants) | | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) | | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) | @@ -973,6 +975,7 @@ flowchart TD | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 8214acf0b9..274612402f 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,7 +8,7 @@ export type { ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, - DirectoryEntry, DirectoryListing, DirectoryPickerKind, + DirectoryEntry, DirectoryListing, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, 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 94d2e9adb5..82ecbb1cf3 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -863,12 +863,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }, }, host: { - 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' }, - }), + describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }), + // Deterministic native pick: the keyless lanes drive the full + // pick-then-adopt path without an OS chooser (design-mock content, + // same tree the browse primitives serve). + pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), listDirectory: (request) => { const target = request.payload.path ?? FIXTURE_HOME const children = childrenOf(target) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index ceeaffb66e..0c98f0c1e9 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -13,7 +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, + DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, 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 bce6fce2ce..4de4a31f25 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, directoryPicker: 'browse' as const })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) 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, directoryPicker: 'browse' as const })) + gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) 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 f6ca264bf8..13ca7f4922 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -63,8 +63,8 @@ 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, directoryPicker: 'browse' as const })) + onDescribe: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 2d1b42c249..99e2e73433 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -23,7 +23,7 @@ export type { SessionListPhase } from './sessions/manager.ts' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' export type { - DirectoryEntry, DirectoryListing, DirectoryPickerKind, WorkspaceId, WorkspaceView, + DirectoryEntry, DirectoryListing, 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' diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 88e5bd988c..ca88eb001b 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { - DirectoryListing, DirectoryPickerKind, IApiClient, RpcError, + DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' @@ -191,21 +191,6 @@ export class WorkspacesService { return response.result.value.path } - /** - * The directory-picking interaction the Host composed — the fact the picker - * UI branches on (`native` opens the native chooser; `browse` opens the - * in-app browser). Read per flow open: one describe round trip, no cache to - * go stale across reconnects. - * @returns the Host's advertised picker kind. - */ - 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. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index e2eac0a364..b0f5f13db5 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -81,8 +81,8 @@ 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, directoryPicker: 'browse' as const })) + onDescribe: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 })) onPickDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: null })) onOpenPath: (payload: unknown) => Promise> = diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 0b2788a693..6f0d35f2fa 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -238,15 +238,6 @@ describe('WorkspacesService', () => { 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() diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 7e0cd9380e..00b639f625 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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/client/ui-workspace/README.md -README.md: deaa25184f5ddbfc5980033ce60cef43577ff33c -README.zh.md: e14e8ca6a2e3d65ce5fc403291e45ebc03598e04 +README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96 +README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index deaa25184f..8acf819121 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action renders only when the Host advertises the `native` picker interaction (read per flow open through `host.describe`); `browse` — until its in-app browser UI lands — and unknown kinds hide the entry, the seam's documented default. When shown, it delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. @@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions. -- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. +- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index e14e8ca6a2..e97d93f7e3 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -4,7 +4,7 @@ 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作仅在 Host 广播 `native` 选择交互时渲染(每次流程打开时通过 `host.describe` 读取);`browse`(在其应用内浏览器 UI 落地之前)以及未知 kind 都会隐藏该入口,即 seam 文档化的默认行为。显示时它会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 @@ -19,4 +19,4 @@ ## 已知限制与暂缓事项 - **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。 -- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。 +- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 67d5f0d9e5..61da0cc1ea 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -253,8 +253,8 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, - pickDirectory, - directoryPickerKind, + hasDirectoryFlow, + renderSlot, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const groupBy = useStore(s => s.groupBy) @@ -372,8 +372,8 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} - pickDirectory={pickDirectory} - directoryPickerKind={directoryPickerKind} + hasDirectoryFlow={hasDirectoryFlow} + renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)} createOnly side="right" onPick={(workspaceId) => { diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index cd68faf551..e98d6d4e26 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -2,18 +2,20 @@ * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same * package) and wrapped by WorkspacePicker for the conversation empty-state - * slot registration. + * slot registration. Directory picking itself lives in the composed flow + * package's slot occupant (see the contract module doc): this core only + * opens the flow, adopts the picked path, and owns the error surface. */ -import type { RefObject } from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import type { ReactNode, RefObject } from 'react' +import { useCallback, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import { WorkspaceCreateError, - type DirectoryPickerKind, type WorkspaceId, type WorkspaceListState, type WorkspaceView, + type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerProps } from './contract/slots.ts' +import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' const OPEN_LOCAL_FOLDER = '::open-local-folder' @@ -31,10 +33,10 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Open the Host's native single-directory picker. */ - pickDirectory: () => Promise - /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ - directoryPickerKind: () => Promise + /** Whether this surface's directory-flow hole is occupied (read per menu render; empty hides the local-folder entry). */ + hasDirectoryFlow: () => boolean + /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */ + renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode /** A real Workspace was picked or created. */ onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ @@ -57,8 +59,8 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, - pickDirectory, - directoryPickerKind, + hasDirectoryFlow, + renderDirectoryFlow, onPick, onClose, createOnly = false, @@ -75,6 +77,7 @@ export function WorkspaceCreateFlow({ const [workspaceName, setWorkspaceName] = useState('') const [creating, setCreating] = useState(false) const [modalError, setModalError] = useState(null) + const [flowOpen, setFlowOpen] = useState(false) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) const composingRef = useRef(false) @@ -82,36 +85,12 @@ export function WorkspaceCreateFlow({ const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) - // The advertised interaction gates the picking affordance: 'native' is the - // only kind pickDirectory() can serve, so its entry renders under that kind - // alone; 'browse' (until the in-app browser UI lands) and unknown kinds - // hide the entry, the seam's documented unknown-kind default. Re-read per - // flow open — no cache to go stale across reconnects. - const [nativePicker, setNativePicker] = useState(false) - useEffect(() => { - if (!open) { - // Close discards the answer: a reconnect or HMR can swap the composed - // backend while the menu is closed, and the reopened menu must never - // paint the previous host's entry before the fresh read lands. - setNativePicker(false) - return - } - // Reset before each read: the injected reader can also change identity - // while the flow stays open, and that prior answer must not leak either; - // a settlement from a superseded read is discarded via the - // cleanup-toggled flag. - setNativePicker(false) - let stale = false - void directoryPickerKind() - .then((kind) => { if (!stale) setNativePicker(kind === 'native') }) - // A failed describe hides the entry too: the same Host that cannot - // answer describe cannot serve pickDirectory. - .catch(() => { if (!stale) setNativePicker(false) }) - return () => { stale = true } - }, [open, directoryPickerKind]) - + // The occupied hole gates the picking affordance: with no composed flow the + // entry simply is not there (the seam's documented no-flow default). Read + // per render while the menu is open — registrations land through plugin + // activation, and the menu re-renders on every toggle. const createEntries: MenuEntry[] = [ - ...(nativePicker + ...(hasDirectoryFlow() ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }] : []), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, @@ -134,15 +113,10 @@ export function WorkspaceCreateFlow({ setModalError(null) } - const openLocalFolder = (): void => { - onClose() - setModalKind(null) - setModalError(null) - setFolderConflict(false) - setPickingFolder(true) - void pickDirectory().then(async (path) => { - if (path === null) return - const workspace = await createWorkspace({ path }) + /** Adopt a picked directory; failures land in the folder-error dialog (Choose again reopens the flow). */ + const adoptDirectory = (path: string): Promise => + createWorkspace({ path }).then((workspace) => { + setFlowOpen(false) onPick(workspace.workspaceId) }).catch((reason: unknown) => { setFolderConflict( @@ -150,8 +124,33 @@ export function WorkspaceCreateFlow({ && reason.rpcError.code === 'workspace-name-conflict', ) setModalError(reason instanceof Error ? reason.message : String(reason)) + setFlowOpen(false) setModalKind('folder-error') - }).finally(() => { setPickingFolder(false) }) + }) + + const openLocalFolder = (): void => { + onClose() + setModalKind(null) + setModalError(null) + setFolderConflict(false) + setFlowOpen(true) + } + + /** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */ + const flowOwner: DirectoryFlowOwnerProps = { + open: flowOpen, + busy: pickingFolder, + onPicked: (path) => { + setPickingFolder(true) + void adoptDirectory(path).finally(() => { setPickingFolder(false) }) + }, + onCancel: () => { setFlowOpen(false) }, + onError: (message) => { + setFlowOpen(false) + setFolderConflict(false) + setModalError(message) + setModalKind('folder-error') + }, } const handleSelect = (id: string): void => { @@ -205,6 +204,7 @@ export function WorkspaceCreateFlow({ getAnchorRect={getAnchorRect} /> {open && workspaceSnapshot.phase === 'pending' &&
Loading workspaces…
} + {renderDirectoryFlow(flowOwner)} renderSlot('conversation.hero.workspace.directoryFlow', owner)} selectedId={selectedId} onPick={onPick} onClose={onClose} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index c20955b567..fce3bd9b46 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -7,21 +7,74 @@ * consumes the shell's two-fact owner share (wide / expandSidebar). * - WorkspacePicker fills the conversation empty-state hole (menu + * create dialogs shared with the browser). + * + * Each registration also declares one **directory-flow hole** (`single` + * kind): the slot a composed picker package's client half fills with its + * picking interaction — a renderless native-chooser driver or an in-app + * browsing dialog. ui-workspace owns the trigger (the "Open local folder…" + * menu entry, shown only while the hole is occupied) and the adoption + * semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose + * again); the occupant owns everything between `open` and the picked path. + * Two holes exist because the two menu surfaces are independent slot entries + * and a hole has exactly one declaring entry — they carry the same owner + * contract and the same occupant. */ -import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { DirectoryPickerKind, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { createWorkspaceViewStore } from '../stores.ts' +/** + * Owner share of the directory-flow holes: the complete conversation between + * the trigger surface and the picking interaction. The occupant reads `open` + * to run/render its interaction and reports exactly one outcome per open. + */ +export interface DirectoryFlowOwnerProps { + /** True while a picking interaction is requested; flipping back to false withdraws the request. */ + open: boolean + /** True while the owner adopts a picked path (`createWorkspace` in flight); occupants disable their commit affordances. */ + busy: boolean + /** The operator picked a directory (absolute host path); the owner adopts it. */ + onPicked: (path: string) => void + /** The operator dismissed the interaction; the owner just closes the flow. */ + onCancel: () => void + /** The interaction itself failed (chooser missing, listing denied); the owner shows its error surface. */ + onError: (message: string) => void +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */ + 'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps } + /** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */ + 'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps } + } +} + +/** The two directory-flow holes; a flow package's client half registers its one component into both. */ +export type DirectoryFlowSlotName = + | 'conversation.hero.workspace.directoryFlow' + | 'sidebar.workspaces.directoryFlow' + +/** Directory-picking share both trigger surfaces consume. */ +export type DirectoryPickingInjected = { + /** + * Whether this surface's directory-flow hole is occupied — read when the + * menu opens; an empty hole hides the "Open local folder…" entry (the + * no-flow composition simply has no picking affordance). + */ + hasDirectoryFlow: () => boolean +} + /** * Browser-private injected share (arrives via the register inject factory). * Data reads use the global framework hooks; these are the Host actions the * browsing region drives. */ -export type WorkspaceBrowserInjected = { +export type WorkspaceBrowserInjected = DirectoryPickingInjected & { /** * Start a New Session in a Workspace: reuse-or-create its blank session * and open it; with no workspace, clear the selection into the New Session @@ -42,15 +95,12 @@ export type WorkspaceBrowserInjected = { insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise - /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ - directoryPickerKind: () => Promise } /** Full browser props: shell owner share + viewing store + injected actions. */ export type WorkspaceBrowserProps = PropsRuntime<'sidebar.workspaces'> + & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> & PropsStore> & WorkspaceBrowserInjected @@ -59,13 +109,9 @@ export type WorkspaceBrowserProps = * callback; this callback creates only the real Host Workspace. A type alias * supplies the implicit index signature required by the registry. */ -export type WorkspacePickerInjected = { +export type WorkspacePickerInjected = DirectoryPickingInjected & { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Ask the local Host to open its native single-directory picker. */ - pickDirectory: () => Promise - /** The Host's advertised picker interaction (read per flow open); gates which picking affordance renders. */ - directoryPickerKind: () => Promise } /** @@ -74,4 +120,6 @@ export type WorkspacePickerInjected = { * currency, so one composed type serves both registrations. */ export type WorkspacePickerProps = - PropsRuntime<'conversation.hero.workspace'> & WorkspacePickerInjected + PropsRuntime<'conversation.hero.workspace'> + & PropsRenderSlots<'conversation.hero.workspace.directoryFlow'> + & WorkspacePickerInjected diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 6b84680fbc..4fd9edb38c 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -3,9 +3,12 @@ * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), * and WorkspacePicker fills the conversation hero's picker hole * (`conversation.hero.workspace` — both hero forms). Both read real Host - * Workspaces through the global useWorkspaces hook. Export discipline: + * Workspaces through the global useWorkspaces hook, and each declares its + * own `single` directory-flow child hole for the composed picker package's + * client half (see the contract module doc). Export discipline: * packages/client/AGENTS.md. */ +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' import { createWorkspaceViewStore } from './stores.ts' @@ -13,6 +16,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' export type { + DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, } from './contract/slots.ts' @@ -44,50 +48,40 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), - directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), + hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), - pickDirectory: () => ctx.workspaces.pickDirectory(), - directoryPickerKind: () => ctx.workspaces.directoryPickerKind(), + hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0, }) - // Declaration-aware registration: each owner's declaring apply may activate - // after this one (entry activation order is unconstrained), and a register - // into an undeclared slot throws. Register once the declaration is on the - // ledger; the subscription also re-registers after an HMR collapse - // re-declares the slot (the cascade disposed our entry with it). + // Declaration-aware registration (deferRegistration): each owner's + // declaring apply may activate after this one, and a register into an + // undeclared slot throws; the deferral also re-registers after an HMR + // collapse re-declares the slot. Each registration declares its own + // directory-flow child hole in the same call (declaration = render + // authorization, one table). ctx.effect(() => { - const registrations = [ - { - name: 'sidebar.workspaces' as const, - component: WorkspaceBrowser, - register: () => ctx.slots.register( - { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, + const deferred = [ + deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () => + ctx.slots.register( + { + name: 'sidebar.workspaces', + children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } }, + store: createWorkspaceViewStore(), + inject: browserInjected, + }, WorkspaceBrowser, - ), - }, - { - name: 'conversation.hero.workspace' as const, - component: WorkspacePicker, - register: () => ctx.slots.register( - { name: 'conversation.hero.workspace', inject: pickerInjected }, + )), + deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () => + ctx.slots.register( + { + name: 'conversation.hero.workspace', + children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } }, + inject: pickerInjected, + }, WorkspacePicker, - ), - }, + )), ] - const disposers = new Map void>() - const tryRegister = (entry: (typeof registrations)[number]): void => { - if (ctx.slots.spec(entry.name) === undefined) return - if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return - disposers.set(entry.name, entry.register()) - } - const unsubscribers = registrations.map(entry => - ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) - for (const entry of registrations) tryRegister(entry) - return () => { - for (const unsubscribe of unsubscribers) unsubscribe() - for (const dispose of disposers.values()) dispose() - } + return () => { for (const entry of deferred) entry.dispose() } }, 'ui-workspace: browser + picker registrations') } diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index e670a05e9b..4ef3738d01 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -14,18 +14,16 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) - const pickDirectory = vi.fn(async () => '/tmp/picked') - const directoryPickerKind = vi.fn(async () => 'native' as const) const startSession = vi.fn() const rename = vi.fn(async () => ({})) const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() ctx.provide('workspaces', { - create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, + create, startSession, rename, insertSessionBefore, } as never) ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, directoryPickerKind, startSession, rename, insertSessionBefore, open, clear } + return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -74,18 +72,30 @@ describe('ui-workspace apply', () => { expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') await browser.createWorkspace({ name: 'project' }) expect(b.create).toHaveBeenCalledWith({ name: 'project' }) - await browser.pickDirectory() - expect(b.pickDirectory).toHaveBeenCalledOnce() - await browser.directoryPickerKind() - expect(b.directoryPickerKind).toHaveBeenCalledOnce() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() await picker.createWorkspace({ path: '/tmp/project' }) expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) - await picker.pickDirectory() - expect(b.pickDirectory).toHaveBeenCalledTimes(2) - await picker.directoryPickerKind() - expect(b.directoryPickerKind).toHaveBeenCalledTimes(2) + }) + + it('declares the two directory-flow holes and reports their occupancy per surface', async () => { + const b = await bench() + declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace') + await b.ctx.plugin({ inject: [...inject], apply }).await() + // Registration declared the child holes (declaration = render authorization). + expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' }) + expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' }) + + const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() + expect(browser.hasDirectoryFlow()).toBe(false) + expect(picker.hasDirectoryFlow()).toBe(false) + // A flow occupant flips exactly its own surface. + const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null) + expect(browser.hasDirectoryFlow()).toBe(true) + expect(picker.hasDirectoryFlow()).toBe(false) + dispose() + expect(browser.hasDirectoryFlow()).toBe(false) }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 5561394348..e4b038b0f3 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -59,8 +59,8 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), - pickDirectory: vi.fn(async () => null), - directoryPickerKind: vi.fn(async () => 'native' as const), + hasDirectoryFlow: () => true, + renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, ...overrides, } const view = render() @@ -264,13 +264,11 @@ describe('WorkspaceBrowser', () => { } }) - it('rail create-workspace toggles the create-only picker in place, without expanding', async () => { + it('rail create-workspace toggles the create-only picker in place, without expanding', () => { const expandSidebar = vi.fn() mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) expect(expandSidebar).not.toHaveBeenCalled() - // Flush the advertised-kind read that gates the local-folder entry. - await act(async () => {}) // createOnly: existing workspaces are not listed, only the create actions. expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull() expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 7c661cc7c2..dbbb444de2 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -5,6 +5,7 @@ import type { SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -35,15 +36,29 @@ function anchor(): { current: HTMLElement } { return { current: element } } +/** + * Probe occupant of the directory-flow hole: records the latest owner + * conversation so tests drive onPicked/onCancel/onError like a composed flow + * package would, and renders a marker element while the flow is open. + */ +function flowProbe() { + const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined } + const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => { + probe.owner = owner + return owner.open ?
: null + }) as never + return { probe, renderSlot } +} + function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), - pickDirectory = vi.fn(async () => null as string | null), - directoryPickerKind = vi.fn(async () => 'native'), + hasDirectoryFlow: () => boolean = () => true, ) { const onPick = vi.fn() const onClose = vi.fn() const anchorRef = anchor() + const { probe, renderSlot } = flowProbe() const renderPicker = (nextItems: readonly WorkspaceView[]) => ( ) const view = render( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, pickDirectory, directoryPickerKind, + view, onPick, onClose, createWorkspace, probe, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } -// findByRole, not getByRole: the folder entry renders only after the advertised -// picker kind resolves, one microtask after the menu opens. -async function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): Promise { - fireEvent.click(await screen.findByRole('menuitem', { name })) +function chooseItem(name: 'Open local folder…' | 'Create a new workspace'): void { + fireEvent.click(screen.getByRole('menuitem', { name })) } describe('WorkspacePicker', () => { @@ -83,7 +96,7 @@ describe('WorkspacePicker', () => { const created = workspace('new', 'New') const createWorkspace = vi.fn(async () => created) const b = mount([], createWorkspace) - await chooseItem('Create a new workspace') + chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.change(input, { target: { value: 'project-one' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -91,78 +104,84 @@ describe('WorkspacePicker', () => { await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) }) - it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => { + it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => { const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' } const createWorkspace = vi.fn(async () => created) - const pickDirectory = vi.fn(async () => '/tmp/project') - const b = mount([], createWorkspace, pickDirectory) - await chooseItem('Open local folder…') - expect(pickDirectory).toHaveBeenCalledOnce() - await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) }) + const b = mount([], createWorkspace) + expect(screen.queryByTestId('directory-flow')).toBeNull() + chooseItem('Open local folder…') + expect(b.onClose).toHaveBeenCalled() + expect(screen.getByTestId('directory-flow')).toBeTruthy() + await act(async () => { b.probe.owner!.onPicked('/tmp/project') }) expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) + // Successful adoption withdraws the flow request. + expect(screen.queryByTestId('directory-flow')).toBeNull() }) - it('treats native picker cancellation as a silent no-op', async () => { - const b = mount([], vi.fn(), vi.fn(async () => null)) - await chooseItem('Open local folder…') - await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() }) + it('treats flow cancellation as a silent no-op', () => { + const b = mount([]) + chooseItem('Open local folder…') + act(() => { b.probe.owner!.onCancel() }) + expect(screen.queryByTestId('directory-flow')).toBeNull() expect(b.createWorkspace).not.toHaveBeenCalled() expect(b.onPick).not.toHaveBeenCalled() expect(screen.queryByRole('dialog')).toBeNull() }) - it('shows a name conflict and retries through the native picker', async () => { - const pickDirectory = vi.fn() - .mockResolvedValueOnce('/one/project') - .mockResolvedValueOnce(null) + it('shows a name conflict and retries by reopening the flow', async () => { const createWorkspace = vi.fn(async () => { throw new WorkspaceCreateError({ code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' }, }) }) - const b = mount([], createWorkspace, pickDirectory) - await chooseItem('Open local folder…') + const b = mount([], createWorkspace) + chooseItem('Open local folder…') + await act(async () => { b.probe.owner!.onPicked('/one/project') }) await waitFor(() => { expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy() }) expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.') + // The failed adoption withdrew the flow; Choose again reopens it. + expect(b.probe.owner!.open).toBe(false) fireEvent.click(screen.getByRole('button', { name: 'Choose again' })) - await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) }) + expect(b.probe.owner!.open).toBe(true) expect(b.onPick).not.toHaveBeenCalled() }) - it('disables the folder action while the native picker is already open', async () => { - let resolve!: (path: string | null) => void - const pending = new Promise((settle) => { resolve = settle }) - const b = mount([], vi.fn(), vi.fn(() => pending)) - await chooseItem('Open local folder…') + it('disables the create actions and reports busy to the flow while adopting', async () => { + let resolve!: (workspace: WorkspaceView) => void + const pending = new Promise((settle) => { resolve = settle }) + const created = workspace('adopted') + const b = mount([], vi.fn(() => pending)) + chooseItem('Open local folder…') + act(() => { b.probe.owner!.onPicked('/tmp/project') }) + expect(b.probe.owner!.busy).toBe(true) expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) - fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' })) - expect(b.pickDirectory).toHaveBeenCalledTimes(1) - await act(async () => { resolve(null); await pending }) + await act(async () => { resolve(created); await pending }) + expect(b.probe.owner!.busy).toBe(false) }) - it('reports non-Error native picker failures', async () => { - const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' })) - await chooseItem('Open local folder…') - await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('picker unavailable') - }) + it('shows the flow-reported failure in the folder-error surface', () => { + const b = mount([]) + chooseItem('Open local folder…') + act(() => { b.probe.owner!.onError('no chooser installed') }) + expect(screen.getByRole('alert').textContent).toBe('no chooser installed') + expect(screen.queryByTestId('directory-flow')).toBeNull() expect(b.createWorkspace).not.toHaveBeenCalled() }) - it('closes a creation modal when the user cancels', async () => { + it('closes a creation modal when the user cancels', () => { mount([]) - await chooseItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(screen.queryByRole('dialog')).toBeNull() }) - it('blocks a create-new name already present in the Workspace list', async () => { + it('blocks a create-new name already present in the Workspace list', () => { const b = mount([workspace('alpha', 'Alpha')]) - await chooseItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: ' Alpha ' } }) expect(screen.getByRole('alert').textContent).toBe('A workspace named “Alpha” already exists.') expect(screen.getByRole('button', { name: 'Create workspace' }).disabled).toBe(true) @@ -175,7 +194,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((settle) => { resolve = settle }) const created = workspace('fresh', 'same-name') const b = mount([], vi.fn(() => pending)) - await chooseItem('Create a new workspace') + chooseItem('Create a new workspace') fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'same-name' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -191,7 +210,7 @@ describe('WorkspacePicker', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) const createWorkspace = vi.fn(() => pending) const b = mount([], createWorkspace) - await chooseItem('Create a new workspace') + chooseItem('Create a new workspace') const input = screen.getByLabelText('New workspace name') fireEvent.keyDown(input, { key: 'ArrowRight' }) fireEvent.change(input, { target: { value: 'broken' } }) @@ -208,7 +227,7 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) - await chooseItem('Create a new workspace') + chooseItem('Create a new workspace') // The name field starts empty (no prefill); a name is required to submit. fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) @@ -219,11 +238,12 @@ describe('WorkspacePicker', () => { }) it('waits to show its menu until an optional anchor is available', () => { + const { renderSlot } = flowProbe() render( 'native')} + onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} + hasDirectoryFlow={() => true} renderSlot={renderSlot} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -233,101 +253,31 @@ describe('WorkspacePicker', () => { const state: WorkspaceListState = { ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false, } + const { renderSlot } = flowProbe() render( 'native')} + onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} + hasDirectoryFlow={() => true} renderSlot={renderSlot} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') }) - it('hides the folder affordance unless the Host advertises the dialog interaction', async () => { - const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => 'browse')) - await screen.findByRole('menuitem', { name: 'Create a new workspace' }) - await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + it('hides the folder entry while the directory-flow hole is empty', () => { + mount([], vi.fn(), () => false) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy() expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) - it('hides the folder affordance when the Host cannot answer describe', async () => { - const b = mount([], vi.fn(), vi.fn(async () => null), vi.fn(async () => { - throw new Error('host unreachable') - })) - await screen.findByRole('menuitem', { name: 'Create a new workspace' }) - await waitFor(() => { expect(b.directoryPickerKind).toHaveBeenCalled() }) + it('shows the folder entry once the hole reports an occupant on a later render', () => { + let occupied = false + const b = mount([], vi.fn(), () => occupied) expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() - }) - - it('does not read the picker kind while the flow is closed', () => { - const directoryPickerKind = vi.fn(async () => 'native') - render( - , - ) - expect(directoryPickerKind).not.toHaveBeenCalled() - }) - - /** Render the picker with an owner-controlled `open` and a scripted kind read. */ - function togglable(directoryPickerKind: () => Promise) { - const anchorRef = anchor() - const props = (open: boolean) => ( - - ) - const view = render(props(true)) - return { setOpen: (open: boolean) => { view.rerender(props(open)) } } - } - - it('discards a kind settlement from a superseded flow open', async () => { - let resolveFirst!: (kind: string) => void - const first = new Promise((settle) => { resolveFirst = settle }) - const directoryPickerKind = vi.fn<() => Promise>() - .mockImplementationOnce(() => first) - .mockImplementation(async () => 'browse') - const t = togglable(directoryPickerKind) - // Close while the first read is in flight, then let it answer 'native': - // the settlement is stale and must not leak into the next open. - t.setOpen(false) - await act(async () => { resolveFirst('native') }) - t.setOpen(true) - await screen.findByRole('menuitem', { name: 'Create a new workspace' }) - await waitFor(() => { expect(directoryPickerKind).toHaveBeenCalledTimes(2) }) - expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() - }) - - it('clears the advertised kind on close so a reopen cannot paint the previous host entry', async () => { - const directoryPickerKind = vi.fn<() => Promise>() - .mockImplementationOnce(async () => 'native') - // The reopened read never settles: the assertion below sees the paint - // that precedes any fresh answer. - .mockImplementation(() => new Promise(() => {})) - const t = togglable(directoryPickerKind) - await screen.findByRole('menuitem', { name: 'Open local folder…' }) - t.setOpen(false) - t.setOpen(true) - await screen.findByRole('menuitem', { name: 'Create a new workspace' }) - expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() - }) - - it('discards a stale describe failure after a newer open already answered', async () => { - let rejectFirst!: (reason: Error) => void - const first = new Promise((_settle, reject) => { rejectFirst = reject }) - const directoryPickerKind = vi.fn<() => Promise>() - .mockImplementationOnce(() => first) - .mockImplementation(async () => 'native') - const t = togglable(directoryPickerKind) - t.setOpen(false) - t.setOpen(true) - await screen.findByRole('menuitem', { name: 'Open local folder…' }) - // The superseded read failing late must not hide the freshly shown entry. - await act(async () => { rejectFirst(new Error('late loss')); await first.catch(() => {}) }) + // A flow package activating after the first paint is observed on the + // next render — the same cadence as reopening the menu. + occupied = true + b.rerenderItems([]) expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() }) }) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index f50948eb6d..9a6deda327 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: 0810be58fc773a241528656d7f6e826e9c3aabda -README.zh.md: f9133eee8498594d913b2fe0814ac51d712b678d +README.md: 7df0ecc4a362be1149188d133233307b1fc48c8a +README.zh.md: 90d5ea2b0947d2cff9ba06e89b6225b39dad7fce diff --git a/packages/host/README.md b/packages/host/README.md index 0810be58fc..7df0ecc4a3 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -9,7 +9,7 @@ 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 `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` | -| `directory-picker-native/` | Native-OS-chooser backend (osascript / PowerShell / Zenity+KDialog); host-display only | (registers `ctx.directoryPicker`) | +| `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (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 f9133eee84..90d5ea2b09 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -9,7 +9,7 @@ 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 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` | -| `directory-picker-native/` | 原生 OS 选择器后端(osascript/PowerShell/Zenity+KDialog);仅宿主屏幕可用 | (注册 `ctx.directoryPicker`) | +| `directory-picker-native/` | 双面原生交互:OS 选择器后端(osascript/PowerShell/Zenity+KDialog,仅宿主屏幕可用)+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `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 ee91f44ce8..73b0845370 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: 3639722ab25826af8f7a0721f22d244f78b4210b -README.zh.md: 3ac81fb412d4e4caf192953bc7a7c846a1d29965 +README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 +README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 9beead944f..ca4471454f 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -18,7 +18,7 @@ Session model routing is a session-domain contract. `session.models` returns the Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. -Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); `host.describe.directoryPicker` advertises the capability kind the client renders for, and a method called outside the advertised kind fails with `directory-picker-unavailable`. Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. +Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request. `host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index a32fbb9074..953539e119 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -18,7 +18,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 -目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));`host.describe.directoryPicker` 广播客户端应按其渲染的能力 kind,调用广播之外的方法会以 `directory-picker-unavailable` 失败。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 +目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`);该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用,调用方与连接的中止仍会传播至原生进程。在 `browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable`/`directory-exists`/`directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏(dsh-client-connection)像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。 `host.openPath` 会用操作系统的默认应用打开一个文件系统路径(macOS 为 `open`,Windows 为 `Invoke-Item`,Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 8b68d53e30..9a95ba6fd4 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1009,7 +1009,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro provider: defaults.provider, model: defaults.model, attachedSessions: ctx.agents.list().length, - directoryPicker: ctx.directoryPicker.capability().kind, })) }, diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 97c3979ed8..f5d17421fb 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -19,7 +19,6 @@ export const hostDescribeValueSchema = z.object({ attachedSessions: z.number().int().nonnegative(), // Open string, not a literal union: unknown kinds must survive the wire so // a merge-added capability can advertise (the client hides the affordance). - directoryPicker: z.string(), }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 2c84998bfc..937fa905af 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -5,18 +5,6 @@ import type { RpcRequest, RpcResponse } from './rpc.ts' -/** - * The composed directory-picker interaction the host serves (mirror of the - * `ctx.directoryPicker` capability kind): `native` = one OS chooser on - * the host display (`host.pickDirectory`); `browse` = in-app listing/creation - * primitives (`host.listDirectory`/`host.createDirectory`). Calling a method - * outside the advertised kind fails with `directory-picker-unavailable`. - * The wire preserves kinds beyond the two with methods here (a merge-added - * capability advertises before its RPCs exist); the client's documented - * default for a kind it does not recognize is to hide the picking affordance. - */ -export type DirectoryPickerKind = 'native' | 'browse' | (string & {}) - /** 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). */ @@ -51,7 +39,6 @@ export interface HostApi { * 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); - * directoryPicker = the composed picker interaction the client renders for. */ describe(request: RpcRequest<{}>): Promise> /** diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index bb6f7d51c3..7944336b36 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, SessionProjectionsBlock, SessionsApi, SessionSummary, } from './sessions.ts' -export type { DirectoryEntry, DirectoryListing, DirectoryPickerKind, HostApi } from './host.ts' +export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 5d1989ab5d..e978db9f9b 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -192,17 +192,14 @@ describe('host.listDirectory / host.createDirectory', () => { }) }) - it('refuses the browse RPCs under a native composition and advertises the kind in describe', async () => { + it('refuses the browse RPCs under a native composition', async () => { const { api } = await harness() - expect((await api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'native' } }) expect((await api.host.listDirectory(request({}))).result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) - const browse = await harness(undefined, BROWSE_STUB) - expect((await browse.api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'browse' } }) }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 8ec798238e..fc1dfd777a 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -48,7 +48,7 @@ function scriptedApi(overrides: { ...overrides.sessions, }, host: { - describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, directoryPicker: 'browse' as const }), + describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), pickDirectory: r => ok(r, { path: null }), listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }), createDirectory: r => ok(r, { path: '/t/new' }), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index cac34e2ec1..bf0be224f2 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -76,7 +76,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, host: { async describe(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, directoryPicker: 'native' as const } } } + return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } } }, async pickDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 5c9ced4249..6c400871ae 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -218,12 +218,9 @@ describe('sessions domain schemas', () => { describe('host domain schemas', () => { it('validates describe request/value', () => { expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'native' }) + const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 }) expect(value.attachedSessions).toBe(2) - expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined() - // A kind beyond the two with methods survives the wire (merge-added - // capabilities advertise; the client hides the affordance). - expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' }).directoryPicker).toBe('other') + expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined() }) it('validates the browse listing/creation payloads', () => { diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index c67ecdce91..e798bd6471 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/directory-picker-native/README.md -README.md: 8e9d6c7c558a6b33c2a37d504c571fd3eda579bb -README.zh.md: 68f23698ff0c1a5ee4456a1f121dcf244f09c72b +README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8 +README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 8e9d6c7c55..0b54c651d4 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,9 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). + +**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 68f23698ff..e5ac2762a6 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,7 +2,9 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 + +**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 72cb8a3e8a..bafe18e09f 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -15,12 +15,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/client.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -31,11 +36,27 @@ "@deepseek-ai/dsh-native-command": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7", + "react": "^18.2.0" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" } } diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts new file mode 100644 index 0000000000..29b7876d2f --- /dev/null +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -0,0 +1,73 @@ +/** + * Browser half of the native directory-picker backend: fills ui-workspace's + * two directory-flow holes with a renderless occupant that answers each + * `open` by driving `host.pickDirectory` (the node half's OS chooser) and + * reporting the one outcome — picked path, cancellation, or failure — back + * through the owner conversation. Mounting this package therefore composes + * both sides of the native interaction with one cordis.yml row; no client + * code branches on a capability kind. + */ +import { useEffect, useRef } from 'react' +import type { ReactElement } from 'react' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: the SlotMap merge declaring the directory-flow holes and their owner contract. +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' + +/** Injected face: the wire call the flow drives (bound in apply's closure). */ +interface NativeFlowInjected { + /** Ask the local Host to open its native single-directory chooser. */ + pick: () => Promise +} + +/** + * Renderless flow occupant: each rising `open` edge runs exactly one pick and + * reports exactly one outcome; the ref arms once per open so re-renders (and + * an adoption keeping `open` true while `busy`) never launch a second + * chooser. The owner withdrawing `open` re-arms the next request. + * @param props - owner conversation plus the injected pick call. + * @returns nothing — the native chooser renders on the host display. + */ +export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null { + const { open, pick } = props + const armed = useRef(false) + // Callbacks ride a ref so the settled pick reports through the owner's + // latest handlers, not the ones captured when the chooser opened. + const outcome = useRef(props) + outcome.current = props + useEffect(() => { + if (!open) { + armed.current = false + return + } + if (armed.current) return + armed.current = true + pick().then( + (path) => { if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) }, + (reason: unknown) => { outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) }, + ) + }, [open, pick]) + return null +} + +/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */ +export const inject = ['slots', 'workspaces'] + +/** + * Client plugin body: register the renderless native flow into both + * directory-flow holes (declaration-aware deferral — the declaring + * ui-workspace entries may activate later, and an HMR collapse re-declares). + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) + ctx.effect(() => { + const deferred = [ + deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () => + ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow)), + deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () => + ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow)), + ] + return () => { for (const entry of deferred) entry.dispose() } + }, 'directory-picker-native: flow registrations') +} diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index ee3d3075e5..2c8e236acc 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -1,4 +1,4 @@ -/** Cross-platform native single-directory chooser behind the dialog backend's capability. */ +/** Cross-platform native single-directory chooser behind the native backend's capability. */ import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx new file mode 100644 index 0000000000..cf2cb96679 --- /dev/null +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { apply, inject, NativeDirectoryFlow } from '../src/client/index.ts' + +afterEach(cleanup) + +const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') + ctx.provide('workspaces', { pickDirectory } as never) + const slots = ctx.get('slots') as SlotsService + const declare = () => slots.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, () => null) + return { ctx, slots, pickDirectory, declare } +} + +function owner(overrides: Partial = {}): DirectoryFlowOwnerProps { + return { + open: true, busy: false, + onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(), + ...overrides, + } +} + +describe('directory-picker-native client half', () => { + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'workspaces']) + }) + + it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { + const before = await bench() + before.declare() + const fiber = before.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1) + // Registry-contribution disposal proof: the fiber going down empties the holes. + await fiber.dispose() + for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0) + + const after = await bench() + await after.ctx.plugin({ inject: [...inject], apply }).await() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0) + after.declare() + await Promise.resolve() + for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) + }) + + it('rejects a second flow occupant at load (single-kind hole)', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + expect(() => b.slots.register({ name: HOLES[0] } as never, () => null)) + .toThrow(/already has a registration/) + }) + + it('drives the injected pick through the hole entry and reports the picked path', async () => { + const b = await bench() + b.declare() + await b.ctx.plugin({ inject: [...inject], apply }).await() + const entry = b.slots.entries(HOLES[0])[0]! + const injected = (entry.inject as () => { pick: () => Promise })() + await expect(injected.pick()).resolves.toBe('/tmp/picked') + expect(b.pickDirectory).toHaveBeenCalledOnce() + }) + + it('runs one pick per open edge and reports the path to the latest onPicked', async () => { + let resolve!: (path: string | null) => void + const pick = vi.fn(() => new Promise((settle) => { resolve = settle })) + const first = owner() + const view = render() + expect(pick).toHaveBeenCalledOnce() + // Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser. + const second = owner() + view.rerender() + expect(pick).toHaveBeenCalledOnce() + await act(async () => { resolve('/tmp/project') }) + expect(second.onPicked).toHaveBeenCalledWith('/tmp/project') + expect(first.onPicked).not.toHaveBeenCalled() + }) + + it('reports null as cancellation and re-arms after the owner withdraws open', async () => { + const pick = vi.fn(async () => null as string | null) + const props = owner() + const view = render() + await act(async () => {}) + expect(props.onCancel).toHaveBeenCalledOnce() + expect(props.onPicked).not.toHaveBeenCalled() + // Withdraw and reopen: a fresh request runs a fresh pick. + view.rerender() + view.rerender() + await act(async () => {}) + expect(pick).toHaveBeenCalledTimes(2) + }) + + it('folds pick failures into onError messages', async () => { + const props = owner() + render( { throw new Error('no chooser installed') })} />) + await act(async () => {}) + expect(props.onError).toHaveBeenCalledWith('no chooser installed') + + const nonError = owner() + render( { throw 'denied' })} />) + await act(async () => {}) + expect(nonError.onError).toHaveBeenCalledWith('denied') + }) + + it('renders nothing while closed and while open', () => { + const closed = render( null)} />) + expect(closed.container.innerHTML).toBe('') + const opened = render( null)} />) + expect(opened.container.innerHTML).toBe('') + }) +}) diff --git a/packages/host/directory-picker-native/tsconfig.json b/packages/host/directory-picker-native/tsconfig.json index 64a32c3441..395595e836 100644 --- a/packages/host/directory-picker-native/tsconfig.json +++ b/packages/host/directory-picker-native/tsconfig.json @@ -1,19 +1,16 @@ { - "extends": "../../../tsconfig.base.json", + "extends": "../../../tsconfig.base.client.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib/types" + "outDir": "lib/types", + "types": [ + "node" + ] }, "include": [ "src" ], "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, { "path": "../directory-picker" }, @@ -22,6 +19,15 @@ }, { "path": "../../util/native-command" + }, + { + "path": "../../client/ui-slots" + }, + { + "path": "../../client/runtime" + }, + { + "path": "../../client/ui-workspace" } ] } diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts new file mode 100644 index 0000000000..4f280f8112 --- /dev/null +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 7ba7c09de0..3e5bae41b5 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/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/directory-picker/README.md -README.md: dcac8903522d53a8dd5fd346f124071f0f24b38e -README.zh.md: 5b7fc15513bf19722bd71bcbb30c6d187d6a71f5 +README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f +README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index dcac890352..8ef8889c87 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. +The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. 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). diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 5b7fc15513..8aefffa7b2 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。 +web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。 浏览原语以带类型的 `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)。 diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 7d711e9e28..b57a63ef98 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/native-command/README.md README.md: 7fc8b1f4640ef87ada62b6656854feb37080e4e6 -README.zh.md: 7c1cacb06d1d58601cc9e63c2ebd9c5f0ccb8159 +README.zh.md: 4bc66c4047194de03f236fa7591dad88e5c3fb57 diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 7c1cacb06d..4bc66c4047 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -4,7 +4,7 @@ 宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接派生可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时以附带退出 `code` 与两路已捕获输出的错误拒绝,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。 -它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.zh.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。 +它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。 它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b06cc5b19..d9fd918267 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2760,12 +2760,27 @@ importers: specifier: workspace:^ version: link:../../util/native-command devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 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) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/host/webserver: dependencies: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 87c2be5cfe..4128e4462b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -423,7 +423,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['directory-picker-native', 'directory-picker-browse'], consumers: ['apiproxy'], - note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; the gateway advertises the kind via host.describe.', + note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; each backend is dual-face, its browser half filling ui-workspace directory-flow slots (no wire advertisement).', }, { key: 'httpServer', diff --git a/tsconfig.client.json b/tsconfig.client.json index 9f67ab4af7..5eb8ffa5a6 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -19,6 +19,8 @@ "packages/client/*/src/css-modules.d.ts", "packages/client/*/tests/**/*.ts", "packages/client/*/tests/**/*.tsx", + "packages/host/directory-picker-native/tests/**/*.ts", + "packages/host/directory-picker-native/tests/**/*.tsx", "packages/client/tsdown.client.ts", "scripts/client-bundle-purity.spec.ts" ], @@ -27,6 +29,10 @@ // smoke policy). webserver has zero workspace deps and no cordis merge, // so it cannot drag host-side Context augmentation into this program. { "path": "./packages/host/webserver" }, + // Dual-face host leaf: the node half is the native picking backend, the + // browser half registers the picking flow into ui-workspace's slot — + // client-side Context merges keep it out of the host program. + { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/client/ui-slots" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/web-react" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index effb110c07..4ea1881acb 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -33,6 +33,7 @@ ], "exclude": [ "packages/client/**", + "packages/host/directory-picker-native/**", "scripts/client-bundle-purity.spec.ts" ], "references": [ @@ -168,7 +169,6 @@ { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker-browse" }, - { "path": "./packages/host/directory-picker-native" }, { "path": "./packages/host/webserver" }, { "path": "./packages/sdk/sdk-client" }, { "path": "./packages/sdk/helper" }, From 95a8e3f94992a9a9c1b02c7d4f2212e81d884334 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 22:29:34 +0800 Subject: [PATCH 11/26] =?UTF-8?q?fix(client,doc):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20flow-open=20busy=20gating,=20seam=20on=20the=20arch?= =?UTF-8?q?itecture=20map,=20browse=20gap=20documented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - While a picking flow is open (native chooser pending, browse dialog up) or its pick is being adopted, every other menu action disables: a late outcome must not race a concurrent selection or creation (ds-review-bot warning). - ctx.directoryPicker joins the architecture Capability Services map (both languages); neighboring rows condensed to keep the doc inside its ceiling. - directory-picker-browse documents that its client half lands in the next stacked PR: a -browse composition today hides the picking affordance (the documented empty-hole default) rather than misbehaving (ds-review-bot critical; the dialog itself ships in #821). --- docs/architecture.i18n.yaml | 4 ++-- docs/architecture.md | 17 +++++++++-------- docs/architecture.zh.md | 5 +++-- .../ui-workspace/src/client/WorkspacePicker.tsx | 11 ++++++++--- .../tests/workspace-picker.spec.tsx | 8 ++++++-- .../directory-picker-browse/README.i18n.yaml | 4 ++-- packages/host/directory-picker-browse/README.md | 1 + .../host/directory-picker-browse/README.zh.md | 1 + 8 files changed, 32 insertions(+), 19 deletions(-) diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index e56a3f91bb..c296e10fb1 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 054985ac5ea32a44b9daca3c1abfd58dcdc5d897 -architecture.zh.md: 84876faf2ae27069ba8bd026bcfbc56e32f65574 +architecture.md: 2ae982eba49b6dbd2365496915f9917071167813 +architecture.zh.md: abaef961504ff64dbcd1e8e8ba9bd002406fa7f4 diff --git a/docs/architecture.md b/docs/architecture.md index 054985ac5e..2ae982eba4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,27 +25,28 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | ctx key | Package family | Role | |---|---|---| -| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry and streaming model calls | -| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | singleton replay-aware request and surface pressure | +| `ctx.llm` | [`llm/`](../packages/llm/README.md) | adapter registry, streaming model calls | +| `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | replay-aware request and surface pressure | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | foreground/background command execution | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for the bash executors, the LSP host, and the ACP subagent backend | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | managed child-process trees for bash, LSP, and ACP subagent backends | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | owner-scoped persistent terminal sessions | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | same-world process confinement through argv wrapping and per-call policy | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | shared sandbox policy home | | `ctx.codeRuntime` | [`code-runtime/`](../packages/code-runtime/README.md) | model-written program execution | | `ctx.fs` | [`fs/`](../packages/fs/README.md) | filesystem provider primitives and policy events | | `ctx.lsp` | [`lsp/`](../packages/lsp/README.md) | semantic navigation registry | -| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry and progressive disclosure | +| `ctx.skills` | [`skill/`](../packages/skill/README.md) | skill provider registry, progressive disclosure | | `ctx.web` | [`web/`](../packages/web/README.md) | search/fetch provider registries | -| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction and optional model-free result pruning | +| `ctx.compact`, `ctx.toolResultPrune` | [`compact/`](../packages/compact/README.md)/[`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune/README.md) | summary compaction, optional model-free result pruning | | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.planMode` | [`plan/`](../packages/plan/README.md) | logged plan collaboration state | -| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry and generic `task_*` controls | +| `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace interface, SQLite FTS backend, workspace-authorized model tools | -| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks and one optional asynchronous provider | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | +| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks, one optional asynchronous provider | +| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI-host directory picking (`native`/`browse` interactions) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry of package-owned runtime checks | ## Event diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 84876faf2a..abaef96150 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -28,7 +28,7 @@ | `ctx.llm` | [`llm/`](../packages/llm/README.md) | 适配器注册表和模型流式调用 | | `ctx.tokenMeter` | [`llm/token-meter`](../packages/llm/token-meter/README.md) | 感知回放的单实例请求压力与表面压力 | | `ctx.bash` | [`bash/`](../packages/bash/README.md) | 前台和后台命令执行 | -| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash 执行器、LSP host 与 ACP subagent 后端使用的受管子进程树 | +| `ctx.subprocess` | [`subprocess/`](../packages/subprocess/README.md) | 供 bash、LSP 与 ACP subagent 后端使用的受管子进程树 | | `ctx.pty` | [`pty/`](../packages/pty/README.md) | 按 owner 隔离的持久化终端会话 | | `ctx.sandbox` | [`sandbox/`](../packages/sandbox/README.md) | 通过 argv 包装和逐调用策略限制同一执行环境内的进程 | | `ctx.sandboxPolicy` | [`sandbox/`](../packages/sandbox/README.md) | 共享沙箱策略归属点 | @@ -44,8 +44,9 @@ | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端、经工作区授权的模型工具 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 | +| `ctx.directoryPicker` | [`host/directory-picker`](../packages/host/directory-picker/README.md) | GUI 宿主目录选取(`native`/`browse` 交互) | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | ## 事件 diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index e98d6d4e26..83c06b369c 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -81,6 +81,11 @@ export function WorkspaceCreateFlow({ const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) const composingRef = useRef(false) + // One picking interaction at a time: while the flow is open (native chooser + // pending, browse dialog up) or its pick is being adopted, every other + // menu action stays disabled — a late outcome must not race a concurrent + // selection or creation. + const flowBusy = flowOpen || pickingFolder const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) @@ -91,9 +96,9 @@ export function WorkspaceCreateFlow({ // activation, and the menu re-renders on every toggle. const createEntries: MenuEntry[] = [ ...(hasDirectoryFlow() - ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }] + ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: flowBusy }] : []), - { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: flowBusy }, ] // With workspaces listed, the create actions pin below the scroll region // (divider + always visible); otherwise they ARE the menu. @@ -103,7 +108,7 @@ export function WorkspaceCreateFlow({ id: workspace.workspaceId, label: workspace.title, icon: , - disabled: pickingFolder, + disabled: flowBusy, })) : createEntries diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index dbbb444de2..61d353fe45 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -149,12 +149,16 @@ describe('WorkspacePicker', () => { expect(b.onPick).not.toHaveBeenCalled() }) - it('disables the create actions and reports busy to the flow while adopting', async () => { + it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => { let resolve!: (workspace: WorkspaceView) => void const pending = new Promise((settle) => { resolve = settle }) const created = workspace('adopted') - const b = mount([], vi.fn(() => pending)) + const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending)) chooseItem('Open local folder…') + // The flow is open but nothing is picked yet: a chooser pending on the + // host display must already block concurrent workspace actions. + expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(true) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true) act(() => { b.probe.owner!.onPicked('/tmp/project') }) expect(b.probe.owner!.busy).toBe(true) expect(screen.getByRole('menuitem', { name: 'Open local folder…' }).disabled).toBe(true) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 34c3e35a79..150a9c2323 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 688a60894cb0ab066a6d501e4df310f8d31bfb5f -README.zh.md: c19bccc2ff9268cb7a6c931da4671bebc9f76b0c +README.md: 632dfec3dac57cac9ea7a02225959fe6e3acf6a0 +README.zh.md: 81a1eb53eac0d3b5a1ef8f2f98c4359ef6a4c5fd diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 688a60894c..632dfec3da 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -16,6 +16,7 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work +- **No client half yet** — the in-app browsing dialog that consumes these primitives lands in the next PR of this stack; until then a `-browse` composition hides the picking affordance entirely (ui-workspace's documented empty-hole default) and the listing/creation RPCs go unconsumed. - **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 index c19bccc2ff..81a1eb53ea 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -16,6 +16,7 @@ ## 已知限制与延期工作 +- **尚无 client half**——消费这些原语的应用内浏览对话框在本栈的下一个 PR 落地;在那之前 `-browse` 组合会完全隐藏选目录入口(ui-workspace 文档化的空洞默认行为),列举/创建 RPC 无消费者。 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 From 7c9d688a825a5b8eb754cb0e9feccedb9d7ac0ed Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 28 Jul 2026 22:37:19 +0800 Subject: [PATCH 12/26] test(host): cover the native flow's re-arm guard A fresh injected face while the same request is open re-fires the effect; the armed guard must not relaunch the chooser (the uncovered branch CI's per-file gate flagged). --- .../host/directory-picker-native/tests/client-flow.spec.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index cf2cb96679..fe4c30fc5d 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -83,6 +83,11 @@ describe('directory-picker-native client half', () => { const second = owner() view.rerender() expect(pick).toHaveBeenCalledOnce() + // Even a fresh injected face (re-registration re-runs the inject factory) + // must not relaunch while the same request is still open. + const replacedPick = vi.fn(() => new Promise(() => {})) + view.rerender() + expect(replacedPick).not.toHaveBeenCalled() await act(async () => { resolve('/tmp/project') }) expect(second.onPicked).toHaveBeenCalledWith('/tmp/project') expect(first.onPicked).not.toHaveBeenCalled() From b6762d20a206ac402f2c6e285fd8e11a6eafac31 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 01:29:08 +0800 Subject: [PATCH 13/26] fix(host): roll back the surviving deferral when flow-registration setup fails halfway A declared-but-occupied second hole registers synchronously, so the pair construction can throw after the first deferral installed its subscription; that orphan then fires against the failed fiber's inactive context. The effect now disposes already-created deferrals before rethrowing (ds-review-bot on the browse twin; same shape here). --- .../src/client/index.ts | 19 +++++++++----- .../tests/client-flow.spec.tsx | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index 29b7876d2f..c716b97b0c 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -62,12 +62,19 @@ export const inject = ['slots', 'workspaces'] export function apply(ctx: ClientContext): void { const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) ctx.effect(() => { - const deferred = [ - deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow)), - deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow)), - ] + // Constructing the pair can throw halfway (a declared hole already + // occupied registers synchronously): roll the earlier deferral back so + // no live subscription outlives the failed fiber. + const deferred: ReturnType[] = [] + try { + deferred.push(deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () => + ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow))) + deferred.push(deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () => + ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow))) + } catch (error) { + for (const entry of deferred) entry.dispose() + throw error + } return () => { for (const entry of deferred) entry.dispose() } }, 'directory-picker-native: flow registrations') } diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index fe4c30fc5d..c92b987f20 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -55,6 +55,31 @@ describe('directory-picker-native client half', () => { for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) }) + it('rolls back the first deferral when the second hole is already occupied', async () => { + const b = await bench() + b.declare() + // Foreign occupant in the SECOND registered hole: the pair construction + // throws after the first deferral installed its subscription. + b.slots.register({ name: HOLES[1] } as never, () => null) + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await expect(fiber.await()).rejects.toThrow(/already has a registration/) + // A leaked first deferral would now race this probe registration and + // throw from its orphaned subscription against the HERO hole; the + // rollback leaves only the activation failure itself (cordis re-raises + // the apply throw as a late rejection — installFailLoud's contract). + const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([]) + disposeProbe() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + it('rejects a second flow occupant at load (single-kind hole)', async () => { const b = await bench() b.declare() From 663ac4b50b193f247e088d69c74c8a04dac744e7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 01:49:58 +0800 Subject: [PATCH 14/26] fix(client): deferRegistration rolls its subscription back when construction throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The immediate registration attempt runs after the subscription is installed; a synchronous failure (declared slot already occupied) escaped the constructor without returning a handle, leaving a subscription owned by nothing — later ledger flushes would fire it against a failed fiber. Construction failure now unsubscribes before rethrowing, with a dedicated deferred.spec covering both arms (ds-review-bot on the flow registrations; the effect-level rollback in the flow packages keeps covering earlier successfully-constructed deferrals). --- packages/client/ui-slots/src/deferred.ts | 12 ++++- .../client/ui-slots/tests/deferred.spec.ts | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/client/ui-slots/tests/deferred.spec.ts diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts index af85c27f97..9b68c4d206 100644 --- a/packages/client/ui-slots/src/deferred.ts +++ b/packages/client/ui-slots/src/deferred.ts @@ -37,6 +37,8 @@ export interface DeferredRegistration { * @param component - the component whose ledger presence marks "registered". * @param register - performs the actual registration; returns its disposer. * @returns the deferral handle (dispose in the owning effect's disposer). + * @throws the immediate registration's failure, after removing the + * just-installed subscription — a throwing construction leaves nothing live. */ export function deferRegistration( registry: DeferralRegistry, @@ -51,7 +53,15 @@ export function deferRegistration( dispose = register() } const unsubscribe = registry.subscribe(name, () => { tryRegister() }) - tryRegister() + try { + tryRegister() + } catch (error) { + // A synchronous registration failure (the declared slot is already + // occupied) must not leave the just-installed subscription behind: the + // caller receives no handle to dispose it through. + unsubscribe() + throw error + } return { refresh() { dispose?.() diff --git a/packages/client/ui-slots/tests/deferred.spec.ts b/packages/client/ui-slots/tests/deferred.spec.ts new file mode 100644 index 0000000000..2153e52181 --- /dev/null +++ b/packages/client/ui-slots/tests/deferred.spec.ts @@ -0,0 +1,44 @@ +// deferRegistration lifecycle: declaration-aware registration, HMR +// re-registration, and — the failure contract — no subscription survives a +// construction that throws synchronously (an already-occupied single slot). +import { describe, expect, it, vi } from 'vitest' +import { deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' + +// Shares the merges declared by core.spec.ts (same program); reuse its keys. +const HOLE = 'test.single' as const + +function declared(): SlotCore { + const core = new SlotCore() + core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) + return core +} + +describe('deferRegistration', () => { + it('registers immediately under an existing declaration and disposes cleanly', () => { + const core = declared() + const component = (): null => null + const handle = deferRegistration(core, HOLE, component, () => + core.register({ name: HOLE } as never, component as never)) + expect(core.entries(HOLE)).toHaveLength(1) + handle.dispose() + expect(core.entries(HOLE)).toHaveLength(0) + }) + + it('drops its subscription when the immediate registration throws', async () => { + const core = declared() + const foreign = (): null => null + const disposeForeign = core.register({ name: HOLE } as never, foreign as never) + const component = (): null => null + const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) + // The single hole is occupied: the immediate attempt throws out of the + // constructor, and the caller never receives a handle to dispose. + expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/) + expect(register).toHaveBeenCalledOnce() + // The subscription rolled back with it: freeing the hole flushes a + // notification that must not resurrect the failed registration. + disposeForeign() + await Promise.resolve() + expect(register).toHaveBeenCalledOnce() + expect(core.entries(HOLE)).toHaveLength(0) + }) +}) From e7c9877ac5a5f8e89808fe1bc96c8f519b47ee40 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 02:11:31 +0800 Subject: [PATCH 15/26] fix(build,doc): native paths aliases; the note states browse's client half is stacked - tsconfig.base.json still aliased the removed directory-picker-dialog and offered no mapping for -native: on a clean tree, package-name imports and source launches fell through to a missing lib/ until a build ran. - The seam note (and the generated seam prose) no longer describe the browse browser half as shipped here: it lands in the stacked follow-up, and a -browse composition meanwhile shows the documented empty-hole default (ds-review-bot). --- .../2026-07-28-directory-picker-capability-seam.i18n.yaml | 4 ++-- .../2026-07-28-directory-picker-capability-seam.md | 2 +- .../2026-07-28-directory-picker-capability-seam.zh.md | 2 +- docs/capability-seams.md | 2 +- scripts/gen-doc-graphs.ts | 2 +- tsconfig.base.json | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) 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 index b49544ddfb..f1bd3fd344 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ce5a2695345e29db5739df206965720559783ce3 -2026-07-28-directory-picker-capability-seam.zh.md: 5b73c3c48493d4f178a523db19bc124eda9c7cca +2026-07-28-directory-picker-capability-seam.md: 268caad197985d052affb17fe2a7644cd6722075 +2026-07-28-directory-picker-capability-seam.zh.md: 573bfc19bb0f5eeae3f1e851e733fd2498a986bf 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 index ce5a269534..268caad197 100644 --- 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 @@ -12,7 +12,7 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. -**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Each backend package is **dual-face**: its browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app browsing dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` ships here with a renderless occupant driving `host.pickDirectory`; `-browse`'s half (the in-app browsing dialog) lands in the stacked follow-up PR, until which a `-browse` composition shows no picking affordance (the documented empty-hole default). The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: 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 index 5b73c3c484..573bfc19bb 100644 --- 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 @@ -12,7 +12,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick 在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 -**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。每个后端包都是**双面包**:其 browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内浏览对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 在本 PR 随附驱动 `host.pickDirectory` 的无渲染占用者;`-browse` 的那一半(应用内浏览对话框)在栈中的后续 PR 落地,在那之前 `-browse` 组合不显示选目录入口(文档化的空洞默认行为)。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 并入本决策的位置与策略裁决: diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 7a2420f4ab..43f5b20716 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -369,7 +369,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-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; each backend is dual-face, its browser half filling ui-workspace directory-flow slots (no wire advertisement). | +| `ctx.directoryPicker` | `seam` | `directory-picker` | `directory-picker-native`, `directory-picker-browse` | `apiproxy` | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | | `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/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 95dac64feb..8846cca3eb 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -431,7 +431,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['directory-picker-native', 'directory-picker-browse'], consumers: ['apiproxy'], - note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; each backend is dual-face, its browser half filling ui-workspace directory-flow slots (no wire advertisement).', + note: 'Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement).', }, { key: 'httpServer', diff --git a/tsconfig.base.json b/tsconfig.base.json index 9e30974454..6adcd565cc 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -111,8 +111,8 @@ "@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-directory-picker-native": ["./packages/host/directory-picker-native/src"], + "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/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"], From 3f23347d10227a0de3578b9f7725767a092c8584 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 02:24:27 +0800 Subject: [PATCH 16/26] fix(host): the native flow discards chooser settlements after unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An HMR replacement left the old instance's pick promise able to adopt a second path through the shared owner callbacks; settlements now check a component-lifetime ref (an injected-face identity change alone keeps the pending settlement — the host-side dialog is unchanged, and the wire has no per-request abort). Both settlement arms covered (ds-review-bot). --- .../src/client/index.ts | 19 +++++++++++++-- .../tests/client-flow.spec.tsx | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index c716b97b0c..3ff6a4b3f9 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -35,6 +35,15 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI // latest handlers, not the ones captured when the chooser opened. const outcome = useRef(props) outcome.current = props + // Unmount (HMR replacing the occupant) discards settlements wholesale: the + // dead instance must neither adopt a path nor drive the owner's error + // surface. The wire carries no per-request abort, so the host-side chooser + // survives until answered — its answer just lands nowhere; the replacement + // instance re-arms under the owner's still-open request. An injected-face + // identity change alone (re-registration) keeps the pending settlement: + // the chooser on the host display is still the same dialog. + const alive = useRef(true) + useEffect(() => () => { alive.current = false }, []) useEffect(() => { if (!open) { armed.current = false @@ -43,8 +52,14 @@ export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowI if (armed.current) return armed.current = true pick().then( - (path) => { if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) }, - (reason: unknown) => { outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) }, + (path) => { + if (!alive.current) return + if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) + }, + (reason: unknown) => { + if (!alive.current) return + outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) + }, ) }, [open, pick]) return null diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index c92b987f20..656aea74df 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -118,6 +118,30 @@ describe('directory-picker-native client half', () => { expect(first.onPicked).not.toHaveBeenCalled() }) + it('discards a settlement that lands after the flow unmounted', async () => { + let resolve!: (path: string | null) => void + const pick = vi.fn(() => new Promise((settle) => { resolve = settle })) + const props = owner() + const view = render() + expect(pick).toHaveBeenCalledOnce() + view.unmount() + // The dead instance must neither adopt nor error; the owner's callbacks + // stay untouched by the orphaned chooser's answer. + await act(async () => { resolve('/tmp/late') }) + expect(props.onPicked).not.toHaveBeenCalled() + expect(props.onCancel).not.toHaveBeenCalled() + expect(props.onError).not.toHaveBeenCalled() + + // The failure arm is discarded the same way. + let reject!: (reason: unknown) => void + const failing = vi.fn(() => new Promise((_settle, rejectPick) => { reject = rejectPick })) + const late = owner() + const failingView = render() + failingView.unmount() + await act(async () => { reject(new Error('too late')) }) + expect(late.onError).not.toHaveBeenCalled() + }) + it('reports null as cancellation and re-arms after the owner withdraws open', async () => { const pick = vi.fn(async () => null as string | null) const props = owner() From 7114cc7475d4c3cb83bb556fc98e991b8c76a8ca Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 02:36:08 +0800 Subject: [PATCH 17/26] fix(client): flow occupancy is a subscribed source; an unloading occupant withdraws the open flow hasDirectoryFlow was a plain per-render read: a flow plugin unloading (HMR) while its dialog was open left flowOpen stuck with nobody to cancel, permanently disabling the workspace actions. Occupancy now rides useSyncExternalStore over the hole's registration subscription, and an empty hole withdraws an active flow; the menu entry also reacts to activation without a reopen (ds-review-bot). --- .../src/client/WorkspaceBrowser.tsx | 2 + .../src/client/WorkspacePicker.tsx | 23 +++++--- .../ui-workspace/src/client/contract/slots.ts | 13 +++-- .../client/ui-workspace/src/client/index.ts | 2 + .../client/ui-workspace/tests/apply.spec.ts | 5 ++ .../tests/workspace-browser.spec.tsx | 1 + .../tests/workspace-picker.spec.tsx | 53 ++++++++++++++----- 7 files changed, 77 insertions(+), 22 deletions(-) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 61da0cc1ea..9e7eb89872 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -254,6 +254,7 @@ export function WorkspaceBrowser({ insertSessionBefore, createWorkspace, hasDirectoryFlow, + subscribeDirectoryFlow, renderSlot, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) @@ -373,6 +374,7 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} hasDirectoryFlow={hasDirectoryFlow} + subscribeDirectoryFlow={subscribeDirectoryFlow} renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)} createOnly side="right" diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 83c06b369c..cc4989c463 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -7,7 +7,7 @@ * opens the flow, adopts the picked path, and owns the error surface. */ import type { ReactNode, RefObject } from 'react' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -33,8 +33,10 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Whether this surface's directory-flow hole is occupied (read per menu render; empty hides the local-folder entry). */ + /** Whether this surface's directory-flow hole is occupied (empty hides the local-folder entry). */ hasDirectoryFlow: () => boolean + /** Registration-change subscription for the same hole (the uSES pair of hasDirectoryFlow). */ + subscribeDirectoryFlow: (listener: () => void) => () => void /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */ renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode /** A real Workspace was picked or created. */ @@ -60,6 +62,7 @@ export function WorkspaceCreateFlow({ useWorkspaces, createWorkspace, hasDirectoryFlow, + subscribeDirectoryFlow, renderDirectoryFlow, onPick, onClose, @@ -91,11 +94,17 @@ export function WorkspaceCreateFlow({ && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) // The occupied hole gates the picking affordance: with no composed flow the - // entry simply is not there (the seam's documented no-flow default). Read - // per render while the menu is open — registrations land through plugin - // activation, and the menu re-renders on every toggle. + // entry simply is not there (the seam's documented no-flow default). The + // subscription keeps occupancy live: flow plugins activate (and HMR-reload) + // independently of this menu's renders. + const flowAvailable = useSyncExternalStore(subscribeDirectoryFlow, hasDirectoryFlow) + // An occupant that unloads mid-interaction leaves nobody to cancel: an + // open flow over an empty hole withdraws so the menu actions come back. + useEffect(() => { + if (!flowAvailable) setFlowOpen(false) + }, [flowAvailable]) const createEntries: MenuEntry[] = [ - ...(hasDirectoryFlow() + ...(flowAvailable ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: flowBusy }] : []), { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: flowBusy }, @@ -288,6 +297,7 @@ export function WorkspacePicker({ onClose, createWorkspace, hasDirectoryFlow, + subscribeDirectoryFlow, renderSlot, }: WorkspacePickerProps) { return ( @@ -297,6 +307,7 @@ export function WorkspacePicker({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} hasDirectoryFlow={hasDirectoryFlow} + subscribeDirectoryFlow={subscribeDirectoryFlow} renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)} selectedId={selectedId} onPick={onPick} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index fce3bd9b46..34c59d764f 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -62,11 +62,18 @@ export type DirectoryFlowSlotName = /** Directory-picking share both trigger surfaces consume. */ export type DirectoryPickingInjected = { /** - * Whether this surface's directory-flow hole is occupied — read when the - * menu opens; an empty hole hides the "Open local folder…" entry (the - * no-flow composition simply has no picking affordance). + * Whether this surface's directory-flow hole is occupied — an empty hole + * hides the "Open local folder…" entry (the no-flow composition simply has + * no picking affordance). */ hasDirectoryFlow: () => boolean + /** + * Subscribe to the hole's registration changes (the uSES pair of + * {@link hasDirectoryFlow}): the trigger surface withdraws an open flow + * whose occupant unloaded mid-interaction — nobody is left to cancel it. + * @returns the unsubscriber. + */ + subscribeDirectoryFlow: (listener: () => void) => () => void } /** diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 4fd9edb38c..9dbe2ed2e6 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -49,10 +49,12 @@ export function apply(ctx: ClientContext): void { }, createWorkspace: input => ctx.workspaces.create(input), hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0, + subscribeDirectoryFlow: listener => ctx.slots.subscribe('sidebar.workspaces.directoryFlow', listener), }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0, + subscribeDirectoryFlow: listener => ctx.slots.subscribe('conversation.hero.workspace.directoryFlow', listener), }) // Declaration-aware registration (deferRegistration): each owner's // declaring apply may activate after this one, and a register into an diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 4ef3738d01..7d5dac71de 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -91,11 +91,16 @@ describe('ui-workspace apply', () => { expect(browser.hasDirectoryFlow()).toBe(false) expect(picker.hasDirectoryFlow()).toBe(false) // A flow occupant flips exactly its own surface. + const notified = vi.fn() + const unsubscribe = browser.subscribeDirectoryFlow(notified) const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null) expect(browser.hasDirectoryFlow()).toBe(true) expect(picker.hasDirectoryFlow()).toBe(false) + await Promise.resolve() + expect(notified).toHaveBeenCalled() dispose() expect(browser.hasDirectoryFlow()).toBe(false) + unsubscribe() }) it('unregisters every entry on teardown', async () => { diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e4b038b0f3..f1d43997f7 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -60,6 +60,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), hasDirectoryFlow: () => true, + subscribeDirectoryFlow: () => () => {}, renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, ...overrides, } diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 61d353fe45..1f81ac59bf 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -50,10 +50,27 @@ function flowProbe() { return { probe, renderSlot } } +/** Manual occupancy source: flip() drives the uSES subscription like a real registration change. */ +function occupancySource(initial = true) { + let occupied = initial + const listeners = new Set<() => void>() + return { + hasDirectoryFlow: () => occupied, + subscribeDirectoryFlow: (listener: () => void) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + flip: (next: boolean) => { + occupied = next + for (const listener of [...listeners]) listener() + }, + } +} + function mount( items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')], createWorkspace = vi.fn(), - hasDirectoryFlow: () => boolean = () => true, + occupancy = occupancySource(), ) { const onPick = vi.fn() const onClose = vi.fn() @@ -68,7 +85,8 @@ function mount( onPick={onPick} onClose={onClose} createWorkspace={createWorkspace} - hasDirectoryFlow={hasDirectoryFlow} + hasDirectoryFlow={occupancy.hasDirectoryFlow} + subscribeDirectoryFlow={occupancy.subscribeDirectoryFlow} renderSlot={renderSlot} /> ) @@ -76,7 +94,7 @@ function mount( renderPicker(items), ) return { - view, onPick, onClose, createWorkspace, probe, + view, onPick, onClose, createWorkspace, probe, occupancy, rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) }, } } @@ -247,7 +265,7 @@ describe('WorkspacePicker', () => { true} renderSlot={renderSlot} + hasDirectoryFlow={() => true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -262,26 +280,35 @@ describe('WorkspacePicker', () => { true} renderSlot={renderSlot} + hasDirectoryFlow={() => true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') }) it('hides the folder entry while the directory-flow hole is empty', () => { - mount([], vi.fn(), () => false) + mount([], vi.fn(), occupancySource(false)) expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy() expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() }) - it('shows the folder entry once the hole reports an occupant on a later render', () => { - let occupied = false - const b = mount([], vi.fn(), () => occupied) + it('shows the folder entry when a flow package activates after the first paint', () => { + const b = mount([], vi.fn(), occupancySource(false)) expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() - // A flow package activating after the first paint is observed on the - // next render — the same cadence as reopening the menu. - occupied = true - b.rerenderItems([]) + // Registration changes flow through the subscription, no re-render needed. + act(() => { b.occupancy.flip(true) }) expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() }) + + it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => { + const b = mount([]) + chooseItem('Open local folder…') + expect(screen.getByTestId('directory-flow')).toBeTruthy() + // The flow plugin unloads mid-interaction (HMR): nobody is left to + // cancel, so the owner withdraws and the actions come back. + act(() => { b.occupancy.flip(false) }) + expect(b.probe.owner!.open).toBe(false) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false) + expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull() + }) }) From a9b8fa25858b4406fd3b6850e9b2371a4026b699 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 02:50:45 +0800 Subject: [PATCH 18/26] =?UTF-8?q?fix(client,host):=20review=20round=20?= =?UTF-8?q?=E2=80=94=20hooks-compartment=20occupancy,=20StrictMode=20re-ar?= =?UTF-8?q?m,=20internal=20flow=20module,=20honest=20swap=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Directory-flow occupancy moves onto the inject face's reserved hooks compartment: apply publishes a stable observable per surface and the renderer binds useDirectoryFlow — no hand-rolled component subscriptions (the client contract's channel for registrant-private reactive facts). - The native flow's alive guard re-arms in effect setup: StrictMode's development replay ran the cleanup once and every later outcome was discarded. - NativeDirectoryFlow moves to a package-internal module; ./client exports only the Loader surface, tests import the internal module directly. - The composition swap comment no longer advertises -browse as a complete swap before its dialog lands (stacked follow-up). --- apps/cli/cordis.yml | 6 +- .../src/client/WorkspaceBrowser.tsx | 6 +- .../src/client/WorkspacePicker.tsx | 24 +++---- .../ui-workspace/src/client/contract/slots.ts | 39 ++++++----- .../client/ui-workspace/src/client/index.ts | 17 +++-- .../client/ui-workspace/tests/apply.spec.ts | 14 ++-- .../tests/workspace-browser.spec.tsx | 3 +- .../tests/workspace-picker.spec.tsx | 19 +++--- .../src/client/flow.ts | 65 +++++++++++++++++++ .../src/client/index.ts | 58 ++--------------- .../tests/client-flow.spec.tsx | 3 +- 11 files changed, 140 insertions(+), 114 deletions(-) create mode 100644 packages/host/directory-picker-native/src/client/flow.ts diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml index 559c5bf198..bd60fe2273 100644 --- a/apps/cli/cordis.yml +++ b/apps/cli/cordis.yml @@ -252,8 +252,10 @@ # mapping target (user config overrides these engineering defaults). # Directory-picking package, dual-face: the node half serves the gateway's # host.* picker RPCs, the browser half fills ui-workspace's directory-flow -# slots — one row composes the whole interaction. Swap point: mount -# '-browse' instead for the in-app browser (remote-capable). +# slots — one row composes the whole interaction. Swap point: '-browse' +# serves remote-capable listing primitives; its in-app dialog (and this +# row's flip) land in the stacked follow-up PR — until then a '-browse' +# composition has no picking affordance. - id: directory-picker name: '@deepseek-ai/dsh-host-directory-picker-native' diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 9e7eb89872..7effff36b5 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -253,8 +253,7 @@ export function WorkspaceBrowser({ deleteWorkspace, insertSessionBefore, createWorkspace, - hasDirectoryFlow, - subscribeDirectoryFlow, + useDirectoryFlow, renderSlot, }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) @@ -373,8 +372,7 @@ export function WorkspaceBrowser({ anchorRef={wsPlusRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} - hasDirectoryFlow={hasDirectoryFlow} - subscribeDirectoryFlow={subscribeDirectoryFlow} + useDirectoryFlow={useDirectoryFlow} renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)} createOnly side="right" diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index cc4989c463..fa6290e1f1 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -7,7 +7,7 @@ * opens the flow, adopts the picked path, and owns the error surface. */ import type { ReactNode, RefObject } from 'react' -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -15,6 +15,7 @@ import { WorkspaceCreateError, type WorkspaceId, type WorkspaceListState, type WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' +import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -33,10 +34,8 @@ export interface WorkspaceCreateFlowProps { useWorkspaces: (selector: (state: WorkspaceListState) => S) => S /** Create or adopt a real Host Workspace. */ createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Whether this surface's directory-flow hole is occupied (empty hides the local-folder entry). */ - hasDirectoryFlow: () => boolean - /** Registration-change subscription for the same hole (the uSES pair of hasDirectoryFlow). */ - subscribeDirectoryFlow: (listener: () => void) => () => void + /** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */ + useDirectoryFlow: SnapshotSelectorHook /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */ renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode /** A real Workspace was picked or created. */ @@ -61,8 +60,7 @@ export function WorkspaceCreateFlow({ anchorRef, useWorkspaces, createWorkspace, - hasDirectoryFlow, - subscribeDirectoryFlow, + useDirectoryFlow, renderDirectoryFlow, onPick, onClose, @@ -95,9 +93,9 @@ export function WorkspaceCreateFlow({ // The occupied hole gates the picking affordance: with no composed flow the // entry simply is not there (the seam's documented no-flow default). The - // subscription keeps occupancy live: flow plugins activate (and HMR-reload) - // independently of this menu's renders. - const flowAvailable = useSyncExternalStore(subscribeDirectoryFlow, hasDirectoryFlow) + // framework-bound hook keeps occupancy live: flow plugins activate (and + // HMR-reload) independently of this menu's renders. + const flowAvailable = useDirectoryFlow(occupied => occupied) // An occupant that unloads mid-interaction leaves nobody to cancel: an // open flow over an empty hole withdraws so the menu actions come back. useEffect(() => { @@ -296,8 +294,7 @@ export function WorkspacePicker({ onPick, onClose, createWorkspace, - hasDirectoryFlow, - subscribeDirectoryFlow, + useDirectoryFlow, renderSlot, }: WorkspacePickerProps) { return ( @@ -306,8 +303,7 @@ export function WorkspacePicker({ anchorRef={anchorRef} useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} - hasDirectoryFlow={hasDirectoryFlow} - subscribeDirectoryFlow={subscribeDirectoryFlow} + useDirectoryFlow={useDirectoryFlow} renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)} selectedId={selectedId} onPick={onPick} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 34c59d764f..63b60d8051 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -19,7 +19,7 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -59,21 +59,24 @@ export type DirectoryFlowSlotName = | 'conversation.hero.workspace.directoryFlow' | 'sidebar.workspaces.directoryFlow' -/** Directory-picking share both trigger surfaces consume. */ +/** + * Directory-picking share both trigger surfaces consume. Occupancy rides the + * inject face's reserved `hooks` compartment: the renderer binds the source + * into the `useDirectoryFlow` selector hook, so an empty hole hides the + * "Open local folder…" entry reactively and the surface withdraws an open + * flow whose occupant unloaded mid-interaction (nobody is left to cancel). + */ export type DirectoryPickingInjected = { - /** - * Whether this surface's directory-flow hole is occupied — an empty hole - * hides the "Open local folder…" entry (the no-flow composition simply has - * no picking affordance). - */ - hasDirectoryFlow: () => boolean - /** - * Subscribe to the hole's registration changes (the uSES pair of - * {@link hasDirectoryFlow}): the trigger surface withdraws an open flow - * whose occupant unloaded mid-interaction — nobody is left to cancel it. - * @returns the unsubscriber. - */ - subscribeDirectoryFlow: (listener: () => void) => () => void + hooks: { + /** True while this surface's directory-flow hole is occupied. */ + directoryFlow: HostObservable + } +} + +/** Component-side view of the picking share: the bound occupancy selector hook. */ +export type DirectoryPickingHooks = { + /** Selector hook over this surface's directory-flow occupancy. */ + useDirectoryFlow: SnapshotSelectorHook } /** @@ -109,7 +112,8 @@ export type WorkspaceBrowserProps = PropsRuntime<'sidebar.workspaces'> & PropsRenderSlots<'sidebar.workspaces.directoryFlow'> & PropsStore> - & WorkspaceBrowserInjected + & Omit + & DirectoryPickingHooks /** * Picker-private injected share. Pick semantics remain in the owner's onPick @@ -129,4 +133,5 @@ export type WorkspacePickerInjected = DirectoryPickingInjected & { export type WorkspacePickerProps = PropsRuntime<'conversation.hero.workspace'> & PropsRenderSlots<'conversation.hero.workspace.directoryFlow'> - & WorkspacePickerInjected + & Omit + & DirectoryPickingHooks diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 9dbe2ed2e6..1cf5a7ae5a 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -9,6 +9,7 @@ * packages/client/AGENTS.md. */ import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' import { createWorkspaceViewStore } from './stores.ts' @@ -16,7 +17,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' export type { - DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingInjected, + DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected, WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, } from './contract/slots.ts' @@ -37,6 +38,14 @@ export const inject = ['slots', 'sessions', 'workspaces'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + // Stable per-surface occupancy sources (the renderer's hook cache keys by + // source identity): true while the surface's directory-flow hole is filled. + const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable => ({ + getSnapshot: () => ctx.slots.entries(hole).length > 0, + subscribe: listener => ctx.slots.subscribe(hole, listener), + }) + const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') + const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session rides // the runtime's shared action (recent-Workspace projection inside). @@ -48,13 +57,11 @@ export function apply(ctx: ClientContext): void { await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => ctx.workspaces.create(input), - hasDirectoryFlow: () => ctx.slots.entries('sidebar.workspaces.directoryFlow').length > 0, - subscribeDirectoryFlow: listener => ctx.slots.subscribe('sidebar.workspaces.directoryFlow', listener), + hooks: { directoryFlow: browserFlowSource }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => ctx.workspaces.create(input), - hasDirectoryFlow: () => ctx.slots.entries('conversation.hero.workspace.directoryFlow').length > 0, - subscribeDirectoryFlow: listener => ctx.slots.subscribe('conversation.hero.workspace.directoryFlow', listener), + hooks: { directoryFlow: pickerFlowSource }, }) // Declaration-aware registration (deferRegistration): each owner's // declaring apply may activate after this one, and a register into an diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 7d5dac71de..817416663e 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -88,18 +88,18 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() - expect(browser.hasDirectoryFlow()).toBe(false) - expect(picker.hasDirectoryFlow()).toBe(false) - // A flow occupant flips exactly its own surface. + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) + expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) + // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() - const unsubscribe = browser.subscribeDirectoryFlow(notified) + const unsubscribe = browser.hooks.directoryFlow.subscribe(notified) const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null) - expect(browser.hasDirectoryFlow()).toBe(true) - expect(picker.hasDirectoryFlow()).toBe(false) + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true) + expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) await Promise.resolve() expect(notified).toHaveBeenCalled() dispose() - expect(browser.hasDirectoryFlow()).toBe(false) + expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) unsubscribe() }) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index f1d43997f7..a1d6d4ffce 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -59,8 +59,7 @@ function mount(overrides: Partial = {}) { deleteWorkspace: vi.fn(async () => {}), insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), - hasDirectoryFlow: () => true, - subscribeDirectoryFlow: () => () => {}, + useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, ...overrides, } diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 1f81ac59bf..18ddd8b1fd 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -6,6 +6,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client' import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' afterEach(cleanup) @@ -50,16 +51,19 @@ function flowProbe() { return { probe, renderSlot } } -/** Manual occupancy source: flip() drives the uSES subscription like a real registration change. */ +/** Manual occupancy source bound like the renderer would: flip() drives the hook like a real registration change. */ function occupancySource(initial = true) { let occupied = initial const listeners = new Set<() => void>() - return { - hasDirectoryFlow: () => occupied, - subscribeDirectoryFlow: (listener: () => void) => { + const useDirectoryFlow = bindSnapshotSelector({ + getSnapshot: () => occupied, + subscribe: (listener: () => void) => { listeners.add(listener) return () => { listeners.delete(listener) } }, + }) + return { + useDirectoryFlow, flip: (next: boolean) => { occupied = next for (const listener of [...listeners]) listener() @@ -85,8 +89,7 @@ function mount( onPick={onPick} onClose={onClose} createWorkspace={createWorkspace} - hasDirectoryFlow={occupancy.hasDirectoryFlow} - subscribeDirectoryFlow={occupancy.subscribeDirectoryFlow} + useDirectoryFlow={occupancy.useDirectoryFlow} renderSlot={renderSlot} /> ) @@ -265,7 +268,7 @@ describe('WorkspacePicker', () => { true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot} + useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} />, ) expect(screen.queryByRole('menu')).toBeNull() @@ -280,7 +283,7 @@ describe('WorkspacePicker', () => { true} subscribeDirectoryFlow={() => () => {}} renderSlot={renderSlot} + useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} />, ) expect(screen.getByRole('status').textContent).toBe('Loading workspaces…') diff --git a/packages/host/directory-picker-native/src/client/flow.ts b/packages/host/directory-picker-native/src/client/flow.ts new file mode 100644 index 0000000000..14f705d071 --- /dev/null +++ b/packages/host/directory-picker-native/src/client/flow.ts @@ -0,0 +1,65 @@ +/** + * The native picking occupant (package-internal; the `./client` surface + * exposes only the Loader exports). Same-package tests exercise it directly + * through this module. + */ +import { useEffect, useRef } from 'react' +import type { ReactElement } from 'react' +// Type-only: the owner contract of the directory-flow holes. +import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' + +/** Injected face: the wire call the flow drives (bound in apply's closure). */ +export interface NativeFlowInjected { + /** Ask the local Host to open its native single-directory chooser. */ + pick: () => Promise +} + +/** + * Renderless flow occupant: each rising `open` edge runs exactly one pick and + * reports exactly one outcome; the ref arms once per open so re-renders (and + * an adoption keeping `open` true while `busy`) never launch a second + * chooser. The owner withdrawing `open` re-arms the next request. + * @param props - owner conversation plus the injected pick call. + * @returns nothing — the native chooser renders on the host display. + */ +export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null { + const { open, pick } = props + const armed = useRef(false) + // Callbacks ride a ref so the settled pick reports through the owner's + // latest handlers, not the ones captured when the chooser opened. + const outcome = useRef(props) + outcome.current = props + // Unmount (HMR replacing the occupant) discards settlements wholesale: the + // dead instance must neither adopt a path nor drive the owner's error + // surface. The wire carries no per-request abort, so the host-side chooser + // survives until answered — its answer just lands nowhere; the replacement + // instance re-arms under the owner's still-open request. An injected-face + // identity change alone (re-registration) keeps the pending settlement: + // the chooser on the host display is still the same dialog. + const alive = useRef(true) + useEffect(() => { + // StrictMode's development replay runs the cleanup once before the real + // lifetime: re-arm on setup or every outcome would be discarded. + alive.current = true + return () => { alive.current = false } + }, []) + useEffect(() => { + if (!open) { + armed.current = false + return + } + if (armed.current) return + armed.current = true + pick().then( + (path) => { + if (!alive.current) return + if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) + }, + (reason: unknown) => { + if (!alive.current) return + outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) + }, + ) + }, [open, pick]) + return null +} diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index 3ff6a4b3f9..f58bcbc056 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -7,63 +7,13 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import { useEffect, useRef } from 'react' -import type { ReactElement } from 'react' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -// Type-only: the SlotMap merge declaring the directory-flow holes and their owner contract. -import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' +// Type-only: pulls the SlotMap merge declaring the directory-flow holes. +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { NativeFlowInjected } from './flow.ts' +import { NativeDirectoryFlow } from './flow.ts' -/** Injected face: the wire call the flow drives (bound in apply's closure). */ -interface NativeFlowInjected { - /** Ask the local Host to open its native single-directory chooser. */ - pick: () => Promise -} - -/** - * Renderless flow occupant: each rising `open` edge runs exactly one pick and - * reports exactly one outcome; the ref arms once per open so re-renders (and - * an adoption keeping `open` true while `busy`) never launch a second - * chooser. The owner withdrawing `open` re-arms the next request. - * @param props - owner conversation plus the injected pick call. - * @returns nothing — the native chooser renders on the host display. - */ -export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null { - const { open, pick } = props - const armed = useRef(false) - // Callbacks ride a ref so the settled pick reports through the owner's - // latest handlers, not the ones captured when the chooser opened. - const outcome = useRef(props) - outcome.current = props - // Unmount (HMR replacing the occupant) discards settlements wholesale: the - // dead instance must neither adopt a path nor drive the owner's error - // surface. The wire carries no per-request abort, so the host-side chooser - // survives until answered — its answer just lands nowhere; the replacement - // instance re-arms under the owner's still-open request. An injected-face - // identity change alone (re-registration) keeps the pending settlement: - // the chooser on the host display is still the same dialog. - const alive = useRef(true) - useEffect(() => () => { alive.current = false }, []) - useEffect(() => { - if (!open) { - armed.current = false - return - } - if (armed.current) return - armed.current = true - pick().then( - (path) => { - if (!alive.current) return - if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path) - }, - (reason: unknown) => { - if (!alive.current) return - outcome.current.onError(reason instanceof Error ? reason.message : String(reason)) - }, - ) - }, [open, pick]) - return null -} /** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */ export const inject = ['slots', 'workspaces'] diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index 656aea74df..a91732fab4 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -5,7 +5,8 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' -import { apply, inject, NativeDirectoryFlow } from '../src/client/index.ts' +import { apply, inject } from '../src/client/index.ts' +import { NativeDirectoryFlow } from '../src/client/flow.ts' afterEach(cleanup) From 9f37ca270914fbbb5466abc79a9b62b0e0c3236f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 03:06:34 +0800 Subject: [PATCH 19/26] fix(client,host): late duplicate-provider conflicts roll back wholesale and fail loud Holes declared after two flow providers activated left the loser throwing out of the slot flush with partial occupancy. deferRegistration gains an onFailure channel (late failures unsubscribe, then hand over instead of throwing through the flush); the native flow's pair rolls back wholesale and re-raises on the global channel the boot's fail-loud handler owns. Duplicate rows of the SAME package stay silently idempotent (the component identity guard skips an occupied hole). --- packages/client/ui-slots/src/deferred.ts | 17 ++++++++++- .../client/ui-slots/tests/deferred.spec.ts | 21 ++++++++++++++ .../src/client/index.ts | 14 ++++++++-- .../tests/client-flow.spec.tsx | 28 +++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts index 9b68c4d206..a5233812e6 100644 --- a/packages/client/ui-slots/src/deferred.ts +++ b/packages/client/ui-slots/src/deferred.ts @@ -36,6 +36,12 @@ export interface DeferredRegistration { * @param name - target slot name. * @param component - the component whose ledger presence marks "registered". * @param register - performs the actual registration; returns its disposer. + * @param onFailure - owns a registration failure that fires from a LATER + * ledger flush (a declaration landing after two providers deferred, say): + * the deferral first removes its own subscription, then hands the error + * over instead of throwing through the flush — the callback's chance to + * roll back sibling deferrals and surface the conflict on a loud channel. + * Absent, a late failure rethrows out of the flush. * @returns the deferral handle (dispose in the owning effect's disposer). * @throws the immediate registration's failure, after removing the * just-installed subscription — a throwing construction leaves nothing live. @@ -45,6 +51,7 @@ export function deferRegistration( name: string, component: unknown, register: () => () => void, + onFailure?: (error: unknown) => void, ): DeferredRegistration { let dispose: (() => void) | undefined const tryRegister = (): void => { @@ -52,7 +59,15 @@ export function deferRegistration( if (registry.entries(name).some(e => e.component === component)) return dispose = register() } - const unsubscribe = registry.subscribe(name, () => { tryRegister() }) + const unsubscribe = registry.subscribe(name, () => { + try { + tryRegister() + } catch (error) { + unsubscribe() + if (onFailure === undefined) throw error + onFailure(error) + } + }) try { tryRegister() } catch (error) { diff --git a/packages/client/ui-slots/tests/deferred.spec.ts b/packages/client/ui-slots/tests/deferred.spec.ts index 2153e52181..ba4d9b91ee 100644 --- a/packages/client/ui-slots/tests/deferred.spec.ts +++ b/packages/client/ui-slots/tests/deferred.spec.ts @@ -24,6 +24,27 @@ describe('deferRegistration', () => { expect(core.entries(HOLE)).toHaveLength(0) }) + it('hands a late registration failure to onFailure after unsubscribing itself', async () => { + const core = new SlotCore() + const component = (): null => null + const foreign = (): null => null + const failures: unknown[] = [] + // Nothing is declared yet: the deferral just subscribes and waits. + const register = vi.fn(() => core.register({ name: HOLE } as never, component as never)) + deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) }) + // The declaration lands with a foreign occupant racing in first: the + // deferral's flush-time attempt fails, unsubscribes itself, and reports + // through onFailure instead of throwing out of the flush. + core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never) + const disposeForeign = core.register({ name: HOLE } as never, foreign as never) + await Promise.resolve() + expect(failures.map(String).join('')).toContain('already has a registration') + // Unsubscribed: freeing the hole must not resurrect the loser. + disposeForeign() + await Promise.resolve() + expect(core.entries(HOLE)).toHaveLength(0) + }) + it('drops its subscription when the immediate registration throws', async () => { const core = declared() const foreign = (): null => null diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index f58bcbc056..aa2936e1da 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -29,13 +29,21 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => { // Constructing the pair can throw halfway (a declared hole already // occupied registers synchronously): roll the earlier deferral back so - // no live subscription outlives the failed fiber. + // no live subscription outlives the failed fiber. A conflict surfacing + // from a LATER ledger flush (holes declared after two flow providers + // activated) rolls the whole pair back the same way and re-raises on + // the global channel the boot's fail-loud handler owns — never a throw + // through the slot flush, never partial occupancy from this package. const deferred: ReturnType[] = [] + const lateConflict = (error: unknown): void => { + for (const entry of deferred) entry.dispose() + queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) + } try { deferred.push(deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow))) + ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow), lateConflict)) deferred.push(deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow))) + ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow), lateConflict)) } catch (error) { for (const entry of deferred) entry.dispose() throw error diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index a91732fab4..97dbef41e1 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -56,6 +56,34 @@ describe('directory-picker-native client half', () => { for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1) }) + it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => { + const b = await bench() + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + // queueMicrotask throws surface as uncaughtException, not a rejection. + process.on('unhandledRejection', onUnhandled) + process.on('uncaughtException', onUnhandled) + try { + // This provider activates BEFORE any hole exists: both deferrals wait. + // (Duplicate rows of the SAME package converge silently — the deferral + // skips a hole its own component already occupies; the conflict needs + // a rival provider.) + await b.ctx.plugin({ inject: [...inject], apply }).await() + b.declare() + // A rival occupies both holes ahead of the pending microtask flush. + b.slots.register({ name: HOLES[0] } as never, () => null) + b.slots.register({ name: HOLES[1] } as never, () => null) + await new Promise(resolve => setTimeout(resolve, 20)) + // The rival keeps both holes; this provider rolled back wholesale and + // surfaced the conflict on the fail-loud channel — no partial mix. + for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) + expect(rejections.map(String).join('\n')).toContain('already has a registration') + } finally { + process.off('unhandledRejection', onUnhandled) + process.off('uncaughtException', onUnhandled) + } + }) + it('rolls back the first deferral when the second hole is already occupied', async () => { const b = await bench() b.declare() From 62db16f81a4b22b95c09ac6fb35681b71108b17e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 03:08:40 +0800 Subject: [PATCH 20/26] test(host): cover the non-Error late-conflict wrap --- .../tests/client-flow.spec.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index 97dbef41e1..ecddf84eb4 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -78,6 +78,19 @@ describe('directory-picker-native client half', () => { // surfaced the conflict on the fail-loud channel — no partial mix. for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1) expect(rejections.map(String).join('\n')).toContain('already has a registration') + + // Non-Error conflicts wrap before the loud rethrow (same channel). + const c = await bench() + await c.ctx.plugin({ inject: [...inject], apply }).await() + const original = c.slots.register.bind(c.slots) + const slotsAny = c.slots as { register: typeof original } + slotsAny.register = ((options: never, component: never) => { + if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict' + return original(options, component) + }) as typeof original + c.declare() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(rejections.map(String).join('\n')).toContain('string conflict') } finally { process.off('unhandledRejection', onUnhandled) process.off('uncaughtException', onUnhandled) From f56b9149e61d543a5a81d9797cda44dd91d1da2d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 03:17:01 +0800 Subject: [PATCH 21/26] refactor(client): deferGroupRegistration owns the multi-hole flow semantics The construction-rollback + late-conflict-rollback + loud-rethrow block was about to be a verbatim clone across the two flow packages; ui-slots now owns it as deferGroupRegistration (one occupant, several holes, as a unit), with direct specs for all three arms, and the native flow consumes it. --- packages/client/ui-slots/src/deferred.ts | 37 +++++++++++ .../client/ui-slots/tests/deferred.spec.ts | 63 ++++++++++++++++++- .../src/client/index.ts | 34 ++++------ 3 files changed, 110 insertions(+), 24 deletions(-) diff --git a/packages/client/ui-slots/src/deferred.ts b/packages/client/ui-slots/src/deferred.ts index a5233812e6..68f509b916 100644 --- a/packages/client/ui-slots/src/deferred.ts +++ b/packages/client/ui-slots/src/deferred.ts @@ -89,3 +89,40 @@ export function deferRegistration( }, } } + +/** + * Defer ONE occupant into several holes as a unit. Construction that throws + * partway (a declared hole already occupied registers synchronously) rolls + * every earlier deferral back before rethrowing; a failure surfacing from a + * LATER ledger flush (holes declared after rival providers activated) rolls + * the whole group back the same way and re-raises the wrapped error on the + * global channel the boot's fail-loud handler owns — never a throw through + * the slot flush, never partial occupancy from the group's owner. + * @param registry - the slot registry face. + * @param names - the target holes (one registration per name). + * @param component - the occupant whose ledger presence marks "registered". + * @param register - performs one hole's registration; returns its disposer. + * @returns the group handle (dispose in the owning effect's disposer). + * @throws the immediate registration's failure, after rolling the group back. + */ +export function deferGroupRegistration( + registry: DeferralRegistry, + names: readonly K[], + component: unknown, + register: (name: K) => () => void, +): { dispose: () => void } { + const deferred: DeferredRegistration[] = [] + const lateFailure = (error: unknown): void => { + for (const entry of deferred) entry.dispose() + queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) + } + try { + for (const name of names) { + deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure)) + } + } catch (error) { + for (const entry of deferred) entry.dispose() + throw error + } + return { dispose: () => { for (const entry of deferred) entry.dispose() } } +} diff --git a/packages/client/ui-slots/tests/deferred.spec.ts b/packages/client/ui-slots/tests/deferred.spec.ts index ba4d9b91ee..6e7b983852 100644 --- a/packages/client/ui-slots/tests/deferred.spec.ts +++ b/packages/client/ui-slots/tests/deferred.spec.ts @@ -2,7 +2,7 @@ // re-registration, and — the failure contract — no subscription survives a // construction that throws synchronously (an already-occupied single slot). import { describe, expect, it, vi } from 'vitest' -import { deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' +import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots' // Shares the merges declared by core.spec.ts (same program); reuse its keys. const HOLE = 'test.single' as const @@ -63,3 +63,64 @@ describe('deferRegistration', () => { expect(core.entries(HOLE)).toHaveLength(0) }) }) + +describe('deferGroupRegistration', () => { + const HOLES = ['test.single', 'test.grandchild'] as const + + function declaredPair(): SlotCore { + const core = new SlotCore() + core.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, (() => null) as never) + return core + } + + it('registers the whole group and disposes it as a unit', () => { + const core = declaredPair() + const component = (): null => null + const group = deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never)) + for (const name of HOLES) expect(core.entries(name)).toHaveLength(1) + group.dispose() + for (const name of HOLES) expect(core.entries(name)).toHaveLength(0) + }) + + it('rolls the group back when construction fails partway', () => { + const core = declaredPair() + const component = (): null => null + core.register({ name: HOLES[1] } as never, (() => null) as never) + expect(() => deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never))).toThrow(/already has a registration/) + // The first hole's registration and subscription rolled back with it. + expect(core.entries(HOLES[0])).toHaveLength(0) + }) + + it('rolls the group back and re-raises loudly on a late conflict', async () => { + const core = new SlotCore() + const component = (): null => null + const failures: unknown[] = [] + const onLoud = (reason: unknown): void => { failures.push(reason) } + process.on('uncaughtException', onLoud) + try { + const group = deferGroupRegistration(core, HOLES, component, name => + core.register({ name } as never, component as never)) + // Declaration lands with a rival racing in ahead of the flush. + core.register({ + name: 'root', + children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])), + } as never, (() => null) as never) + core.register({ name: HOLES[0] } as never, (() => null) as never) + core.register({ name: HOLES[1] } as never, (() => null) as never) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(failures.map(String).join('')).toContain('already has a registration') + // No partial occupancy from the group's owner survives. + for (const name of HOLES) { + expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0) + } + group.dispose() + } finally { + process.off('uncaughtException', onLoud) + } + }) +}) diff --git a/packages/host/directory-picker-native/src/client/index.ts b/packages/host/directory-picker-native/src/client/index.ts index aa2936e1da..2d5de9a9e7 100644 --- a/packages/host/directory-picker-native/src/client/index.ts +++ b/packages/host/directory-picker-native/src/client/index.ts @@ -7,7 +7,7 @@ * both sides of the native interaction with one cordis.yml row; no client * code branches on a capability kind. */ -import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -27,27 +27,15 @@ export const inject = ['slots', 'workspaces'] export function apply(ctx: ClientContext): void { const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() }) ctx.effect(() => { - // Constructing the pair can throw halfway (a declared hole already - // occupied registers synchronously): roll the earlier deferral back so - // no live subscription outlives the failed fiber. A conflict surfacing - // from a LATER ledger flush (holes declared after two flow providers - // activated) rolls the whole pair back the same way and re-raises on - // the global channel the boot's fail-loud handler owns — never a throw - // through the slot flush, never partial occupancy from this package. - const deferred: ReturnType[] = [] - const lateConflict = (error: unknown): void => { - for (const entry of deferred) entry.dispose() - queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) }) - } - try { - deferred.push(deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, NativeDirectoryFlow), lateConflict)) - deferred.push(deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', NativeDirectoryFlow, () => - ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, NativeDirectoryFlow), lateConflict)) - } catch (error) { - for (const entry of deferred) entry.dispose() - throw error - } - return () => { for (const entry of deferred) entry.dispose() } + // One occupant, both holes, as a unit: construction or late conflicts + // (holes declared after rival providers activated) roll the whole pair + // back and fail loud — semantics owned by deferGroupRegistration. + const group = deferGroupRegistration( + ctx.slots, + ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const, + NativeDirectoryFlow, + name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow), + ) + return () => { group.dispose() } }, 'directory-picker-native: flow registrations') } From 5245182db290e057a23fba5e4bba936d6de64557 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 03:45:26 +0800 Subject: [PATCH 22/26] fix(host): bound listDirectory levels at a configurable maxEntries One list call now materializes at most maxEntries child rows (config, default 1000 - GitHub's web-UI directory-listing bound). Candidates sort before probing so a cut level keeps the name-sorted head and symlink probing stops with the bound, and DirectoryListing carries a required truncated flag on the seam and the wire so clients can state incompleteness instead of silently missing tail entries. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 1 + ...-28-directory-picker-capability-seam.zh.md | 1 + docs/config-catalog.md | 13 +++++- docs/cordis-catalog/services.md | 2 +- .../client/connection/src/client/fixture.ts | 2 + packages/client/connection/tests/fake-api.ts | 3 +- packages/client/runtime/tests/fake-api.ts | 3 +- .../runtime/tests/workspaces-service.spec.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/src/api/host.schema.ts | 1 + packages/host/apiproxy/src/api/host.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 1 + .../apiproxy/tests/client-handler.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 4 +- .../host/apiproxy/tests/rpc-schemas.spec.ts | 3 ++ .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 43 +++++++++++++++++-- .../tests/service.spec.ts | 21 +++++++++ packages/host/directory-picker/src/index.ts | 9 +++- 22 files changed, 107 insertions(+), 20 deletions(-) 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 index f1bd3fd344..500e032939 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 268caad197985d052affb17fe2a7644cd6722075 -2026-07-28-directory-picker-capability-seam.zh.md: 573bfc19bb0f5eeae3f1e851e733fd2498a986bf +2026-07-28-directory-picker-capability-seam.md: 29c00c370b4fe00f528db62751f1a5df014ffd32 +2026-07-28-directory-picker-capability-seam.zh.md: 740d098ce881dd38f01281313bf44421d54b64c8 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 index 268caad197..29c00c370b 100644 --- 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 @@ -20,6 +20,7 @@ Placement and policy rulings folded into this decision: - **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. +- **Listing levels are bounded.** One `list` call materializes at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound): candidates sort before probing so a cut level keeps the name-sorted head and probing stops with the bound, and the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **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 native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. 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 index 573bfc19bb..740d098ce8 100644 --- 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 @@ -20,6 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 +- **列举层级有上限。** 单次 `list` 至多物化 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限):候选先按名排序再探测,因此被截断的层级保留排序后的头部、探测随上限一同停止;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9bdae9aabf..865e97a555 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -523,6 +523,18 @@ export interface Config { Source: [`packages/host/apiproxy/src/index.ts:33`](../packages/host/apiproxy/src/index.ts) +## `@deepseek-ai/dsh-host-directory-picker-browse` + +```ts config-catalog +/** Validated plugin configuration. */ +export interface Config { + /** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */ + maxEntries: number +} +``` + +Source: [`packages/host/directory-picker-browse/src/index.ts:85`](../packages/host/directory-picker-browse/src/index.ts) + ## `@deepseek-ai/dsh-host-webserver` ```ts config-catalog @@ -2209,7 +2221,6 @@ 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-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) - `@deepseek-ai/dsh-lsp` ([`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e8c7aac38a..5c422d6586 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:121`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:128`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 82ecbb1cf3..0a029072d0 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -880,6 +880,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { crumbs: crumbsOf(target), entries: [...children].sort((a, b) => a.localeCompare(b)) .map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })), + // The fixture tree is tiny; no level ever reaches a backend bound. + truncated: false, }) }, createDirectory: (request) => { diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index 13ca7f4922..59bb426def 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -75,8 +75,9 @@ export class FakeApiClient implements IApiClient { home: string crumbs: { name: string; path: string; hidden: boolean }[] entries: { name: string; path: string; hidden: boolean }[] + truncated: boolean }>> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] })) + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) onCreateDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index b0f5f13db5..15b2df1a81 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -93,8 +93,9 @@ export class FakeApiClient implements IApiClient { home: string crumbs: { name: string; path: string; hidden: boolean }[] entries: { name: string; path: string; hidden: boolean }[] + truncated: boolean }>> = - () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] })) + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) onCreateDirectory: (payload: unknown) => Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 6f0d35f2fa..54ec218765 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -242,7 +242,7 @@ describe('WorkspacesService', () => { 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 }] } + const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false } api.onListDirectory = () => Promise.resolve(ok(listing)) await expect(workspaces.listDirectory()).resolves.toEqual(listing) await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7c23da0949..9ca1d81ca9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1665,7 +1665,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DirectoryListing', - declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n}', + declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n truncated: boolean;\n}', }, { name: 'DirectoryPickerBrowseCapability', diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index f5d17421fb..43031288fc 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -47,6 +47,7 @@ export const hostListDirectoryValueSchema = z.object({ home: z.string(), crumbs: z.array(directoryEntrySchema), entries: z.array(directoryEntrySchema), + truncated: z.boolean(), }) satisfies z.ZodType>> /** host.createDirectory request payload: name must be one plain path segment. */ diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 937fa905af..0338494bed 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -28,6 +28,8 @@ export interface DirectoryListing { crumbs: DirectoryEntry[] /** Direct child directories, name-sorted; symlinks to directories included. */ entries: DirectoryEntry[] + /** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */ + truncated: boolean } /** Host-level unary methods. */ diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index e978db9f9b..9c52b62a61 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -159,6 +159,7 @@ const BROWSE_STUB: DirectoryPickerCapability = { home: '/home/user', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }], + truncated: false, } }, createDirectory: async (path, name) => { diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index fc1dfd777a..8232b2f524 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -50,7 +50,7 @@ function scriptedApi(overrides: { host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }), pickDirectory: r => ok(r, { path: null }), - listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }), + listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }), createDirectory: r => ok(r, { path: '/t/new' }), openPath: r => ok(r, { opened: true as const }), ...overrides.host, diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index bf0be224f2..b760f3e3e4 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -82,7 +82,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra 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: [] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } } }, async createDirectory(request) { return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } } @@ -224,7 +224,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const listed = await c.host.listDirectory({ path: '/w' }) expect(listed.result).toEqual({ ok: true, - value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }, + value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }, }) const home = await c.host.listDirectory({}) expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } }) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 6c400871ae..4d648e491d 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -231,8 +231,11 @@ describe('host domain schemas', () => { 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 }], + truncated: false, }) expect(listing.entries[0]?.hidden).toBe(true) + // The flag is part of the wire value, not an optional decoration. + expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow() 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() diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 150a9c2323..9588943980 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 632dfec3dac57cac9ea7a02225959fe6e3acf6a0 -README.zh.md: 81a1eb53eac0d3b5a1ef8f2f98c4359ef6a4c5fd +README.md: 2f1994100f0fb8fcadae46094267057b3966197e +README.zh.md: a149133a13ca99f176680c68b4931b9673f4aaa8 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 632dfec3da..2f1994100f 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. -Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call materializes at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings): a cut level keeps the name-sorted head, counts hidden rows against the bound, stops probing once the bound is hit, and reports `truncated: true` so the client can say the level is incomplete. 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 diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 81a1eb53ea..a149133a13 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多物化 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限):被截断的层级保留按名排序的头部、隐藏行计入上限、达到上限即停止探测,并报告 `truncated: true`,供客户端提示层级不完整。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 561e168421..b9699cd612 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -12,6 +12,8 @@ import { mkdir, readdir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, posix, resolve, win32 } from 'node:path' +import type { Context } from 'cordis' +import z from 'schemastery' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' @@ -79,14 +81,35 @@ async function directoryRow(parent: string, name: string, isDirectory: boolean, return { name, path, hidden: name.startsWith('.') } } +/** Validated plugin configuration. */ +export interface Config { + /** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */ + maxEntries: number +} + /** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */ export default class BrowseDirectoryPicker extends DirectoryPicker { + /** + * `maxEntries` bounds the complete listing level a single `list` call may + * materialize and put on the wire: at most this many child-directory rows + * (hidden rows included), with `truncated` flagging a cut level. The + * default follows GitHub's web UI, which truncates directory listings at + * 1,000 entries. + */ + static Config: z = z.object({ + maxEntries: z.natural().min(1).default(1000), + }) + private readonly browseCapability: DirectoryPickerCapability = { kind: 'browse', list: path => this.list(path), createDirectory: (path, name) => this.createDirectory(path, name), } + constructor(ctx: Context, private readonly config: Config) { + super(ctx) + } + /** * The browse interaction capability. * @returns the stable `browse` capability object. @@ -115,10 +138,22 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } 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 } + // Sort candidates before probing so the bound keeps the name-sorted head + // of the level and probing (symlink stat) stops with the bound instead of + // touching every child of an oversized directory. + names.sort((a, b) => a.name.localeCompare(b.name)) + const entries: DirectoryEntry[] = [] + let truncated = false + for (const entry of names) { + const row = await directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink) + if (row === null) continue + if (entries.length === this.config.maxEntries) { + truncated = true + break + } + entries.push(row) + } + return { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated } } private async createDirectory(path: string, name: string): Promise { diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 3f214760b7..8e7a2ba049 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -45,6 +45,27 @@ describe('BrowseDirectoryPicker', () => { 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) + // Well under the default bound: the complete level, not a cut one. + expect(listing.truncated).toBe(false) + }) + + it('cuts a level at maxEntries keeping the name-sorted head, and flags the cut', async () => { + const ctx = new Context() + const fiber = ctx.plugin(BrowseDirectoryPicker, { maxEntries: 1 }) + await fiber.await() + const bounded = ctx.get('directoryPicker')!.capability() + if (bounded.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') + try { + const cut = await bounded.list(root) + expect(cut.entries.map(entry => entry.name)).toEqual(['.hidden-dir']) + expect(cut.truncated).toBe(true) + // Exactly at the bound is complete, not truncated. + const exact = await bounded.list(join(root, 'projects')) + expect(exact.entries.map(entry => entry.name)).toEqual(['harness']) + expect(exact.truncated).toBe(false) + } finally { + await fiber.dispose() + } }) it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index c96f21d9a6..3b4b60562f 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -47,6 +47,12 @@ export interface DirectoryListing { crumbs: DirectoryEntry[] /** Direct child directories, name-sorted; symlinks to directories included. */ entries: DirectoryEntry[] + /** + * True when the backend cut `entries` at its complete-result bound: the + * level has more child directories than reported, and the missing rows are + * the name-sorted tail (hidden rows count toward the bound). + */ + truncated: boolean } /** @@ -59,7 +65,8 @@ export interface DirectoryPickerBrowseCapability { /** * List one directory level. * @param path - absolute directory to list; absent lists the home directory. - * @returns the level's listing with ancestry. + * @returns the level's listing with ancestry; backends bound the complete + * result, and a cut level reports `truncated`. * @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully * qualified (a wire value must never resolve against the host cwd or, on * Windows, its current drive) or cannot be listed. From da970ea269d2d9b2ba05347c38b1c004ea7c602f Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 04:11:41 +0800 Subject: [PATCH 23/26] fix(host,client): stream bounded listings, declare schemastery, guard Choose again The browse level now streams through opendir into a name-sorted window of maxEntries + 1 candidates (boundedInsert), so memory stays O(maxEntries) no matter how many children a directory holds and enterability probing touches only windowed candidates; a windowed broken symlink is not backfilled since the eviction already marks the level truncated. schemastery joins the package's runtime dependencies (the source launcher and isolated installs failed to resolve the value import). The folder-error dialog's Choose again goes inert while the flow hole is empty, and the withdrawal effect also keys on the open transition, so a flow can never open over a hole nobody serves. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- docs/config-catalog.md | 2 +- .../src/client/WorkspacePicker.tsx | 11 +++- .../tests/workspace-picker.spec.tsx | 14 ++++ .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/package.json | 3 +- .../host/directory-picker-browse/src/index.ts | 66 ++++++++++++++----- .../tests/service.spec.ts | 30 ++++++++- pnpm-lock.yaml | 3 + 13 files changed, 116 insertions(+), 29 deletions(-) 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 index 500e032939..52bd6fd263 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 29c00c370b4fe00f528db62751f1a5df014ffd32 -2026-07-28-directory-picker-capability-seam.zh.md: 740d098ce881dd38f01281313bf44421d54b64c8 +2026-07-28-directory-picker-capability-seam.md: 7f78f7db0cca36c5aaeeb4efcab51d826945a4a1 +2026-07-28-directory-picker-capability-seam.zh.md: 31537fe1f21c1acbca73763bdd20e6d5f91a8d5e 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 index 29c00c370b..7f78f7db0c 100644 --- 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 @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **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. -- **Listing levels are bounded.** One `list` call materializes at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound): candidates sort before probing so a cut level keeps the name-sorted head and probing stops with the bound, and the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **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 native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. 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 index 740d098ce8..31537fe1f2 100644 --- 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 @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **列举层级有上限。** 单次 `list` 至多物化 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限):候选先按名排序再探测,因此被截断的层级保留排序后的头部、探测随上限一同停止;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 865e97a555..8db82e5cc7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:85`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:114`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index fa6290e1f1..903b0e115b 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -98,9 +98,12 @@ export function WorkspaceCreateFlow({ const flowAvailable = useDirectoryFlow(occupied => occupied) // An occupant that unloads mid-interaction leaves nobody to cancel: an // open flow over an empty hole withdraws so the menu actions come back. + // flowOpen is a dependency because the flow can also OPEN over an already + // empty hole (Choose again after the occupant unloaded with the error + // dialog up) — that transition must snap back too, not just occupancy loss. useEffect(() => { - if (!flowAvailable) setFlowOpen(false) - }, [flowAvailable]) + if (flowOpen && !flowAvailable) setFlowOpen(false) + }, [flowOpen, flowAvailable]) const createEntries: MenuEntry[] = [ ...(flowAvailable ? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: flowBusy }] @@ -224,7 +227,9 @@ export function WorkspaceCreateFlow({ footer={( <> - + {/* Retrying needs an occupant to serve the flow; without one the + * button would open a flow nobody can answer or cancel. */} + )} > diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 18ddd8b1fd..490a86d782 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -303,6 +303,20 @@ describe('WorkspacePicker', () => { expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() }) + it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => { + const b = mount([], vi.fn(async () => { throw new Error('adoption failed') })) + chooseItem('Open local folder…') + await act(async () => { b.probe.owner!.onPicked('/one/project') }) + await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldn’t open folder' })).toBeTruthy() }) + // The occupant unloads while the error dialog is up: retrying would open + // a flow nobody can serve or cancel, so the button goes inert. + act(() => { b.occupancy.flip(false) }) + expect(screen.getByRole('button', { name: 'Choose again' }).disabled).toBe(true) + // Cancel stays the way out, and the menu actions are usable again. + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByRole('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false) + }) + it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => { const b = mount([]) chooseItem('Open local folder…') diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 9588943980..4320c46148 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 2f1994100f0fb8fcadae46094267057b3966197e -README.zh.md: a149133a13ca99f176680c68b4931b9673f4aaa8 +README.md: 155b18eecb46d704dc181e29fdf7eb67fac42839 +README.zh.md: 154eeb571171116ae98e46efe77efd45986f67ea diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 2f1994100f..155b18eecb 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. -Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call materializes at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings): a cut level keeps the name-sorted head, counts hidden rows against the bound, stops probing once the bound is hit, and reports `truncated: true` so the client can say the level is incomplete. 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). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated). 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 diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index a149133a13..154eeb5711 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多物化 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限):被截断的层级保留按名排序的头部、隐藏行计入上限、达到上限即停止探测,并报告 `truncated: true`,供客户端提示层级不完整。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断)。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index c1e8626bed..f83b0fcb86 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -27,7 +27,8 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/dsh-host-directory-picker": "workspace:^" + "@deepseek-ai/dsh-host-directory-picker": "workspace:^", + "schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index b9699cd612..20ebef1c62 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-browse */ -import { mkdir, readdir, stat } from 'node:fs/promises' +import { mkdir, opendir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, posix, resolve, win32 } from 'node:path' import type { Context } from 'cordis' @@ -53,6 +53,35 @@ export function fullyQualified(path: string, platform: NodeJS.Platform = process : posix.isAbsolute(path) } +/** One streamed listing candidate: the dirent facts a row needs, nothing else retained. */ +export interface ListingCandidate { + /** Base name within the streamed level. */ + name: string + /** Dirent says directory (no probe needed). */ + isDirectory: boolean + /** Dirent says symlink (enterability needs a stat probe). */ + isSymbolicLink: boolean +} + +/** + * Insert a streamed candidate into the name-sorted bounded window, evicting + * the name-largest candidate when the window exceeds `keep`. Memory over an + * arbitrarily large level therefore stays O(keep) regardless of how many + * children the directory holds. + * @param window - the name-ascending window, mutated in place. + * @param candidate - the streamed candidate to place. + * @param keep - the window bound. + * @returns true when an eviction happened (the level has candidates beyond the window). + */ +export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean { + const at = window.findIndex(existing => candidate.name.localeCompare(existing.name) < 0) + if (at === -1) window.push(candidate) + else window.splice(at, 0, candidate) + if (window.length <= keep) return false + window.pop() + return true +} + /** 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. */ @@ -127,25 +156,32 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`) } const target = resolve(path ?? home) - let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[] + // Stream the level (opendir, one dirent at a time) into a name-sorted + // window of maxEntries + 1 candidates: memory stays bounded no matter how + // many children the directory holds, the window keeps the name-sorted + // head, and the +1 slot lets an in-window extra row prove the cut. A + // window candidate that turns out non-enterable (broken symlink) is not + // backfilled from beyond the window — an eviction already marks the + // level truncated, which stays the honest answer. + const keep = this.config.maxEntries + 1 + const window: ListingCandidate[] = [] + let evicted = false try { - const dirents = await readdir(target, { withFileTypes: true }) - names = dirents.map(dirent => ({ - name: dirent.name, - isDirectory: dirent.isDirectory(), - isSymbolicLink: dirent.isSymbolicLink(), - })) + const level = await opendir(target) + for await (const dirent of level) { + // Only rows a browser could enter contend for the window; dirent + // says "directory" outright, a symlink needs the later stat probe. + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue + const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() } + if (boundedInsert(window, candidate, keep)) evicted = true + } } catch (error: unknown) { throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) } - // Sort candidates before probing so the bound keeps the name-sorted head - // of the level and probing (symlink stat) stops with the bound instead of - // touching every child of an oversized directory. - names.sort((a, b) => a.name.localeCompare(b.name)) const entries: DirectoryEntry[] = [] - let truncated = false - for (const entry of names) { - const row = await directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink) + let truncated = evicted + for (const candidate of window) { + const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink) if (row === null) continue if (entries.length === this.config.maxEntries) { truncated = true diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 8e7a2ba049..2b01d0d29e 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -7,7 +7,8 @@ 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, { fullyQualified } from '../src/index.ts' +import BrowseDirectoryPicker, { boundedInsert, fullyQualified } from '../src/index.ts' +import type { ListingCandidate } from '../src/index.ts' let root: string let capability: DirectoryPickerBrowseCapability @@ -21,6 +22,13 @@ beforeAll(async () => { 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') + try { + await symlink(join(root, 'notes.txt'), join(root, 'file-link')) + } catch { + // Windows denies unprivileged file symlinks; the file-link row only + // feeds the POSIX lanes' coverage of the symlink-to-file arm, and every + // assertion below expects it to be filtered out anyway. + } const ctx = new Context() const fiber = ctx.plugin(BrowseDirectoryPicker) @@ -63,11 +71,31 @@ describe('BrowseDirectoryPicker', () => { const exact = await bounded.list(join(root, 'projects')) expect(exact.entries.map(entry => entry.name)).toEqual(['harness']) expect(exact.truncated).toBe(false) + // A level that fits the window but exceeds the bound (two rows, bound + // one): the in-window extra row proves the cut without any eviction. + await mkdir(join(root, 'projects', 'harness', 'a')) + await mkdir(join(root, 'projects', 'harness', 'b')) + const inWindow = await bounded.list(join(root, 'projects', 'harness')) + expect(inWindow.entries.map(entry => entry.name)).toEqual(['a']) + expect(inWindow.truncated).toBe(true) } finally { await fiber.dispose() } }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { + const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) + const window: ListingCandidate[] = [] + expect(boundedInsert(window, candidate('m'), 2)).toBe(false) + expect(boundedInsert(window, candidate('z'), 2)).toBe(false) + // A smaller name lands in place and pushes the current largest out. + expect(boundedInsert(window, candidate('a'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + // A name beyond the window's tail enters last and leaves immediately. + expect(boundedInsert(window, candidate('t'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + }) + 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)! diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a23066eb1..483167a961 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2795,6 +2795,9 @@ importers: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-invariants': specifier: workspace:^ From 7503390590d486590f7dd8008ab7d35c884b50a5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 04:42:13 +0800 Subject: [PATCH 24/26] fix(host): cancellable listing scans and O(log window) insertion capability.list gains an optional AbortSignal threaded from the RPC carrier's request signal (the pickDirectory pattern): a disconnected or timed-out caller stops the opendir loop instead of the scan outliving its caller, and the abort surfaces as its own reason rather than a directory-unreadable dressing. boundedInsert rejects a full window's at-or-beyond-tail candidate on one comparison and binary-inserts retained candidates, so an oversized level no longer pays a window scan per dirent. --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../client/connection/src/client/fixture.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- packages/host/apiproxy/src/api-proxy.ts | 6 ++-- packages/host/apiproxy/src/api/host.ts | 5 +++- packages/host/apiproxy/src/fetch/handler.ts | 2 +- .../tests/api-proxy-workspace.spec.ts | 8 +++--- .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../host/directory-picker-browse/src/index.ts | 28 +++++++++++++++---- .../tests/service.spec.ts | 17 ++++++++++- packages/host/directory-picker/src/index.ts | 5 +++- 17 files changed, 68 insertions(+), 27 deletions(-) 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 index 52bd6fd263..5696f35720 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 7f78f7db0cca36c5aaeeb4efcab51d826945a4a1 -2026-07-28-directory-picker-capability-seam.zh.md: 31537fe1f21c1acbca73763bdd20e6d5f91a8d5e +2026-07-28-directory-picker-capability-seam.md: 9d885abe394cfd30a174f07f6ac9432b774188b6 +2026-07-28-directory-picker-capability-seam.zh.md: 6dd1b66b508f5f06d6a150b6c45f0da7d36bf2ce 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 index 7f78f7db0c..9d885abe39 100644 --- 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 @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **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. -- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **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 native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. 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 index 31537fe1f2..6dd1b66b50 100644 --- 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 @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8db82e5cc7..3e70c22fbc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:114`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:127`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5c422d6586..314c707f4c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:128`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0a029072d0..a11d8c4157 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1160,7 +1160,7 @@ 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.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) case 'workspace.list': return this.api.workspace.list(request) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9ca1d81ca9..e8761181ad 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1669,7 +1669,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DirectoryPickerBrowseCapability', - declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string): Promise;\n createDirectory(path: string, name: string): Promise;\n}', + declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise;\n createDirectory(path: string, name: string): Promise;\n}', }, { name: 'DirectoryPickerCapabilities', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 7b7beacec8..d4c4268088 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1076,7 +1076,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, - async listDirectory(request) { + async listDirectory(request, signal) { const capability = ctx.directoryPicker.capability() if (capability.kind !== 'browse') { return err(request, { @@ -1086,7 +1086,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }) } try { - return ok(request, await capability.list(request.payload.path)) + // The carrier's signal follows the caller: a disconnect or timeout + // stops the backend's directory scan instead of outliving it. + return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { return err(request, directoryError(error)) } diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 0338494bed..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -62,10 +62,13 @@ export interface HostApi { /** * 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`. + * unreadable or missing targets fail with `directory-unreadable`. The + * carrier's request signal follows the caller, stopping the backend's scan + * on disconnect or timeout. */ listDirectory( request: RpcRequest<{ path?: string }>, + signal: AbortSignal, ): Promise> /** diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 1f9bf3fde7..5398ce4848 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -64,7 +64,7 @@ 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.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) }, 'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) }, 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index 9c52b62a61..f04850c34d 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -172,9 +172,9 @@ const BROWSE_STUB: DirectoryPickerCapability = { 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({})) + const home = await api.host.listDirectory(request({}), new AbortController().signal) expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } }) - const listed = await api.host.listDirectory(request({ path: '/home/user/projects' })) + const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal) 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' } }) @@ -182,7 +182,7 @@ describe('host.listDirectory / host.createDirectory', () => { 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({ + expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } }, }) expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({ @@ -195,7 +195,7 @@ describe('host.listDirectory / host.createDirectory', () => { it('refuses the browse RPCs under a native composition', async () => { const { api } = await harness() - expect((await api.host.listDirectory(request({}))).result).toMatchObject({ + expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } }, }) expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({ diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4320c46148..96151039b3 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/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/directory-picker-browse/README.md -README.md: 155b18eecb46d704dc181e29fdf7eb67fac42839 -README.zh.md: 154eeb571171116ae98e46efe77efd45986f67ea +README.md: 9543fc89f1314d05d02df72e9b5d86af21fdad66 +README.zh.md: dd314c5b5a709b2cf2e6840911fd665ead7d7e87 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 155b18eecb..9543fc89f1 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot. -Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated). 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). +Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. 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 diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 154eeb5711..dd314c5b5a 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -4,7 +4,7 @@ [目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**:`BrowseDirectoryPicker` 以 `browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。 -行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断)。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 20ebef1c62..be27c5a2d4 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -74,9 +74,22 @@ export interface ListingCandidate { * @returns true when an eviction happened (the level has candidates beyond the window). */ export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean { - const at = window.findIndex(existing => candidate.name.localeCompare(existing.name) < 0) - if (at === -1) window.push(candidate) - else window.splice(at, 0, candidate) + // Full window, name at or beyond the tail: one comparison rejects, so an + // oversized level costs O(1) per candidate past the head instead of a + // window scan (100k children against a 1,001 window must not approach + // 10^8 comparisons). + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail + if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true + // Binary insertion keeps a retained candidate at O(log keep) comparisons. + let lo = 0 + let hi = window.length + while (lo < hi) { + const mid = (lo + hi) >>> 1 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition + if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid + else lo = mid + 1 + } + window.splice(lo, 0, candidate) if (window.length <= keep) return false window.pop() return true @@ -131,7 +144,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private readonly browseCapability: DirectoryPickerCapability = { kind: 'browse', - list: path => this.list(path), + list: (path, signal) => this.list(path, signal), createDirectory: (path, name) => this.createDirectory(path, name), } @@ -147,7 +160,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { return this.browseCapability } - private async list(path?: string): Promise { + private async list(path?: string, signal?: AbortSignal): Promise { const home = homedir() // The seam contract takes fully qualified paths only; resolve() would // silently rebase a relative or empty wire value under the host process @@ -169,6 +182,9 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { try { const level = await opendir(target) for await (const dirent of level) { + // A disconnected/timed-out caller stops the scan here; throwing out + // of the loop closes the directory handle via the iterator's return. + signal?.throwIfAborted() // Only rows a browser could enter contend for the window; dirent // says "directory" outright, a symlink needs the later stat probe. if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue @@ -176,6 +192,8 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { if (boundedInsert(window, candidate, keep)) evicted = true } } catch (error: unknown) { + // An abort is the caller's own reason, not an unreadable directory. + signal?.throwIfAborted() throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`) } const entries: DirectoryEntry[] = [] diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 2b01d0d29e..99366e8754 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -83,6 +83,19 @@ describe('BrowseDirectoryPicker', () => { } }) + it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => { + const gone = new AbortController() + gone.abort(new Error('caller left')) + // The abort surfaces as-is, not dressed as an unreadable directory. + await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left') + // A live signal changes nothing about ordinary failures. + const live = new AbortController() + const missing = join(root, 'no-such-dir') + const failure = await capability.list(missing, live.signal).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(DirectoryPickerError) + expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') + }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) const window: ListingCandidate[] = [] @@ -91,9 +104,11 @@ describe('BrowseDirectoryPicker', () => { // A smaller name lands in place and pushes the current largest out. expect(boundedInsert(window, candidate('a'), 2)).toBe(true) expect(window.map(entry => entry.name)).toEqual(['a', 'm']) - // A name beyond the window's tail enters last and leaves immediately. + // A name at or beyond the full window's tail rejects on one comparison. expect(boundedInsert(window, candidate('t'), 2)).toBe(true) expect(window.map(entry => entry.name)).toEqual(['a', 'm']) + expect(boundedInsert(window, candidate('m'), 2)).toBe(true) + expect(window.map(entry => entry.name)).toEqual(['a', 'm']) }) it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => { diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 3b4b60562f..4dba9c9c22 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -65,13 +65,16 @@ export interface DirectoryPickerBrowseCapability { /** * List one directory level. * @param path - absolute directory to list; absent lists the home directory. + * @param signal - caller lifetime; abort stops the scan (a stalled network + * directory must not outlive a disconnected caller) and rejects with the + * abort reason. * @returns the level's listing with ancestry; backends bound the complete * result, and a cut level reports `truncated`. * @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully * qualified (a wire value must never resolve against the host cwd or, on * Windows, its current drive) or cannot be listed. */ - list(path?: string): Promise + list(path?: string, signal?: AbortSignal): Promise /** * Create one child directory under an existing parent. * @param path - absolute existing parent directory. From baaa53523542c1f3247e3f8277f0ed57f9bdf20b Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 05:05:36 +0800 Subject: [PATCH 25/26] fix(host): race directory reads against the caller's signal; report aborts as cancelled Every filesystem await in the browse scan (opendir and each read) now races the signal through raceAbort, so a stalled network open/read stops with a departed caller and an already-aborted request rejects even for an empty level; the abandoned settlement is swallowed and an abandoned open that still mints a handle is closed, never leaked. apiproxy maps an aborted listing to the cancelled wire code, matching pickDirectory and command.execute, instead of reporting a false internal failure. The fixture spec call sites gain the wire signal argument the previous commit's static lane flagged. --- docs/config-catalog.md | 2 +- .../client/connection/tests/fixture.spec.ts | 4 +- packages/host/apiproxy/src/api-proxy.ts | 5 ++ .../tests/api-proxy-workspace.spec.ts | 14 ++++ .../host/directory-picker-browse/src/index.ts | 82 ++++++++++++++++--- .../tests/service.spec.ts | 39 ++++++++- 6 files changed, 131 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3e70c22fbc..42eb9c2325 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:127`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:170`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 3c8256ac3e..15fecad404 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -320,13 +320,13 @@ describe('createFixtureApi', () => { const created = await api.host.createDirectory(req({ path: '/', name: 'srv' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.path).toBe('/srv') - const listed = await api.host.listDirectory(req({ path: '/srv' })) + const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal) if (!listed.result.ok) throw new Error('list failed') expect(listed.result.value.crumbs).toEqual([ { name: '/', path: '/', hidden: false }, { name: 'srv', path: '/srv', hidden: false }, ]) - const root = await api.host.listDirectory(req({ path: '/' })) + const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal) if (!root.result.ok) throw new Error('root list failed') expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d4c4268088..a2b2a887cd 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1090,6 +1090,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // stops the backend's directory scan instead of outliving it. return ok(request, await capability.list(request.payload.path, signal)) } catch (error: unknown) { + // An abort is the caller's own timeout/disconnect, not a server + // failure — same code pickDirectory and command.execute report. + if (signal.aborted) { + return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} }) + } return err(request, directoryError(error)) } }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index f04850c34d..3968cba5c7 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -193,6 +193,20 @@ describe('host.listDirectory / host.createDirectory', () => { }) }) + it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => { + const { api } = await harness(undefined, { + kind: 'browse', + list: (_path, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true }) + }), + createDirectory: async () => '/never', + }) + const abort = new AbortController() + const pending = api.host.listDirectory(request({}), abort.signal) + abort.abort() + expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) + it('refuses the browse RPCs under a native composition', async () => { const { api } = await harness() expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({ diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index be27c5a2d4..dbb3a03a5b 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -95,6 +95,49 @@ export function boundedInsert(window: ListingCandidate[], candidate: ListingCand return true } +/** + * Await `operation`, but reject with the signal's reason the moment it + * aborts. Node's filesystem reads are not retractable, so the operation + * itself keeps running against a handle the caller then closes — its late + * settlement is swallowed here so an abandoned read cannot surface as an + * unhandled rejection. + * @param operation - the in-flight filesystem step. + * @param signal - caller lifetime; absent means plain awaiting. + * @returns the operation's value. + */ +export function raceAbort(operation: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return operation + return new Promise((resolve, reject) => { + const onAbort = (): void => { + operation.catch(() => { + // Abandoned read: its handle is being closed by the aborting caller, + // and the abort reason already carried the outcome. + }) + reject(asError(signal.reason)) + } + if (signal.aborted) { + onAbort() + return + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (reason: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(asError(reason)) + }, + ) + }) +} + +/** The thrown value as an Error (wire/abort reasons may be anything). */ +function asError(reason: unknown): Error { + return reason instanceof Error ? reason : new Error(String(reason)) +} + /** 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. */ @@ -180,16 +223,35 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { const window: ListingCandidate[] = [] let evicted = false try { - const level = await opendir(target) - for await (const dirent of level) { - // A disconnected/timed-out caller stops the scan here; throwing out - // of the loop closes the directory handle via the iterator's return. - signal?.throwIfAborted() - // Only rows a browser could enter contend for the window; dirent - // says "directory" outright, a symlink needs the later stat probe. - if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue - const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() } - if (boundedInsert(window, candidate, keep)) evicted = true + // Every filesystem await races the caller's signal: a stalled + // opendir/read on a network filesystem must not keep a departed + // caller's scan alive, and an already-aborted request rejects even + // when the level is empty. + const opening = opendir(target) + const level = await raceAbort(opening, signal).catch((error: unknown) => { + // The abandoned open can still mint a handle after the abort won; + // close it so a departed caller cannot leak a descriptor. (A lost + // race against opendir's own rejection has nothing to close.) + void opening.then(async (dir) => { await dir.close() }, () => { + // Already rejected: raceAbort surfaced or swallowed it. + }) + throw error + }) + try { + for (;;) { + const dirent = await raceAbort(level.read(), signal) + if (dirent === null) break + // Only rows a browser could enter contend for the window; dirent + // says "directory" outright, a symlink needs the later stat probe. + if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue + const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() } + if (boundedInsert(window, candidate, keep)) evicted = true + } + } finally { + // Manual read() never auto-closes; close on every exit, the aborted + // one included (its abandoned read settles against the closed handle + // and raceAbort already swallowed that settlement). + await level.close() } } catch (error: unknown) { // An abort is the caller's own reason, not an unreadable directory. diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 99366e8754..835948d9fe 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -7,7 +7,7 @@ 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, { boundedInsert, fullyQualified } from '../src/index.ts' +import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts' import type { ListingCandidate } from '../src/index.ts' let root: string @@ -86,8 +86,15 @@ describe('BrowseDirectoryPicker', () => { it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => { const gone = new AbortController() gone.abort(new Error('caller left')) - // The abort surfaces as-is, not dressed as an unreadable directory. + // The abort surfaces as-is, not dressed as an unreadable directory — + // and rejects even before any level row is read. await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left') + // The abandoned open that still succeeds is closed, not leaked. + await new Promise(resolve => setTimeout(resolve, 10)) + // Aborted against a missing target: the abandoned open rejects on its + // own and there is nothing to close. + await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left') + await new Promise(resolve => setTimeout(resolve, 10)) // A live signal changes nothing about ordinary failures. const live = new AbortController() const missing = join(root, 'no-such-dir') @@ -96,6 +103,34 @@ describe('BrowseDirectoryPicker', () => { expect((failure as DirectoryPickerError).code).toBe('directory-unreadable') }) + it('raceAbort follows the operation until the signal wins, and swallows the abandoned settlement', async () => { + // No signal / settled operations: plain passthrough, listener removed. + await expect(raceAbort(Promise.resolve('ok'), undefined)).resolves.toBe('ok') + const live = new AbortController() + await expect(raceAbort(Promise.resolve('ok'), live.signal)).resolves.toBe('ok') + // Failure passthrough keeps the operation's own error. + await expect(raceAbort(Promise.reject(new Error('raw failure')), live.signal)).rejects.toThrow('raw failure') + // The abort wins over a pending operation and carries its own reason; + // the operation's late rejection is swallowed, never unhandled. + const rejections: unknown[] = [] + const onUnhandled = (reason: unknown): void => { rejections.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + let rejectLate!: (reason: unknown) => void + const pending = new Promise((_resolve, reject) => { rejectLate = reject }) + const controller = new AbortController() + const raced = raceAbort(pending, controller.signal) + // A bare-string abort reason exercises the Error wrap. + controller.abort('caller left') + await expect(raced).rejects.toThrow('caller left') + rejectLate(new Error('late read failure')) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(rejections).toEqual([]) + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => { const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false }) const window: ListingCandidate[] = [] From 7d07ab0c9dc2983c7476d7648b30289dfaee6a38 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 05:32:12 +0800 Subject: [PATCH 26/26] fix(host): abandon close behind a stalled read; race symlink probes; observe cleanup failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The aborted exit no longer awaits close (Node queues it behind any in-flight read, chaining the departed caller back onto the very stall the abort escaped) — the abandoned close's failure is swallowed, it has no consumer. Symlink stat probes race the signal too, with a per-candidate abort check between probes, so a stalled probe target cannot keep a departed request alive. The deferred handle cleanup after a lost opendir race now consumes its own close failure instead of leaking it as an unhandled rejection. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- docs/config-catalog.md | 2 +- .../host/directory-picker-browse/src/index.ts | 42 +++++++++++++++---- .../tests/service.spec.ts | 7 +++- 6 files changed, 44 insertions(+), 15 deletions(-) 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 index 5696f35720..cd1441f46f 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 9d885abe394cfd30a174f07f6ac9432b774188b6 -2026-07-28-directory-picker-capability-seam.zh.md: 6dd1b66b508f5f06d6a150b6c45f0da7d36bf2ce +2026-07-28-directory-picker-capability-seam.md: 9f2703b7499870bf2d5ff8735e3c8cb2c351a503 +2026-07-28-directory-picker-capability-seam.zh.md: 946156a5d06cea30c833c768147b36b30f258192 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 index 9d885abe39..9f2703b749 100644 --- 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 @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **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. -- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller. An unbounded level is a memory/responsiveness hole for large or adversarial directories. +- **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.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 native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. 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 index 6dd1b66b50..946156a5d0 100644 --- 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 @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 +- **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 42eb9c2325..075597d502 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/host/directory-picker-browse/src/index.ts:170`](../packages/host/directory-picker-browse/src/index.ts) +Source: [`packages/host/directory-picker-browse/src/index.ts:181`](../packages/host/directory-picker-browse/src/index.ts) ## `@deepseek-ai/dsh-host-webserver` diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index dbb3a03a5b..6fa157ea87 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -138,6 +138,11 @@ function asError(reason: unknown): Error { return reason instanceof Error ? reason : new Error(String(reason)) } +/* v8 ignore start -- a close failure of an abandoned handle has no consumer, and forcing one needs a filesystem torn down mid-request. */ +/** Swallow the close failure of a handle its caller already departed. */ +function swallowCloseFailure(): void {} +/* v8 ignore stop */ + /** 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. */ @@ -149,13 +154,19 @@ function messageOf(error: unknown): string { * 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 { +async function directoryRow( + parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean, signal: AbortSignal | undefined, +): Promise { const path = join(parent, name) let enterable = isDirectory if (!enterable && isSymbolicLink) { try { - enterable = (await stat(path)).isDirectory() + // The probe races the caller too: a symlink target on a stalled + // network filesystem must not keep a departed caller's request alive. + enterable = (await raceAbort(stat(path), signal)).isDirectory() } catch { + /* v8 ignore next 2 -- an abort landing mid-probe needs a stalled stat; the per-candidate check in list covers the settled path. */ + if (signal?.aborted) throw asError(signal.reason) // Broken or cyclic symlink: stat is the probe, failure means "not enterable". return null } @@ -231,8 +242,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { const level = await raceAbort(opening, signal).catch((error: unknown) => { // The abandoned open can still mint a handle after the abort won; // close it so a departed caller cannot leak a descriptor. (A lost - // race against opendir's own rejection has nothing to close.) - void opening.then(async (dir) => { await dir.close() }, () => { + // race against opendir's own rejection has nothing to close, and + // the close's own failure is swallowed — the request already + // returned, so a cleanup error has no consumer.) + void opening.then(dir => dir.close().catch(swallowCloseFailure), () => { // Already rejected: raceAbort surfaced or swallowed it. }) throw error @@ -248,10 +261,18 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { if (boundedInsert(window, candidate, keep)) evicted = true } } finally { - // Manual read() never auto-closes; close on every exit, the aborted - // one included (its abandoned read settles against the closed handle - // and raceAbort already swallowed that settlement). - await level.close() + // Manual read() never auto-closes; close on every exit. The aborted + // exit must not await it — Node queues close behind any in-flight + // read, so awaiting would chain the departed caller back onto the + // very stall the abort escaped (the abandoned read's settlement is + // already swallowed by raceAbort). + const closing = level.close() + /* v8 ignore next 3 -- an abort between open and close needs a stalled read; the abandoned-close arm has no observable outcome. */ + if (signal?.aborted) { + closing.catch(swallowCloseFailure) + } else { + await closing + } } } catch (error: unknown) { // An abort is the caller's own reason, not an unreadable directory. @@ -261,7 +282,10 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { const entries: DirectoryEntry[] = [] let truncated = evicted for (const candidate of window) { - const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink) + // A caller that departed between reads and probes stops before the + // next probe (each probe's own await is raced inside directoryRow). + signal?.throwIfAborted() + const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal) if (row === null) continue if (entries.length === this.config.maxEntries) { truncated = true diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 835948d9fe..002d42e516 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -95,8 +95,13 @@ describe('BrowseDirectoryPicker', () => { // own and there is nothing to close. await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left') await new Promise(resolve => setTimeout(resolve, 10)) - // A live signal changes nothing about ordinary failures. + // A live signal leaves a normal listing untouched — the reads and the + // symlink probes race it without ever losing. const live = new AbortController() + const complete = await capability.list(root, live.signal) + expect(complete.truncated).toBe(false) + expect(complete.entries.map(entry => entry.name)).toContain('linked') + // A live signal changes nothing about ordinary failures. const missing = join(root, 'no-such-dir') const failure = await capability.list(missing, live.signal).catch((error: unknown) => error) expect(failure).toBeInstanceOf(DirectoryPickerError)