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:
creatixchu
2026-07-28 15:44:53 +08:00
parent d1ce22e7ad
commit 7fd2abd828
73 changed files with 1536 additions and 49 deletions

View 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 .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

View File

@@ -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.

View File

@@ -0,0 +1,36 @@
# Agent Noteweb 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 后端保留。** 插件化正是目的:多方都能提供该 seamElectron 壳可以原生提供 `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`

View File

@@ -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:

View File

@@ -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:^",

View File

@@ -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. |

View File

@@ -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))

View File

@@ -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.

View File

@@ -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) |

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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'] = {

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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: [] }))

View File

@@ -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 () => {

View File

@@ -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}',

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/README.md
README.md: 61e50cb64b95932085342b01a22d029cf8d5a228
README.zh.md: 3109eccf89ee4c2d4d01be546e3ee9ead9084edc
README.md: 81c483674c0d30847318b8fd9014bd8bb7d341c2
README.zh.md: 8b5ecd89ff4b1407cfa8c2bad63c77412b5fd16c

View File

@@ -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.

View File

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

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
README.md: 2f51aa23e2639e2e98dfdd8aaf71e807d641c3dc
README.zh.md: 687e60879702a762d9e85295789b77daea4bd4ac
README.md: 7c53e6dc9ac5758fce91d8b39abbfab6641384d1
README.zh.md: 5089935fe545bfc6ac3ddb39b9a2a25b50e1ceab

View File

@@ -16,7 +16,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`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)).

View File

@@ -16,7 +16,7 @@ mux 流会在每个已附加会话的订阅基线之后,以及对应的实时
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`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)

View File

@@ -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:^",

View File

@@ -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: {

View File

@@ -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'>>>

View File

@@ -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 }>>
}

View File

@@ -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'

View File

@@ -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']

View File

@@ -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>

View File

@@ -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': {}
}

View File

@@ -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'] = {

View File

@@ -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) },

View File

@@ -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(),

View File

@@ -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', () => {

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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' })
})
})

View File

@@ -50,6 +50,9 @@
{
"path": "../../workspace/workspace"
},
{
"path": "../directory-picker"
},
{
"path": "../../support/invariants"
}

View 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

View 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.

View 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 范围而非边界——等到有部署需要时再做。

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

View 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)}`)
}
}
}

View 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))

View 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')
})
})

View 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"
}
]
}

View 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

View 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).

View 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 后端)。

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

View 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
}
}

View 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))

View File

@@ -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'

View File

@@ -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 })

View 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()
})
})

View 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"
}
]
}

View 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

View 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.

View 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。

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

View 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
}

View 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))

View 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)
})
})

View 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
View File

@@ -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:

View File

@@ -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',

View File

@@ -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',

View File

@@ -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.' },

View File

@@ -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"],

View File

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