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