mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(host): directory-picker capability seam with dialog and browse backends
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
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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`。
|
||||
@@ -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:
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -136,6 +136,10 @@ flowchart LR
|
||||
svc_spillStore["ctx.spillStore<br/>Spill storage seam"]
|
||||
pkg_spill_local["spill-local"]
|
||||
pkg_spill_policy["spill-policy"]
|
||||
pkg_directory_picker["directory-picker"]
|
||||
svc_directoryPicker["ctx.directoryPicker<br/>Workspace-directory picking seam"]
|
||||
pkg_directory_picker_dialog["directory-picker-dialog"]
|
||||
pkg_directory_picker_browse["directory-picker-browse"]
|
||||
pkg_webserver["webserver"]
|
||||
svc_httpServer["ctx.httpServer<br/>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. |
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string[]>([
|
||||
['/', ['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<typeof RpcId> => 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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -62,11 +62,22 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'dialog' | 'browse' }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string | null> {
|
||||
@@ -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<DirectoryPickerKind> {
|
||||
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<DirectoryListing> {
|
||||
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<string> {
|
||||
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.
|
||||
|
||||
@@ -80,11 +80,22 @@ export class FakeApiClient implements IApiClient {
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onCancel: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0 }))
|
||||
onDescribe: (payload: unknown) => Promise<RpcResponse<{ version: string; cwd: string; attachedSessions: number; directoryPicker: 'dialog' | 'browse' }>> =
|
||||
() => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, directoryPicker: 'browse' as const }))
|
||||
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
|
||||
() => Promise.resolve(ok({ path: null }))
|
||||
|
||||
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
|
||||
path: string
|
||||
home: string
|
||||
crumbs: { name: string; path: string; hidden: boolean }[]
|
||||
entries: { name: string; path: string; hidden: boolean }[]
|
||||
}>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] }))
|
||||
|
||||
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
|
||||
() => Promise.resolve(ok({ path: '/home/fake/new' }))
|
||||
|
||||
private readonly muxConns: StreamConn<MuxFrame>[] = []
|
||||
private readonly hostConns: StreamConn<HostFrame>[] = []
|
||||
|
||||
@@ -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<RpcResponse<{ items: never[] }>> = () => Promise.resolve(ok({ items: [] }))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<DirectoryListing>;\n createDirectory(path: string, name: string): Promise<string>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'DirectoryPickerCapability',
|
||||
declaration: 'export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability;',
|
||||
},
|
||||
{
|
||||
name: 'DirectoryPickerDialogCapability',
|
||||
declaration: 'export interface DirectoryPickerDialogCapability {\n kind: \'dialog\';\n pick(signal: AbortSignal): Promise<string | null>;\n}',
|
||||
},
|
||||
{
|
||||
name: 'Domain',
|
||||
declaration: 'export interface Domain<S extends DomainSpec> {\n readonly name: string;\n readonly global: DomainGlobalHandleOf<S>;\n table<N extends keyof S[\'tables\'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>;\n close(): Promise<void>;\n}',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 组的原因:它拥有这条线的两端。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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))。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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<string | null>
|
||||
}
|
||||
|
||||
/** 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: {
|
||||
|
||||
@@ -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<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.pickDirectory request payload (empty object literal). */
|
||||
@@ -25,3 +27,38 @@ export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<W
|
||||
export const hostPickDirectoryValueSchema = z.object({
|
||||
path: z.string().nullable(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
|
||||
|
||||
/** 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<Wire<DirectoryEntry>>
|
||||
|
||||
/** host.listDirectory request payload; an absent path lists the home directory. */
|
||||
export const hostListDirectoryRequestSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'host.listDirectory'>>>
|
||||
|
||||
/** 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<Wire<ResponseValue<'host.listDirectory'>>>
|
||||
|
||||
/** 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<Wire<RequestPayload<'host.createDirectory'>>>
|
||||
|
||||
/** host.createDirectory response value: the created directory's absolute path. */
|
||||
export const hostCreateDirectoryValueSchema = z.object({
|
||||
path: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.createDirectory'>>>
|
||||
|
||||
@@ -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<RpcResponse<{
|
||||
version: string
|
||||
@@ -21,11 +56,34 @@ export interface HostApi {
|
||||
provider?: string
|
||||
model?: string
|
||||
attachedSessions: number
|
||||
directoryPicker: DirectoryPickerKind
|
||||
}>>
|
||||
|
||||
/** 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<RpcResponse<{ path: string | null }>>
|
||||
|
||||
/**
|
||||
* 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<RpcResponse<DirectoryListing>>
|
||||
|
||||
/**
|
||||
* 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<RpcResponse<{ path: string }>>
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -42,6 +42,10 @@ export const rpcErrorSchema: z.ZodType<RpcError> = 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<RpcError>
|
||||
|
||||
@@ -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': {}
|
||||
}
|
||||
|
||||
@@ -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<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
|
||||
listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.listDirectory'>>>
|
||||
createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.createDirectory'>>>
|
||||
}
|
||||
workspace: {
|
||||
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
|
||||
@@ -98,6 +103,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'session.cancel': sessionCancelValueSchema,
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.pickDirectory': hostPickDirectoryValueSchema,
|
||||
'host.listDirectory': hostListDirectoryValueSchema,
|
||||
'host.createDirectory': hostCreateDirectoryValueSchema,
|
||||
'workspace.list': workspaceListValueSchema,
|
||||
'workspace.create': workspaceCreateValueSchema,
|
||||
'workspace.rename': workspaceRenameValueSchema,
|
||||
@@ -305,6 +312,8 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
// A native system dialog is user-paced and may legitimately stay open
|
||||
// longer than the normal unary deadline. Caller/connection aborts remain.
|
||||
pickDirectory: (payload, signal) => 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'] = {
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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<Config> = z.object({
|
||||
provider: z.string().required(),
|
||||
|
||||
@@ -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<string | null>,
|
||||
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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
{
|
||||
"path": "../../workspace/workspace"
|
||||
},
|
||||
{
|
||||
"path": "../directory-picker"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
6
packages/host/directory-picker-browse/README.i18n.yaml
Normal file
6
packages/host/directory-picker-browse/README.i18n.yaml
Normal file
@@ -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
|
||||
21
packages/host/directory-picker-browse/README.md
Normal file
21
packages/host/directory-picker-browse/README.md
Normal file
@@ -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.
|
||||
21
packages/host/directory-picker-browse/README.zh.md
Normal file
21
packages/host/directory-picker-browse/README.zh.md
Normal file
@@ -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 范围而非边界——等到有部署需要时再做。
|
||||
40
packages/host/directory-picker-browse/package.json
Normal file
40
packages/host/directory-picker-browse/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
122
packages/host/directory-picker-browse/src/index.ts
Normal file
122
packages/host/directory-picker-browse/src/index.ts
Normal file
@@ -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<DirectoryEntry | null> {
|
||||
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<DirectoryListing> {
|
||||
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<string> {
|
||||
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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
packages/host/directory-picker-browse/src/invariant.ts
Normal file
25
packages/host/directory-picker-browse/src/invariant.ts
Normal file
@@ -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))
|
||||
96
packages/host/directory-picker-browse/tests/service.spec.ts
Normal file
96
packages/host/directory-picker-browse/tests/service.spec.ts
Normal file
@@ -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<void>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
24
packages/host/directory-picker-browse/tsconfig.json
Normal file
24
packages/host/directory-picker-browse/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/host/directory-picker-dialog/README.i18n.yaml
Normal file
6
packages/host/directory-picker-dialog/README.i18n.yaml
Normal file
@@ -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
|
||||
17
packages/host/directory-picker-dialog/README.md
Normal file
17
packages/host/directory-picker-dialog/README.md
Normal file
@@ -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).
|
||||
17
packages/host/directory-picker-dialog/README.zh.md
Normal file
17
packages/host/directory-picker-dialog/README.zh.md
Normal file
@@ -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 后端)。
|
||||
40
packages/host/directory-picker-dialog/package.json
Normal file
40
packages/host/directory-picker-dialog/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
33
packages/host/directory-picker-dialog/src/index.ts
Normal file
33
packages/host/directory-picker-dialog/src/index.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
25
packages/host/directory-picker-dialog/src/invariant.ts
Normal file
25
packages/host/directory-picker-dialog/src/invariant.ts
Normal file
@@ -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))
|
||||
@@ -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'
|
||||
|
||||
@@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
|
||||
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 })
|
||||
21
packages/host/directory-picker-dialog/tests/service.spec.ts
Normal file
21
packages/host/directory-picker-dialog/tests/service.spec.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
24
packages/host/directory-picker-dialog/tsconfig.json
Normal file
24
packages/host/directory-picker-dialog/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/host/directory-picker/README.i18n.yaml
Normal file
6
packages/host/directory-picker/README.i18n.yaml
Normal file
@@ -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
|
||||
19
packages/host/directory-picker/README.md
Normal file
19
packages/host/directory-picker/README.md
Normal file
@@ -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.
|
||||
19
packages/host/directory-picker/README.zh.md
Normal file
19
packages/host/directory-picker/README.zh.md
Normal file
@@ -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。
|
||||
37
packages/host/directory-picker/package.json
Normal file
37
packages/host/directory-picker/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
118
packages/host/directory-picker/src/index.ts
Normal file
118
packages/host/directory-picker/src/index.ts
Normal file
@@ -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<string | null>
|
||||
}
|
||||
|
||||
/** 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<DirectoryListing>
|
||||
/**
|
||||
* 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<string>
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
22
packages/host/directory-picker/src/invariant.ts
Normal file
22
packages/host/directory-picker/src/invariant.ts
Normal file
@@ -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))
|
||||
35
packages/host/directory-picker/tests/seam.spec.ts
Normal file
35
packages/host/directory-picker/tests/seam.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
21
packages/host/directory-picker/tsconfig.json
Normal file
21
packages/host/directory-picker/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
@@ -226,6 +226,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -73,6 +73,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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.' },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user