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:
6
packages/host/directory-picker/README.i18n.yaml
Normal file
6
packages/host/directory-picker/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md
|
||||
README.md: c1a801cf72f128e6e5ef668c5e28e03ad2284868
|
||||
README.zh.md: c352b35b70dfa835aecfcb5ffec2a9ac46f25c42
|
||||
19
packages/host/directory-picker/README.md
Normal file
19
packages/host/directory-picker/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-host-directory-picker
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'dialog', pick(signal) }` opens one native OS chooser on the host display ([`-dialog`](../directory-picker-dialog/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS dialog can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union is merge-extensible and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime.
|
||||
|
||||
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the seam serves the GUI host's directory selection; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note.
|
||||
19
packages/host/directory-picker/README.zh.md
Normal file
19
packages/host/directory-picker/README.zh.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @deepseek-ai/dsh-host-directory-picker
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'dialog', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-dialog`](../directory-picker-dialog/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型可合并扩展,未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。
|
||||
|
||||
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该 seam 服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV 缓存影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **没有多根词汇**——浏览契约每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。
|
||||
37
packages/host/directory-picker/package.json
Normal file
37
packages/host/directory-picker/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-directory-picker",
|
||||
"description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
118
packages/host/directory-picker/src/index.ts
Normal file
118
packages/host/directory-picker/src/index.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* The `ctx.directoryPicker` seam: how the web-GUI host lets an operator
|
||||
* select a workspace directory. Backends differ in interaction shape, not
|
||||
* just mechanism, so the service exposes a discriminated capability instead
|
||||
* of one method set: a `dialog` backend opens one native OS chooser on the
|
||||
* host's display, while a `browse` backend serves listing/creation primitives
|
||||
* for an in-app browser (and thereby works for remote clients no OS dialog
|
||||
* can reach). Consumers switch on `capability().kind`; the union is
|
||||
* merge-extensible, and the documented default for an unknown kind is to
|
||||
* hide the picking affordance rather than fail.
|
||||
* @module @deepseek-ai/dsh-host-directory-picker
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
|
||||
/** The dialog interaction: one native OS directory chooser on the host display. */
|
||||
export interface DirectoryPickerDialogCapability {
|
||||
kind: 'dialog'
|
||||
/**
|
||||
* Open the chooser and wait for the operator.
|
||||
* @param signal - caller/connection lifetime; abort terminates the chooser.
|
||||
* @returns the chosen absolute path, or null when the operator cancels.
|
||||
*/
|
||||
pick(signal: AbortSignal): Promise<string | null>
|
||||
}
|
||||
|
||||
/** One directory row: a listing child or a breadcrumb ancestor. */
|
||||
export interface DirectoryEntry {
|
||||
/** Base name shown in a browser row (a root crumb carries its full path). */
|
||||
name: string
|
||||
/** Absolute host path — clients never join path segments themselves. */
|
||||
path: string
|
||||
/** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
/** One directory level plus its ancestry, as a browse backend reports it. */
|
||||
export interface DirectoryListing {
|
||||
/** Absolute path of the listed directory. */
|
||||
path: string
|
||||
/** The host account's home directory (breadcrumb "Home" rooting). */
|
||||
home: string
|
||||
/**
|
||||
* Ancestor chain from the filesystem root to the listed directory
|
||||
* inclusive; every crumb is a jump target (crumb `hidden` is always false).
|
||||
*/
|
||||
crumbs: DirectoryEntry[]
|
||||
/** Direct child directories, name-sorted; symlinks to directories included. */
|
||||
entries: DirectoryEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The browse interaction: listing/creation primitives an in-app browser
|
||||
* drives one level at a time. Works for remote clients — nothing renders on
|
||||
* the host display.
|
||||
*/
|
||||
export interface DirectoryPickerBrowseCapability {
|
||||
kind: 'browse'
|
||||
/**
|
||||
* List one directory level.
|
||||
* @param path - absolute directory to list; absent lists the home directory.
|
||||
* @returns the level's listing with ancestry.
|
||||
* @throws {DirectoryPickerError} `directory-unreadable` when the target cannot be listed.
|
||||
*/
|
||||
list(path?: string): Promise<DirectoryListing>
|
||||
/**
|
||||
* Create one child directory under an existing parent.
|
||||
* @param path - absolute existing parent directory.
|
||||
* @param name - single non-blank path segment (no separators, not `.`/`..`).
|
||||
* @returns the created directory's absolute path.
|
||||
* @throws {DirectoryPickerError} `directory-exists` for an existing child, `directory-create-failed` otherwise.
|
||||
*/
|
||||
createDirectory(path: string, name: string): Promise<string>
|
||||
}
|
||||
|
||||
/** Union of interaction shapes a backend can provide (merge-extensible: grows with backends). */
|
||||
export type DirectoryPickerCapability = DirectoryPickerDialogCapability | DirectoryPickerBrowseCapability
|
||||
|
||||
/** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */
|
||||
export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed'
|
||||
|
||||
/** Typed failure thrown by browse primitives so consumers can map business codes without string matching. */
|
||||
export class DirectoryPickerError extends Error {
|
||||
/**
|
||||
* @param code - closed business code of the failure.
|
||||
* @param path - the absolute path the failure is about.
|
||||
* @param message - operator-facing description.
|
||||
*/
|
||||
constructor(readonly code: DirectoryPickerErrorCode, readonly path: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'DirectoryPickerError'
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
directoryPicker: DirectoryPicker
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract directory-picking service. Subclass, implement `capability()`, and
|
||||
* load the subclass as a plugin — it registers as `ctx.directoryPicker` (one
|
||||
* implementation per context; loading a second throws, cordis' standard
|
||||
* duplicate-service behavior). The capability object must be stable for the
|
||||
* service lifetime: consumers may capture it across calls.
|
||||
*/
|
||||
export abstract class DirectoryPicker extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'directoryPicker')
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend's interaction capability.
|
||||
* @returns the discriminated capability consumers switch on.
|
||||
*/
|
||||
abstract capability(): DirectoryPickerCapability
|
||||
}
|
||||
22
packages/host/directory-picker/src/invariant.ts
Normal file
22
packages/host/directory-picker/src/invariant.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'host-directory-picker-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: this stateless seam owns the capability vocabulary, while backends and the RPC consumer own observations. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register the directory-picker invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
35
packages/host/directory-picker/tests/seam.spec.ts
Normal file
35
packages/host/directory-picker/tests/seam.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** Contract behavior the seam itself owns: registration identity and typed failures. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts'
|
||||
import type { DirectoryPickerCapability } from '../src/index.ts'
|
||||
|
||||
/** Minimal concrete backend: all a subclass owes the abstract class is capability(). */
|
||||
class StubPicker extends DirectoryPicker {
|
||||
private readonly stub: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null }
|
||||
capability(): DirectoryPickerCapability {
|
||||
return this.stub
|
||||
}
|
||||
}
|
||||
|
||||
describe('DirectoryPicker seam', () => {
|
||||
it('registers a subclass as ctx.directoryPicker and leaves with its fiber', async () => {
|
||||
const ctx = new Context()
|
||||
const fiber = ctx.plugin(StubPicker)
|
||||
await fiber.await()
|
||||
expect(ctx.get('directoryPicker')).toBeInstanceOf(StubPicker)
|
||||
expect(ctx.get('directoryPicker')!.capability().kind).toBe('dialog')
|
||||
await fiber.dispose()
|
||||
expect(ctx.get('directoryPicker')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries the business code and subject path on DirectoryPickerError', () => {
|
||||
const failure = new DirectoryPickerError('directory-exists', '/home/u/x', '/home/u/x already exists')
|
||||
expect(failure.name).toBe('DirectoryPickerError')
|
||||
expect(failure.code).toBe('directory-exists')
|
||||
expect(failure.path).toBe('/home/u/x')
|
||||
expect(failure.message).toContain('already exists')
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
})
|
||||
})
|
||||
21
packages/host/directory-picker/tsconfig.json
Normal file
21
packages/host/directory-picker/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user