Merge remote-tracking branch 'upstream/master' into fix/hook-matcher-validation

This commit is contained in:
ZiyaZhang
2026-07-28 19:57:23 -07:00
106 changed files with 3138 additions and 178 deletions

View File

@@ -8,6 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -575,6 +575,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()
@@ -980,7 +1011,42 @@ 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 }),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
listDirectory: (request) => {
const target = request.payload.path ?? FIXTURE_HOME
const children = childrenOf(target)
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('.') })),
// The fixture tree is tiny; no level ever reaches a backend bound.
truncated: false,
})
},
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 } })
}
// Same root special case as listDirectory's entry paths: a plain join
// under '/' would mint '//name' and fork the tree's identity.
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
if (children.includes(request.payload.name)) {
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
}
directoryTree.set(parent, [...children, request.payload.name])
directoryTree.set(target, [])
return ok(request, { path: target })
},
openPath: request => ok(request, { opened: true as const }),
},
workspace: {
@@ -1335,6 +1401,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, new AbortController().signal)
case 'host.createDirectory': return this.api.host.createDirectory(request)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(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,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -70,6 +70,18 @@ export class FakeApiClient implements IApiClient {
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -91,6 +103,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)),
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
}

View File

@@ -319,6 +319,22 @@ describe('createFixtureApi', () => {
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
})
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
const api = createFixtureApi()
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.path).toBe('/srv')
const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal)
if (!listed.result.ok) throw new Error('list failed')
expect(listed.result.value.crumbs).toEqual([
{ name: '/', path: '/', hidden: false },
{ name: 'srv', path: '/srv', hidden: false },
])
const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal)
if (!root.result.ok) throw new Error('root list failed')
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
})
it('workspace.list serves the resident account and create reuses on path collision', async () => {
const api = createFixtureApi()
const listed = await api.workspace.list(req({}))

View File

@@ -18,7 +18,7 @@ export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.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 { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
@@ -29,7 +29,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, 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, 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'
@@ -30,6 +31,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 implements IWorkspaces {
/** UI-facing immutable projection; the manager remains wire truth. */
@@ -172,7 +181,7 @@ export class WorkspacesService implements IWorkspaces {
}
/**
* Open the Host's native directory picker.
* Open the Host's native directory picker (the `native` capability).
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
@@ -183,6 +192,29 @@ export class WorkspacesService implements IWorkspaces {
return response.result.value.path
}
/**
* 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
}
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.

View File

@@ -88,6 +88,18 @@ export class FakeApiClient implements IApiClient {
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -109,6 +121,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)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}

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,29 @@ 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('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 }], truncated: 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('opens a filesystem path through the host without local state', async () => {

View File

@@ -36,13 +36,22 @@ export interface DeferredRegistration {
* @param name - target slot name.
* @param component - the component whose ledger presence marks "registered".
* @param register - performs the actual registration; returns its disposer.
* @param onFailure - owns a registration failure that fires from a LATER
* ledger flush (a declaration landing after two providers deferred, say):
* the deferral first removes its own subscription, then hands the error
* over instead of throwing through the flush — the callback's chance to
* roll back sibling deferrals and surface the conflict on a loud channel.
* Absent, a late failure rethrows out of the flush.
* @returns the deferral handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after removing the
* just-installed subscription — a throwing construction leaves nothing live.
*/
export function deferRegistration(
registry: DeferralRegistry,
name: string,
component: unknown,
register: () => () => void,
onFailure?: (error: unknown) => void,
): DeferredRegistration {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
@@ -50,8 +59,24 @@ export function deferRegistration(
if (registry.entries(name).some(e => e.component === component)) return
dispose = register()
}
const unsubscribe = registry.subscribe(name, () => { tryRegister() })
tryRegister()
const unsubscribe = registry.subscribe(name, () => {
try {
tryRegister()
} catch (error) {
unsubscribe()
if (onFailure === undefined) throw error
onFailure(error)
}
})
try {
tryRegister()
} catch (error) {
// A synchronous registration failure (the declared slot is already
// occupied) must not leave the just-installed subscription behind: the
// caller receives no handle to dispose it through.
unsubscribe()
throw error
}
return {
refresh() {
dispose?.()
@@ -64,3 +89,40 @@ export function deferRegistration(
},
}
}
/**
* Defer ONE occupant into several holes as a unit. Construction that throws
* partway (a declared hole already occupied registers synchronously) rolls
* every earlier deferral back before rethrowing; a failure surfacing from a
* LATER ledger flush (holes declared after rival providers activated) rolls
* the whole group back the same way and re-raises the wrapped error on the
* global channel the boot's fail-loud handler owns — never a throw through
* the slot flush, never partial occupancy from the group's owner.
* @param registry - the slot registry face.
* @param names - the target holes (one registration per name).
* @param component - the occupant whose ledger presence marks "registered".
* @param register - performs one hole's registration; returns its disposer.
* @returns the group handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after rolling the group back.
*/
export function deferGroupRegistration<K extends string>(
registry: DeferralRegistry,
names: readonly K[],
component: unknown,
register: (name: K) => () => void,
): { dispose: () => void } {
const deferred: DeferredRegistration[] = []
const lateFailure = (error: unknown): void => {
for (const entry of deferred) entry.dispose()
queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) })
}
try {
for (const name of names) {
deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure))
}
} catch (error) {
for (const entry of deferred) entry.dispose()
throw error
}
return { dispose: () => { for (const entry of deferred) entry.dispose() } }
}

View File

@@ -0,0 +1,126 @@
// deferRegistration lifecycle: declaration-aware registration, HMR
// re-registration, and — the failure contract — no subscription survives a
// construction that throws synchronously (an already-occupied single slot).
import { describe, expect, it, vi } from 'vitest'
import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// Shares the merges declared by core.spec.ts (same program); reuse its keys.
const HOLE = 'test.single' as const
function declared(): SlotCore {
const core = new SlotCore()
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
return core
}
describe('deferRegistration', () => {
it('registers immediately under an existing declaration and disposes cleanly', () => {
const core = declared()
const component = (): null => null
const handle = deferRegistration(core, HOLE, component, () =>
core.register({ name: HOLE } as never, component as never))
expect(core.entries(HOLE)).toHaveLength(1)
handle.dispose()
expect(core.entries(HOLE)).toHaveLength(0)
})
it('hands a late registration failure to onFailure after unsubscribing itself', async () => {
const core = new SlotCore()
const component = (): null => null
const foreign = (): null => null
const failures: unknown[] = []
// Nothing is declared yet: the deferral just subscribes and waits.
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) })
// The declaration lands with a foreign occupant racing in first: the
// deferral's flush-time attempt fails, unsubscribes itself, and reports
// through onFailure instead of throwing out of the flush.
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
await Promise.resolve()
expect(failures.map(String).join('')).toContain('already has a registration')
// Unsubscribed: freeing the hole must not resurrect the loser.
disposeForeign()
await Promise.resolve()
expect(core.entries(HOLE)).toHaveLength(0)
})
it('drops its subscription when the immediate registration throws', async () => {
const core = declared()
const foreign = (): null => null
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
const component = (): null => null
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
// The single hole is occupied: the immediate attempt throws out of the
// constructor, and the caller never receives a handle to dispose.
expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/)
expect(register).toHaveBeenCalledOnce()
// The subscription rolled back with it: freeing the hole flushes a
// notification that must not resurrect the failed registration.
disposeForeign()
await Promise.resolve()
expect(register).toHaveBeenCalledOnce()
expect(core.entries(HOLE)).toHaveLength(0)
})
})
describe('deferGroupRegistration', () => {
const HOLES = ['test.single', 'test.grandchild'] as const
function declaredPair(): SlotCore {
const core = new SlotCore()
core.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, (() => null) as never)
return core
}
it('registers the whole group and disposes it as a unit', () => {
const core = declaredPair()
const component = (): null => null
const group = deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))
for (const name of HOLES) expect(core.entries(name)).toHaveLength(1)
group.dispose()
for (const name of HOLES) expect(core.entries(name)).toHaveLength(0)
})
it('rolls the group back when construction fails partway', () => {
const core = declaredPair()
const component = (): null => null
core.register({ name: HOLES[1] } as never, (() => null) as never)
expect(() => deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))).toThrow(/already has a registration/)
// The first hole's registration and subscription rolled back with it.
expect(core.entries(HOLES[0])).toHaveLength(0)
})
it('rolls the group back and re-raises loudly on a late conflict', async () => {
const core = new SlotCore()
const component = (): null => null
const failures: unknown[] = []
const onLoud = (reason: unknown): void => { failures.push(reason) }
process.on('uncaughtException', onLoud)
try {
const group = deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))
// Declaration lands with a rival racing in ahead of the flush.
core.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, (() => null) as never)
core.register({ name: HOLES[0] } as never, (() => null) as never)
core.register({ name: HOLES[1] } as never, (() => null) as never)
await new Promise(resolve => setTimeout(resolve, 20))
expect(failures.map(String).join('')).toContain('already has a registration')
// No partial occupancy from the group's owner survives.
for (const name of HOLES) {
expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0)
}
group.dispose()
} finally {
process.off('uncaughtException', onLoud)
}
})
})

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/client/ui-workspace/README.md
README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96
README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot因此两个表层使用同一菜单和创建流程。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -19,4 +19,4 @@
## 已知限制与暂缓事项
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。
- **原生文件夹选择依赖本地 Host 载体**`-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -253,7 +253,8 @@ export function WorkspaceBrowser({
deleteWorkspace,
insertSessionBefore,
createWorkspace,
pickDirectory,
useDirectoryFlow,
renderSlot,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -371,7 +372,8 @@ export function WorkspaceBrowser({
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
createOnly
side="right"
onPick={(workspaceId) => {

View File

@@ -2,10 +2,12 @@
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration.
* slot registration. Directory picking itself lives in the composed flow
* package's slot occupant (see the contract module doc): this core only
* opens the flow, adopts the picked path, and owns the error surface.
*/
import type { RefObject } from 'react'
import { useCallback, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -13,7 +15,8 @@ import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
@@ -31,8 +34,10 @@ export interface WorkspaceCreateFlowProps {
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Open the Host's native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
useDirectoryFlow: SnapshotSelectorHook<boolean>
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -55,7 +60,8 @@ export function WorkspaceCreateFlow({
anchorRef,
useWorkspaces,
createWorkspace,
pickDirectory,
useDirectoryFlow,
renderDirectoryFlow,
onPick,
onClose,
createOnly = false,
@@ -72,16 +78,37 @@ export function WorkspaceCreateFlow({
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
// One picking interaction at a time: while the flow is open (native chooser
// pending, browse dialog up) or its pick is being adopted, every other
// menu action stays disabled — a late outcome must not race a concurrent
// selection or creation.
const flowBusy = flowOpen || pickingFolder
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The occupied hole gates the picking affordance: with no composed flow the
// entry simply is not there (the seam's documented no-flow default). The
// framework-bound hook keeps occupancy live: flow plugins activate (and
// HMR-reload) independently of this menu's renders.
const flowAvailable = useDirectoryFlow(occupied => occupied)
// An occupant that unloads mid-interaction leaves nobody to cancel: an
// open flow over an empty hole withdraws so the menu actions come back.
// flowOpen is a dependency because the flow can also OPEN over an already
// empty hole (Choose again after the occupant unloaded with the error
// dialog up) — that transition must snap back too, not just occupancy loss.
useEffect(() => {
if (flowOpen && !flowAvailable) setFlowOpen(false)
}, [flowOpen, flowAvailable])
const createEntries: MenuEntry[] = [
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
...(flowAvailable
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
@@ -91,7 +118,7 @@ export function WorkspaceCreateFlow({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
disabled: flowBusy,
}))
: createEntries
@@ -101,15 +128,10 @@ export function WorkspaceCreateFlow({
setModalError(null)
}
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setPickingFolder(true)
void pickDirectory().then(async (path) => {
if (path === null) return
const workspace = await createWorkspace({ path })
/** Adopt a picked directory; failures land in the folder-error dialog (Choose again reopens the flow). */
const adoptDirectory = (path: string): Promise<void> =>
createWorkspace({ path }).then((workspace) => {
setFlowOpen(false)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
@@ -117,8 +139,33 @@ export function WorkspaceCreateFlow({
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setModalKind('folder-error')
}).finally(() => { setPickingFolder(false) })
})
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
const flowOwner: DirectoryFlowOwnerProps = {
open: flowOpen,
busy: pickingFolder,
onPicked: (path) => {
setPickingFolder(true)
void adoptDirectory(path).finally(() => { setPickingFolder(false) })
},
onCancel: () => { setFlowOpen(false) },
onError: (message) => {
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setModalKind('folder-error')
},
}
const handleSelect = (id: string): void => {
@@ -172,6 +219,7 @@ export function WorkspaceCreateFlow({
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
{renderDirectoryFlow(flowOwner)}
<Modal
open={modalKind === 'folder-error'}
onClose={closeModal}
@@ -179,7 +227,9 @@ export function WorkspaceCreateFlow({
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
{/* Retrying needs an occupant to serve the flow; without one the
* button would open a flow nobody can answer or cancel. */}
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>Choose again</Button>
</>
)}
>
@@ -249,7 +299,8 @@ export function WorkspacePicker({
onPick,
onClose,
createWorkspace,
pickDirectory,
useDirectoryFlow,
renderSlot,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -257,7 +308,8 @@ export function WorkspacePicker({
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
selectedId={selectedId}
onPick={onPick}
onClose={onClose}

View File

@@ -7,8 +7,19 @@
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
*
* Each registration also declares one **directory-flow hole** (`single`
* kind): the slot a composed picker package's client half fills with its
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
* menu entry, shown only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
* again); the occupant owns everything between `open` and the picked path.
* Two holes exist because the two menu surfaces are independent slot entries
* and a hole has exactly one declaring entry — they carry the same owner
* contract and the same occupant.
*/
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -16,12 +27,64 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
* Owner share of the directory-flow holes: the complete conversation between
* the trigger surface and the picking interaction. The occupant reads `open`
* to run/render its interaction and reports exactly one outcome per open.
*/
export interface DirectoryFlowOwnerProps {
/** True while a picking interaction is requested; flipping back to false withdraws the request. */
open: boolean
/** True while the owner adopts a picked path (`createWorkspace` in flight); occupants disable their commit affordances. */
busy: boolean
/** The operator picked a directory (absolute host path); the owner adopts it. */
onPicked: (path: string) => void
/** The operator dismissed the interaction; the owner just closes the flow. */
onCancel: () => void
/** The interaction itself failed (chooser missing, listing denied); the owner shows its error surface. */
onError: (message: string) => void
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */
'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
/** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */
'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
}
}
/** The two directory-flow holes; a flow package's client half registers its one component into both. */
export type DirectoryFlowSlotName =
| 'conversation.hero.workspace.directoryFlow'
| 'sidebar.workspaces.directoryFlow'
/**
* Directory-picking share both trigger surfaces consume. Occupancy rides the
* inject face's reserved `hooks` compartment: the renderer binds the source
* into the `useDirectoryFlow` selector hook, so an empty hole hides the
* "Open local folder…" entry reactively and the surface withdraws an open
* flow whose occupant unloaded mid-interaction (nobody is left to cancel).
*/
export type DirectoryPickingInjected = {
hooks: {
/** True while this surface's directory-flow hole is occupied. */
directoryFlow: HostObservable<boolean>
}
}
/** Component-side view of the picking share: the bound occupancy selector hook. */
export type DirectoryPickingHooks = {
/** Selector hook over this surface's directory-flow occupancy. */
useDirectoryFlow: SnapshotSelectorHook<boolean>
}
/**
* Browser-private injected share (arrives via the register inject factory).
* Data reads use the global framework hooks; these are the Host actions the
* browsing region drives.
*/
export type WorkspaceBrowserInjected = {
export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
/**
* Start a New Session in a Workspace: reuse-or-create its blank session
* and open it; with no workspace, clear the selection into the New Session
@@ -42,26 +105,24 @@ export type WorkspaceBrowserInjected = {
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
& WorkspaceBrowserInjected
& Omit<WorkspaceBrowserInjected, 'hooks'>
& DirectoryPickingHooks
/**
* Picker-private injected share. Pick semantics remain in the owner's onPick
* callback; this callback creates only the real Host Workspace. A type alias
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/**
@@ -70,4 +131,7 @@ export type WorkspacePickerInjected = {
* currency, so one composed type serves both registrations.
*/
export type WorkspacePickerProps =
PropsRuntime<'conversation.hero.workspace'> & WorkspacePickerInjected
PropsRuntime<'conversation.hero.workspace'>
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
& Omit<WorkspacePickerInjected, 'hooks'>
& DirectoryPickingHooks

View File

@@ -3,9 +3,13 @@
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
* and WorkspacePicker fills the conversation hero's picker hole
* (`conversation.hero.workspace` — both hero forms). Both read real Host
* Workspaces through the global useWorkspaces hook. Export discipline:
* Workspaces through the global useWorkspaces hook, and each declares its
* own `single` directory-flow child hole for the composed picker package's
* client half (see the contract module doc). Export discipline:
* packages/client/AGENTS.md.
*/
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
@@ -13,6 +17,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type {
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
@@ -33,6 +38,14 @@ export const inject = ['slots', 'sessions', 'workspaces']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
// Stable per-surface occupancy sources (the renderer's hook cache keys by
// source identity): true while the surface's directory-flow hole is filled.
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
getSnapshot: () => ctx.slots.entries(hole).length > 0,
subscribe: listener => ctx.slots.subscribe(hole, listener),
})
const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow')
const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow')
const browserInjected = (): WorkspaceBrowserInjected => ({
// Explicit group actions keep their target; unscoped New Session rides
// the runtime's shared action (recent-Workspace projection inside).
@@ -44,48 +57,40 @@ export function apply(ctx: ClientContext): void {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
hooks: { directoryFlow: browserFlowSource },
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
hooks: { directoryFlow: pickerFlowSource },
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register
// into an undeclared slot throws. Register once the declaration is on the
// ledger; the subscription also re-registers after an HMR collapse
// re-declares the slot (the cascade disposed our entry with it).
// Declaration-aware registration (deferRegistration): each owner's
// declaring apply may activate after this one, and a register into an
// undeclared slot throws; the deferral also re-registers after an HMR
// collapse re-declares the slot. Each registration declares its own
// directory-flow child hole in the same call (declaration = render
// authorization, one table).
ctx.effect(() => {
const registrations = [
{
name: 'sidebar.workspaces' as const,
component: WorkspaceBrowser,
register: () => ctx.slots.register(
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
const deferred = [
deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () =>
ctx.slots.register(
{
name: 'sidebar.workspaces',
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
},
WorkspaceBrowser,
),
},
{
name: 'conversation.hero.workspace' as const,
component: WorkspacePicker,
register: () => ctx.slots.register(
{ name: 'conversation.hero.workspace', inject: pickerInjected },
)),
deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () =>
ctx.slots.register(
{
name: 'conversation.hero.workspace',
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
inject: pickerInjected,
},
WorkspacePicker,
),
},
)),
]
const disposers = new Map<string, () => void>()
const tryRegister = (entry: (typeof registrations)[number]): void => {
if (ctx.slots.spec(entry.name) === undefined) return
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
disposers.set(entry.name, entry.register())
}
const unsubscribers = registrations.map(entry =>
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
for (const entry of registrations) tryRegister(entry)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
return () => { for (const entry of deferred) entry.dispose() }
}, 'ui-workspace: browser + picker registrations')
}

View File

@@ -14,17 +14,16 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
ctx.provide('workspaces', {
create, pickDirectory, startSession, rename, insertSessionBefore,
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear }
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -73,14 +72,35 @@ describe('ui-workspace apply', () => {
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
await browser.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledOnce()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
await picker.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
})
it('declares the two directory-flow holes and reports their occupancy per surface', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
await b.ctx.plugin({ inject: [...inject], apply }).await()
// Registration declared the child holes (declaration = render authorization).
expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' })
expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' })
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
// A flow occupant flips exactly its own surface, and the source notifies.
const notified = vi.fn()
const unsubscribe = browser.hooks.directoryFlow.subscribe(notified)
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true)
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
await Promise.resolve()
expect(notified).toHaveBeenCalled()
dispose()
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
unsubscribe()
})
it('unregisters every entry on teardown', async () => {

View File

@@ -59,7 +59,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
pickDirectory: vi.fn(async () => null),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)

View File

@@ -5,6 +5,8 @@ import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
afterEach(cleanup)
@@ -35,14 +37,49 @@ function anchor(): { current: HTMLElement } {
return { current: element }
}
/**
* Probe occupant of the directory-flow hole: records the latest owner
* conversation so tests drive onPicked/onCancel/onError like a composed flow
* package would, and renders a marker element while the flow is open.
*/
function flowProbe() {
const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined }
const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => {
probe.owner = owner
return owner.open ? <div data-testid="directory-flow" data-busy={owner.busy} /> : null
}) as never
return { probe, renderSlot }
}
/** Manual occupancy source bound like the renderer would: flip() drives the hook like a real registration change. */
function occupancySource(initial = true) {
let occupied = initial
const listeners = new Set<() => void>()
const useDirectoryFlow = bindSnapshotSelector({
getSnapshot: () => occupied,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
})
return {
useDirectoryFlow,
flip: (next: boolean) => {
occupied = next
for (const listener of [...listeners]) listener()
},
}
}
function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
occupancy = occupancySource(),
) {
const onPick = vi.fn()
const onClose = vi.fn()
const anchorRef = anchor()
const { probe, renderSlot } = flowProbe()
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
<WorkspacePicker
open
@@ -52,14 +89,15 @@ function mount(
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
useDirectoryFlow={occupancy.useDirectoryFlow}
renderSlot={renderSlot}
/>
)
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace, pickDirectory,
view, onPick, onClose, createWorkspace, probe, occupancy,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
@@ -87,65 +125,75 @@ describe('WorkspacePicker', () => {
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
const createWorkspace = vi.fn(async () => created)
const pickDirectory = vi.fn(async () => '/tmp/project')
const b = mount([], createWorkspace, pickDirectory)
const b = mount([], createWorkspace)
expect(screen.queryByTestId('directory-flow')).toBeNull()
chooseItem('Open local folder…')
expect(pickDirectory).toHaveBeenCalledOnce()
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
expect(b.onClose).toHaveBeenCalled()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
// Successful adoption withdraws the flow request.
expect(screen.queryByTestId('directory-flow')).toBeNull()
})
it('treats native picker cancellation as a silent no-op', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null))
it('treats flow cancellation as a silent no-op', () => {
const b = mount([])
chooseItem('Open local folder…')
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
act(() => { b.probe.owner!.onCancel() })
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
expect(b.onPick).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('shows a name conflict and retries through the native picker', async () => {
const pickDirectory = vi.fn()
.mockResolvedValueOnce('/one/project')
.mockResolvedValueOnce(null)
it('shows a name conflict and retries by reopening the flow', async () => {
const createWorkspace = vi.fn(async () => {
throw new WorkspaceCreateError({
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
})
})
const b = mount([], createWorkspace, pickDirectory)
const b = mount([], createWorkspace)
chooseItem('Open local folder…')
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
// The failed adoption withdrew the flow; Choose again reopens it.
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})
it('disables the folder action while the native picker is already open', async () => {
let resolve!: (path: string | null) => void
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => {
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('adopted')
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
chooseItem('Open local folder…')
// The flow is open but nothing is picked yet: a chooser pending on the
// host display must already block concurrent workspace actions.
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
act(() => { b.probe.owner!.onPicked('/tmp/project') })
expect(b.probe.owner!.busy).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending })
await act(async () => { resolve(created); await pending })
expect(b.probe.owner!.busy).toBe(false)
})
it('reports non-Error native picker failures', async () => {
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
it('shows the flow-reported failure in the folder-error surface', () => {
const b = mount([])
chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
})
act(() => { b.probe.owner!.onError('no chooser installed') })
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
})
@@ -215,10 +263,12 @@ describe('WorkspacePicker', () => {
})
it('waits to show its menu until an optional anchor is available', () => {
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -228,12 +278,54 @@ describe('WorkspacePicker', () => {
const state: WorkspaceListState = {
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
}
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
})
it('hides the folder entry while the directory-flow hole is empty', () => {
mount([], vi.fn(), occupancySource(false))
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('shows the folder entry when a flow package activates after the first paint', () => {
const b = mount([], vi.fn(), occupancySource(false))
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
// Registration changes flow through the subscription, no re-render needed.
act(() => { b.occupancy.flip(true) })
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
})
it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
chooseItem('Open local folder…')
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldnt open folder' })).toBeTruthy() })
// The occupant unloads while the error dialog is up: retrying would open
// a flow nobody can serve or cancel, so the button goes inert.
act(() => { b.occupancy.flip(false) })
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Choose again' }).disabled).toBe(true)
// Cancel stays the way out, and the menu actions are usable again.
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
})
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
const b = mount([])
chooseItem('Open local folder…')
expect(screen.getByTestId('directory-flow')).toBeTruthy()
// The flow plugin unloads mid-interaction (HMR): nobody is left to
// cancel, so the owner withdraws and the actions come back.
act(() => { b.occupancy.flip(false) })
expect(b.probe.owner!.open).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
})

View File

@@ -264,6 +264,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.',
@@ -1649,6 +1659,30 @@ 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 truncated: boolean;\n}',
},
{
name: 'DirectoryPickerBrowseCapability',
declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise<DirectoryListing>;\n createDirectory(path: string, name: string): Promise<string>;\n}',
},
{
name: 'DirectoryPickerCapabilities',
declaration: 'export interface DirectoryPickerCapabilities {\n native: DirectoryPickerNativeCapability;\n browse: DirectoryPickerBrowseCapability;\n}',
},
{
name: 'DirectoryPickerCapability',
declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];',
},
{
name: 'DirectoryPickerNativeCapability',
declaration: 'export interface DirectoryPickerNativeCapability {\n kind: \'native\';\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: 7df0ecc4a362be1149188d133233307b1fc48c8a
README.zh.md: 90d5ea2b0947d2cff9ba06e89b6225b39dad7fce

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 `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` |
| `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) |
| `directory-picker-browse/` | In-app browsing backend: listing/creation primitives over Node stdlib; remote-capable | (registers `ctx.directoryPicker`) |
`apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.

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

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: 3639722ab25826af8f7a0721f22d244f78b4210b
README.zh.md: 3ac81fb412d4e4caf192953bc7a7c846a1d29965
README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9

View File

@@ -18,7 +18,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`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)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
@@ -41,4 +41,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 `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).

View File

@@ -18,7 +18,7 @@
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`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));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用调用方连接中止仍会传播至原生进程。`browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法。
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
@@ -41,4 +41,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 原生选择器依赖桌面工具**`native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md)

View File

@@ -44,7 +44,9 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",

View File

@@ -45,7 +45,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'
import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
@@ -195,6 +195,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
@@ -203,8 +211,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>
/** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void>
}
@@ -1090,8 +1096,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async pickDirectory(request, signal) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'native') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.pickDirectory needs the native 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) {
@@ -1109,6 +1123,45 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async listDirectory(request, signal) {
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 {
// The carrier's signal follows the caller: a disconnect or timeout
// stops the backend's directory scan instead of outliving it.
return ok(request, await capability.list(request.payload.path, signal))
} catch (error: unknown) {
// An abort is the caller's own timeout/disconnect, not a server
// failure — same code pickDirectory and command.execute report.
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
}
return err(request, directoryError(error))
}
},
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))
}
},
async openPath(request, signal) {
try {
const open = defaults.openPath

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,8 @@ export const hostDescribeValueSchema = z.object({
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
// Open string, not a literal union: unknown kinds must survive the wire so
// a merge-added capability can advertise (the client hides the affordance).
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */
@@ -26,6 +29,41 @@ 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),
truncated: z.boolean(),
}) 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'>>>
/** host.openPath request payload. */
export const hostOpenPathRequestSchema = z.object({
path: z.string().min(1),

View File

@@ -5,6 +5,33 @@
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** 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[]
/** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */
truncated: boolean
}
/** Host-level unary methods. */
export interface HostApi {
/**
@@ -13,7 +40,7 @@ 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);
*/
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
version: string
@@ -23,16 +50,42 @@ export interface HostApi {
attachedSessions: number
}>>
/** 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 `native` 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`. The
* carrier's request signal follows the caller, stopping the backend's scan
* on disconnect or timeout.
*/
listDirectory(
request: RpcRequest<{ path?: string }>,
signal: AbortSignal,
): Promise<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 }>>
/**
* Open a filesystem path with the operating system's default application
* (Finder / Explorer / xdg-open hand-off). The browser carrier restricts this
* privileged method to loopback, same-origin requests.
* (Finder / Explorer / xdg-open hand-off). The browser carrier's
* prefix-wide trust fence covers this privileged method like every other
* `/api` request.
*/
openPath(
request: RpcRequest<{ path: string }>,

View File

@@ -31,7 +31,7 @@ export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'

View File

@@ -27,6 +27,8 @@ export interface RpcMethodMap {
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']
'host.createDirectory': HostApi['createDirectory']
'host.openPath': HostApi['openPath']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']

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('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),

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 }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}

View File

@@ -14,7 +14,8 @@ 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, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
hostCreateDirectoryValueSchema, hostDescribeValueSchema,
hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
@@ -71,6 +72,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'>>>
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
}
workspace: {
@@ -117,6 +120,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,
'host.openPath': hostOpenPathValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
@@ -331,6 +336,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),
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
}

View File

@@ -24,7 +24,9 @@ import {
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import {
hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema,
hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
@@ -70,6 +72,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, signal) => api.host.listDirectory(r, signal) },
'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(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

@@ -1,6 +1,6 @@
/** Cross-platform open-with-default-application used by the local GUI carrier. */
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
/** Testable command boundary; native implementations never invoke a shell. */
export type PathOpenerRunner = NativeCommandRunner

View File

@@ -3,13 +3,15 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
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,10 +59,8 @@ 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-'))),
extras: {
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
openPath?: (path: string, signal: AbortSignal) => Promise<void>
} = {},
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -95,31 +95,34 @@ 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,
...extras.pickDirectory === undefined ? {} : { pickDirectory: extras.pickDirectory },
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
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, { pickDirectory: async () => '/tmp/project' })
it('returns a selected path or explicit cancellation from the native capability', async () => {
const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' })
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: '/tmp/project' } })
const cancelled = await harness(undefined, { pickDirectory: async () => null })
const cancelled = await harness(undefined, { kind: 'native', pick: async () => null })
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: null } })
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
it('propagates abort into the native capability as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
pickDirectory: signal => new Promise((_resolve, reject) => {
kind: 'native',
pick: signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
})
@@ -128,12 +131,97 @@ describe('host.pickDirectory', () => {
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
it('folds a non-abort native-chooser failure into an internal error', async () => {
const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
})
it('refuses the native RPC under a browse composition', async () => {
const { api } = await harness(undefined, BROWSE_STUB)
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
expect(response.result).toMatchObject({
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 }],
truncated: 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({}), new AbortController().signal)
expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal)
expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
})
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' }), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
})
expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
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('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => {
const { api } = await harness(undefined, {
kind: 'browse',
list: (_path, signal) => new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
}),
createDirectory: async () => '/never',
})
const abort = new AbortController()
const pending = api.host.listDirectory(request({}), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
it('refuses the browse RPCs under a native composition', async () => {
const { api } = await harness()
expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
})
})
describe('host.openPath', () => {
it('opens through the injected native boundary', async () => {
const opened: string[] = []
const { api } = await harness(undefined, {
const { api } = await harness(undefined, undefined, {
openPath: async (path) => { opened.push(path) },
})
expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
@@ -142,7 +230,7 @@ describe('host.openPath', () => {
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
const { api } = await harness(undefined, undefined, {
openPath: (_path, signal) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),

View File

@@ -53,6 +53,8 @@ function scriptedApi(overrides: {
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
createDirectory: r => ok(r, { path: '/t/new' }),
openPath: r => ok(r, { opened: true as const }),
...overrides.host,
},

View File

@@ -81,6 +81,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
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: [], truncated: false } } }
},
async createDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
},
async openPath(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
},
@@ -233,6 +239,19 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } })
})
it('round-trips the browse listing and creation calls through the wire form', async () => {
const c = client()
const listed = await c.host.listDirectory({ path: '/w' })
expect(listed.result).toEqual({
ok: true,
value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false },
})
const home = await c.host.listDirectory({})
expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } })
const created = await c.host.createDirectory({ path: '/w', name: 'fresh' })
expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } })
})
it('round-trips host.openPath through the wire form', async () => {
const api = fakeApi()
let opened: string | undefined

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,
@@ -227,6 +231,26 @@ describe('host domain schemas', () => {
expect(value.attachedSessions).toBe(2)
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
})
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 }],
truncated: false,
})
expect(listing.entries[0]?.hidden).toBe(true)
// The flag is part of the wire value, not an optional decoration.
expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow()
expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' })
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow()
}
expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' })
})
})
describe('workspace domain schemas', () => {

View File

@@ -56,8 +56,14 @@
{
"path": "../../workspace/workspace"
},
{
"path": "../directory-picker"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/native-command"
}
]
}

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: 9543fc89f1314d05d02df72e9b5d86af21fdad66
README.zh.md: dd314c5b5a709b2cf2e6840911fd665ead7d7e87

View File

@@ -0,0 +1,22 @@
# @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 native backend cannot.
Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
## Model Experience
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
- **No client half yet** — the in-app browsing dialog that consumes these primitives lands in the next PR of this stack; until then a `-browse` composition hides the picking affordance entirely (ui-workspace's documented empty-hole default) and the listing/creation RPCs go unconsumed.
- **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost.
- **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here.
- **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it.

View File

@@ -0,0 +1,22 @@
# @deepseek-ai/dsh-host-directory-picker-browse
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**`BrowseDirectoryPicker``browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/``C:\``list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo``/foo`)与不完整的 UNC 前缀(`\\``\\server`)——报 `directory-unreadable``directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
## 模型体验
无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
#### KV 缓存影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **尚无 client half**——消费这些原语的应用内浏览对话框在本栈的下一个 PR 落地;在那之前 `-browse` 组合会完全隐藏选目录入口ui-workspace 文档化的空洞默认行为),列举/创建 RPC 无消费者。
- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。
- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。

View File

@@ -0,0 +1,41 @@
{
"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:^",
"schemastery": "^3.18.0"
},
"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,324 @@
/**
* 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, opendir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
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
}
}
/**
* True when the path names one fixed filesystem location regardless of
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
* (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
* forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
* pass `isAbsolute` yet still resolve against the process's current drive.
* @param path - candidate path.
* @param platform - replaces `process.platform` for deterministic tests.
* @returns whether the path is fully qualified on the platform.
*/
export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
return platform === 'win32'
? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
: posix.isAbsolute(path)
}
/** One streamed listing candidate: the dirent facts a row needs, nothing else retained. */
export interface ListingCandidate {
/** Base name within the streamed level. */
name: string
/** Dirent says directory (no probe needed). */
isDirectory: boolean
/** Dirent says symlink (enterability needs a stat probe). */
isSymbolicLink: boolean
}
/**
* Insert a streamed candidate into the name-sorted bounded window, evicting
* the name-largest candidate when the window exceeds `keep`. Memory over an
* arbitrarily large level therefore stays O(keep) regardless of how many
* children the directory holds.
* @param window - the name-ascending window, mutated in place.
* @param candidate - the streamed candidate to place.
* @param keep - the window bound.
* @returns true when an eviction happened (the level has candidates beyond the window).
*/
export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean {
// Full window, name at or beyond the tail: one comparison rejects, so an
// oversized level costs O(1) per candidate past the head instead of a
// window scan (100k children against a 1,001 window must not approach
// 10^8 comparisons).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail
if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true
// Binary insertion keeps a retained candidate at O(log keep) comparisons.
let lo = 0
let hi = window.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid
else lo = mid + 1
}
window.splice(lo, 0, candidate)
if (window.length <= keep) return false
window.pop()
return true
}
/**
* Await `operation`, but reject with the signal's reason the moment it
* aborts. Node's filesystem reads are not retractable, so the operation
* itself keeps running against a handle the caller then closes — its late
* settlement is swallowed here so an abandoned read cannot surface as an
* unhandled rejection.
* @param operation - the in-flight filesystem step.
* @param signal - caller lifetime; absent means plain awaiting.
* @returns the operation's value.
*/
export function raceAbort<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return operation
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
operation.catch(() => {
// Abandoned read: its handle is being closed by the aborting caller,
// and the abort reason already carried the outcome.
})
reject(asError(signal.reason))
}
if (signal.aborted) {
onAbort()
return
}
signal.addEventListener('abort', onAbort, { once: true })
operation.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(reason: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(asError(reason))
},
)
})
}
/** The thrown value as an Error (wire/abort reasons may be anything). */
function asError(reason: unknown): Error {
return reason instanceof Error ? reason : new Error(String(reason))
}
/* v8 ignore start -- a close failure of an abandoned handle has no consumer, and forcing one needs a filesystem torn down mid-request. */
/** Swallow the close failure of a handle its caller already departed. */
function swallowCloseFailure(): void {}
/* v8 ignore stop */
/** Message text of an unknown thrown value. */
function messageOf(error: unknown): string {
/* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
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, signal: AbortSignal | undefined,
): Promise<DirectoryEntry | null> {
const path = join(parent, name)
let enterable = isDirectory
if (!enterable && isSymbolicLink) {
try {
// The probe races the caller too: a symlink target on a stalled
// network filesystem must not keep a departed caller's request alive.
enterable = (await raceAbort(stat(path), signal)).isDirectory()
} catch {
/* v8 ignore next 2 -- an abort landing mid-probe needs a stalled stat; the per-candidate check in list covers the settled path. */
if (signal?.aborted) throw asError(signal.reason)
// Broken or cyclic symlink: stat is the probe, failure means "not enterable".
return null
}
}
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('.') }
}
/** Validated plugin configuration. */
export interface Config {
/** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */
maxEntries: number
}
/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
export default class BrowseDirectoryPicker extends DirectoryPicker {
/**
* `maxEntries` bounds the complete listing level a single `list` call may
* materialize and put on the wire: at most this many child-directory rows
* (hidden rows included), with `truncated` flagging a cut level. The
* default follows GitHub's web UI, which truncates directory listings at
* 1,000 entries.
*/
static Config: z<Config> = z.object({
maxEntries: z.natural().min(1).default(1000),
})
private readonly browseCapability: DirectoryPickerCapability = {
kind: 'browse',
list: (path, signal) => this.list(path, signal),
createDirectory: (path, name) => this.createDirectory(path, name),
}
constructor(ctx: Context, private readonly config: Config) {
super(ctx)
}
/**
* The browse interaction capability.
* @returns the stable `browse` capability object.
*/
capability(): DirectoryPickerCapability {
return this.browseCapability
}
private async list(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const home = homedir()
// The seam contract takes fully qualified paths only; resolve() would
// silently rebase a relative or empty wire value under the host process
// cwd (or, for rooted drive-less Windows forms, its current drive).
if (path !== undefined && !fullyQualified(path)) {
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
}
const target = resolve(path ?? home)
// Stream the level (opendir, one dirent at a time) into a name-sorted
// window of maxEntries + 1 candidates: memory stays bounded no matter how
// many children the directory holds, the window keeps the name-sorted
// head, and the +1 slot lets an in-window extra row prove the cut. A
// window candidate that turns out non-enterable (broken symlink) is not
// backfilled from beyond the window — an eviction already marks the
// level truncated, which stays the honest answer.
const keep = this.config.maxEntries + 1
const window: ListingCandidate[] = []
let evicted = false
try {
// Every filesystem await races the caller's signal: a stalled
// opendir/read on a network filesystem must not keep a departed
// caller's scan alive, and an already-aborted request rejects even
// when the level is empty.
const opening = opendir(target)
const level = await raceAbort(opening, signal).catch((error: unknown) => {
// The abandoned open can still mint a handle after the abort won;
// close it so a departed caller cannot leak a descriptor. (A lost
// race against opendir's own rejection has nothing to close, and
// the close's own failure is swallowed — the request already
// returned, so a cleanup error has no consumer.)
void opening.then(dir => dir.close().catch(swallowCloseFailure), () => {
// Already rejected: raceAbort surfaced or swallowed it.
})
throw error
})
try {
for (;;) {
const dirent = await raceAbort(level.read(), signal)
if (dirent === null) break
// Only rows a browser could enter contend for the window; dirent
// says "directory" outright, a symlink needs the later stat probe.
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue
const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() }
if (boundedInsert(window, candidate, keep)) evicted = true
}
} finally {
// Manual read() never auto-closes; close on every exit. The aborted
// exit must not await it — Node queues close behind any in-flight
// read, so awaiting would chain the departed caller back onto the
// very stall the abort escaped (the abandoned read's settlement is
// already swallowed by raceAbort).
const closing = level.close()
/* v8 ignore next 3 -- an abort between open and close needs a stalled read; the abandoned-close arm has no observable outcome. */
if (signal?.aborted) {
closing.catch(swallowCloseFailure)
} else {
await closing
}
}
} catch (error: unknown) {
// An abort is the caller's own reason, not an unreadable directory.
signal?.throwIfAborted()
throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`)
}
const entries: DirectoryEntry[] = []
let truncated = evicted
for (const candidate of window) {
// A caller that departed between reads and probes stops before the
// next probe (each probe's own await is raced inside directoryRow).
signal?.throwIfAborted()
const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal)
if (row === null) continue
if (entries.length === this.config.maxEntries) {
truncated = true
break
}
entries.push(row)
}
return { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated }
}
private async createDirectory(path: string, name: string): Promise<string> {
// Same fully-qualified fence as list: never rebase a parent under the
// cwd or the current drive.
if (!fullyQualified(path)) {
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
}
const parent = resolve(path)
// The backend owns segment validation (the wire schema also refuses these,
// 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,231 @@
/** 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, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts'
import type { ListingCandidate } 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')
try {
await symlink(join(root, 'notes.txt'), join(root, 'file-link'))
} catch {
// Windows denies unprivileged file symlinks; the file-link row only
// feeds the POSIX lanes' coverage of the symlink-to-file arm, and every
// assertion below expects it to be filtered out anyway.
}
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker)
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)
// Well under the default bound: the complete level, not a cut one.
expect(listing.truncated).toBe(false)
})
it('cuts a level at maxEntries keeping the name-sorted head, and flags the cut', async () => {
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker, { maxEntries: 1 })
await fiber.await()
const bounded = ctx.get('directoryPicker')!.capability()
if (bounded.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
try {
const cut = await bounded.list(root)
expect(cut.entries.map(entry => entry.name)).toEqual(['.hidden-dir'])
expect(cut.truncated).toBe(true)
// Exactly at the bound is complete, not truncated.
const exact = await bounded.list(join(root, 'projects'))
expect(exact.entries.map(entry => entry.name)).toEqual(['harness'])
expect(exact.truncated).toBe(false)
// A level that fits the window but exceeds the bound (two rows, bound
// one): the in-window extra row proves the cut without any eviction.
await mkdir(join(root, 'projects', 'harness', 'a'))
await mkdir(join(root, 'projects', 'harness', 'b'))
const inWindow = await bounded.list(join(root, 'projects', 'harness'))
expect(inWindow.entries.map(entry => entry.name)).toEqual(['a'])
expect(inWindow.truncated).toBe(true)
} finally {
await fiber.dispose()
}
})
it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => {
const gone = new AbortController()
gone.abort(new Error('caller left'))
// The abort surfaces as-is, not dressed as an unreadable directory —
// and rejects even before any level row is read.
await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left')
// The abandoned open that still succeeds is closed, not leaked.
await new Promise(resolve => setTimeout(resolve, 10))
// Aborted against a missing target: the abandoned open rejects on its
// own and there is nothing to close.
await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left')
await new Promise(resolve => setTimeout(resolve, 10))
// A live signal leaves a normal listing untouched — the reads and the
// symlink probes race it without ever losing.
const live = new AbortController()
const complete = await capability.list(root, live.signal)
expect(complete.truncated).toBe(false)
expect(complete.entries.map(entry => entry.name)).toContain('linked')
// A live signal changes nothing about ordinary failures.
const missing = join(root, 'no-such-dir')
const failure = await capability.list(missing, live.signal).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-unreadable')
})
it('raceAbort follows the operation until the signal wins, and swallows the abandoned settlement', async () => {
// No signal / settled operations: plain passthrough, listener removed.
await expect(raceAbort(Promise.resolve('ok'), undefined)).resolves.toBe('ok')
const live = new AbortController()
await expect(raceAbort(Promise.resolve('ok'), live.signal)).resolves.toBe('ok')
// Failure passthrough keeps the operation's own error.
await expect(raceAbort(Promise.reject(new Error('raw failure')), live.signal)).rejects.toThrow('raw failure')
// The abort wins over a pending operation and carries its own reason;
// the operation's late rejection is swallowed, never unhandled.
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
let rejectLate!: (reason: unknown) => void
const pending = new Promise<never>((_resolve, reject) => { rejectLate = reject })
const controller = new AbortController()
const raced = raceAbort(pending, controller.signal)
// A bare-string abort reason exercises the Error wrap.
controller.abort('caller left')
await expect(raced).rejects.toThrow('caller left')
rejectLate(new Error('late read failure'))
await new Promise(resolve => setTimeout(resolve, 10))
expect(rejections).toEqual([])
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => {
const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false })
const window: ListingCandidate[] = []
expect(boundedInsert(window, candidate('m'), 2)).toBe(false)
expect(boundedInsert(window, candidate('z'), 2)).toBe(false)
// A smaller name lands in place and pushes the current largest out.
expect(boundedInsert(window, candidate('a'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
// A name at or beyond the full window's tail rejects on one comparison.
expect(boundedInsert(window, candidate('t'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
expect(boundedInsert(window, candidate('m'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
})
it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => {
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('classifies fully qualified paths per platform (drive-less rooted Windows forms rejected)', () => {
expect(fullyQualified('/home/x', 'linux')).toBe(true)
expect(fullyQualified('x/y', 'darwin')).toBe(false)
expect(fullyQualified('C:\\projects', 'win32')).toBe(true)
expect(fullyQualified('C:/projects', 'win32')).toBe(true)
expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true)
expect(fullyQualified('//server/share/deep', 'win32')).toBe(true)
// Rooted but drive-less: isAbsolute accepts these, yet resolve() would
// inject the process's current drive.
expect(fullyQualified('\\foo', 'win32')).toBe(false)
expect(fullyQualified('/foo', 'win32')).toBe(false)
expect(fullyQualified('C:relative', 'win32')).toBe(false)
// Incomplete UNC prefixes collapse to drive-relative roots under resolve().
expect(fullyQualified('\\\\', 'win32')).toBe(false)
expect(fullyQualified('\\\\server', 'win32')).toBe(false)
expect(fullyQualified('\\\\server\\', 'win32')).toBe(false)
})
it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => {
for (const relative of ['', 'projects', './projects', '..']) {
const listFailure = await capability.list(relative).catch((error: unknown) => error)
expect(listFailure).toBeInstanceOf(DirectoryPickerError)
expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable')
expect((listFailure as DirectoryPickerError).path).toBe(relative)
const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error)
expect(createFailure).toBeInstanceOf(DirectoryPickerError)
expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed')
expect((createFailure as DirectoryPickerError).path).toBe(relative)
}
})
it('creates one child directory and surfaces it in the next listing', async () => {
const created = await capability.createDirectory(root, 'fresh')
expect(created).toBe(join(root, 'fresh'))
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-native/README.md
README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8
README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f

View File

@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-host-directory-picker-native
English | [中文](README.zh.md)
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
## Model Experience
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,19 @@
# @deepseek-ai/dsh-host-directory-picker-native
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
**双面包**browser half`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
## 模型体验
无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
#### KV 缓存影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-native",
"description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host",
"version": "0.0.1",
"private": true,
"type": "module",
"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"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-ui-workspace": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-workspace"
],
"platform": "web"
}
}

View File

@@ -0,0 +1,65 @@
/**
* The native picking occupant (package-internal; the `./client` surface
* exposes only the Loader exports). Same-package tests exercise it directly
* through this module.
*/
import { useEffect, useRef } from 'react'
import type { ReactElement } from 'react'
// Type-only: the owner contract of the directory-flow holes.
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
/** Injected face: the wire call the flow drives (bound in apply's closure). */
export interface NativeFlowInjected {
/** Ask the local Host to open its native single-directory chooser. */
pick: () => Promise<string | null>
}
/**
* Renderless flow occupant: each rising `open` edge runs exactly one pick and
* reports exactly one outcome; the ref arms once per open so re-renders (and
* an adoption keeping `open` true while `busy`) never launch a second
* chooser. The owner withdrawing `open` re-arms the next request.
* @param props - owner conversation plus the injected pick call.
* @returns nothing — the native chooser renders on the host display.
*/
export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null {
const { open, pick } = props
const armed = useRef(false)
// Callbacks ride a ref so the settled pick reports through the owner's
// latest handlers, not the ones captured when the chooser opened.
const outcome = useRef(props)
outcome.current = props
// Unmount (HMR replacing the occupant) discards settlements wholesale: the
// dead instance must neither adopt a path nor drive the owner's error
// surface. The wire carries no per-request abort, so the host-side chooser
// survives until answered — its answer just lands nowhere; the replacement
// instance re-arms under the owner's still-open request. An injected-face
// identity change alone (re-registration) keeps the pending settlement:
// the chooser on the host display is still the same dialog.
const alive = useRef(true)
useEffect(() => {
// StrictMode's development replay runs the cleanup once before the real
// lifetime: re-arm on setup or every outcome would be discarded.
alive.current = true
return () => { alive.current = false }
}, [])
useEffect(() => {
if (!open) {
armed.current = false
return
}
if (armed.current) return
armed.current = true
pick().then(
(path) => {
if (!alive.current) return
if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path)
},
(reason: unknown) => {
if (!alive.current) return
outcome.current.onError(reason instanceof Error ? reason.message : String(reason))
},
)
}, [open, pick])
return null
}

View File

@@ -0,0 +1,41 @@
/**
* Browser half of the native directory-picker backend: fills ui-workspace's
* two directory-flow holes with a renderless occupant that answers each
* `open` by driving `host.pickDirectory` (the node half's OS chooser) and
* reporting the one outcome — picked path, cancellation, or failure — back
* through the owner conversation. Mounting this package therefore composes
* both sides of the native interaction with one cordis.yml row; no client
* code branches on a capability kind.
*/
import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { NativeFlowInjected } from './flow.ts'
import { NativeDirectoryFlow } from './flow.ts'
/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */
export const inject = ['slots', 'workspaces']
/**
* Client plugin body: register the renderless native flow into both
* directory-flow holes (declaration-aware deferral — the declaring
* ui-workspace entries may activate later, and an HMR collapse re-declares).
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
ctx.effect(() => {
// One occupant, both holes, as a unit: construction or late conflicts
// (holes declared after rival providers activated) roll the whole pair
// back and fail loud — semantics owned by deferGroupRegistration.
const group = deferGroupRegistration(
ctx.slots,
['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
NativeDirectoryFlow,
name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow),
)
return () => { group.dispose() }
}, 'directory-picker-native: flow registrations')
}

View File

@@ -0,0 +1,33 @@
/**
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `native` capability, opening one native OS chooser on the host
* display per pick (macOS `osascript`, Windows STA PowerShell
* `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable
* when the operator sits at the host's screen; remote deployments compose the
* browse backend instead.
* @module @deepseek-ai/dsh-host-directory-picker-native
*/
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` native implementation (stable capability object per service life). */
export default class NativeDirectoryPicker extends DirectoryPicker {
private readonly nativeCapability: DirectoryPickerCapability = {
kind: 'native',
/* v8 ignore next -- pure forward to pickNativeDirectory (its spec owns behavior); invoking here opens a real chooser. */
pick: signal => pickNativeDirectory(signal),
}
/**
* The native interaction capability.
* @returns the stable `native` capability object.
*/
capability(): DirectoryPickerCapability {
return this.nativeCapability
}
}

View File

@@ -0,0 +1,25 @@
/**
* Package-owned invariant companion for the native directory-picker backend.
* @module @deepseek-ai/dsh-host-directory-picker-native/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native'
/** Cordis companion plugin name. */
export const name = 'host-directory-picker-native-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: each pick is one stateless subprocess round trip; the chooser outcome is only the returned path. */
const install: InvariantInstaller = () => {}
/**
* Register the native 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,6 +1,6 @@
/** Cross-platform native single-directory picker used by the local GUI carrier. */
/** Cross-platform native single-directory chooser behind the native backend's capability. */
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = NativeCommandRunner

View File

@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { apply, inject } from '../src/client/index.ts'
import { NativeDirectoryFlow } from '../src/client/flow.ts'
afterEach(cleanup)
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
ctx.provide('workspaces', { pickDirectory } as never)
const slots = ctx.get('slots') as SlotsService
const declare = () => slots.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, () => null)
return { ctx, slots, pickDirectory, declare }
}
function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
return {
open: true, busy: false,
onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
...overrides,
}
}
describe('directory-picker-native client half', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'workspaces'])
})
it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
const before = await bench()
before.declare()
const fiber = before.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
// Registry-contribution disposal proof: the fiber going down empties the holes.
await fiber.dispose()
for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
after.declare()
await Promise.resolve()
for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
})
it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
const b = await bench()
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
// queueMicrotask throws surface as uncaughtException, not a rejection.
process.on('unhandledRejection', onUnhandled)
process.on('uncaughtException', onUnhandled)
try {
// This provider activates BEFORE any hole exists: both deferrals wait.
// (Duplicate rows of the SAME package converge silently — the deferral
// skips a hole its own component already occupies; the conflict needs
// a rival provider.)
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.declare()
// A rival occupies both holes ahead of the pending microtask flush.
b.slots.register({ name: HOLES[0] } as never, () => null)
b.slots.register({ name: HOLES[1] } as never, () => null)
await new Promise(resolve => setTimeout(resolve, 20))
// The rival keeps both holes; this provider rolled back wholesale and
// surfaced the conflict on the fail-loud channel — no partial mix.
for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
expect(rejections.map(String).join('\n')).toContain('already has a registration')
// Non-Error conflicts wrap before the loud rethrow (same channel).
const c = await bench()
await c.ctx.plugin({ inject: [...inject], apply }).await()
const original = c.slots.register.bind(c.slots)
const slotsAny = c.slots as { register: typeof original }
slotsAny.register = ((options: never, component: never) => {
if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
return original(options, component)
}) as typeof original
c.declare()
await new Promise(resolve => setTimeout(resolve, 20))
expect(rejections.map(String).join('\n')).toContain('string conflict')
} finally {
process.off('unhandledRejection', onUnhandled)
process.off('uncaughtException', onUnhandled)
}
})
it('rolls back the first deferral when the second hole is already occupied', async () => {
const b = await bench()
b.declare()
// Foreign occupant in the SECOND registered hole: the pair construction
// throws after the first deferral installed its subscription.
b.slots.register({ name: HOLES[1] } as never, () => null)
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await expect(fiber.await()).rejects.toThrow(/already has a registration/)
// A leaked first deferral would now race this probe registration and
// throw from its orphaned subscription against the HERO hole; the
// rollback leaves only the activation failure itself (cordis re-raises
// the apply throw as a late rejection — installFailLoud's contract).
const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
await new Promise(resolve => setTimeout(resolve, 20))
expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
disposeProbe()
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('rejects a second flow occupant at load (single-kind hole)', async () => {
const b = await bench()
b.declare()
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
.toThrow(/already has a registration/)
})
it('drives the injected pick through the hole entry and reports the picked path', async () => {
const b = await bench()
b.declare()
await b.ctx.plugin({ inject: [...inject], apply }).await()
const entry = b.slots.entries(HOLES[0])[0]!
const injected = (entry.inject as () => { pick: () => Promise<string | null> })()
await expect(injected.pick()).resolves.toBe('/tmp/picked')
expect(b.pickDirectory).toHaveBeenCalledOnce()
})
it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
let resolve!: (path: string | null) => void
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
const first = owner()
const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
// Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
const second = owner()
view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
// Even a fresh injected face (re-registration re-runs the inject factory)
// must not relaunch while the same request is still open.
const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
expect(replacedPick).not.toHaveBeenCalled()
await act(async () => { resolve('/tmp/project') })
expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
expect(first.onPicked).not.toHaveBeenCalled()
})
it('discards a settlement that lands after the flow unmounted', async () => {
let resolve!: (path: string | null) => void
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
const props = owner()
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
view.unmount()
// The dead instance must neither adopt nor error; the owner's callbacks
// stay untouched by the orphaned chooser's answer.
await act(async () => { resolve('/tmp/late') })
expect(props.onPicked).not.toHaveBeenCalled()
expect(props.onCancel).not.toHaveBeenCalled()
expect(props.onError).not.toHaveBeenCalled()
// The failure arm is discarded the same way.
let reject!: (reason: unknown) => void
const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
const late = owner()
const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
failingView.unmount()
await act(async () => { reject(new Error('too late')) })
expect(late.onError).not.toHaveBeenCalled()
})
it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
const pick = vi.fn(async () => null as string | null)
const props = owner()
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
await act(async () => {})
expect(props.onCancel).toHaveBeenCalledOnce()
expect(props.onPicked).not.toHaveBeenCalled()
// Withdraw and reopen: a fresh request runs a fresh pick.
view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
await act(async () => {})
expect(pick).toHaveBeenCalledTimes(2)
})
it('folds pick failures into onError messages', async () => {
const props = owner()
render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
await act(async () => {})
expect(props.onError).toHaveBeenCalledWith('no chooser installed')
const nonError = owner()
render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
await act(async () => {})
expect(nonError.onError).toHaveBeenCalledWith('denied')
})
it('renders nothing while closed and while open', () => {
const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
expect(closed.container.innerHTML).toBe('')
const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
expect(opened.container.innerHTML).toBe('')
})
})

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 native backend (the seam's cordis half). */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import NativeDirectoryPicker from '../src/index.ts'
describe('NativeDirectoryPicker', () => {
it('registers ctx.directoryPicker with a stable native capability and leaves with its fiber', async () => {
const ctx = new Context()
const fiber = ctx.plugin(NativeDirectoryPicker)
await fiber.await()
const picker = ctx.get('directoryPicker')
expect(picker).toBeInstanceOf(NativeDirectoryPicker)
const capability = picker!.capability()
expect(capability.kind).toBe('native')
// 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,33 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"types": [
"node"
]
},
"include": [
"src"
],
"references": [
{
"path": "../directory-picker"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/native-command"
},
{
"path": "../../client/ui-slots"
},
{
"path": "../../client/runtime"
},
{
"path": "../../client/ui-workspace"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'])

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: 8ef8889c875f5b1d07c015ddef819591041c8d7f
README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d

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: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together.
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
## 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: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md)`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。
浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable``directory-exists``directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
## 模型体验
无。该 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,143 @@
/**
* 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 `native` backend opens one OS chooser on the
* host's display, while a `browse` backend serves listing/creation primitives
* for an in-app browser (and thereby works for remote clients no OS dialog
* can reach). Consumers switch on `capability().kind`; the union is
* 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 native interaction: one OS directory chooser on the host display. */
export interface DirectoryPickerNativeCapability {
kind: 'native'
/**
* Open the chooser and wait for the operator.
* @param signal - caller/connection lifetime; abort terminates the chooser.
* @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[]
/**
* True when the backend cut `entries` at its complete-result bound: the
* level has more child directories than reported, and the missing rows are
* the name-sorted tail (hidden rows count toward the bound).
*/
truncated: boolean
}
/**
* 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.
* @param signal - caller lifetime; abort stops the scan (a stalled network
* directory must not outlive a disconnected caller) and rejects with the
* abort reason.
* @returns the level's listing with ancestry; backends bound the complete
* result, and a cut level reports `truncated`.
* @throws {DirectoryPickerError} `directory-unreadable` when the target is not fully
* qualified (a wire value must never resolve against the host cwd or, on
* Windows, its current drive) or cannot be listed.
*/
list(path?: string, signal?: AbortSignal): 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` for a parent that is not fully qualified or any other failure.
*/
createDirectory(path: string, name: string): Promise<string>
}
/**
* Merge-extensible registry of interaction shapes keyed by capability kind: a
* new backend declaration-merges its shape here (the entry's `kind` literal
* must equal its key) instead of editing this package.
*/
export interface DirectoryPickerCapabilities {
native: DirectoryPickerNativeCapability
browse: DirectoryPickerBrowseCapability
}
/** Union of interaction shapes a backend can provide, derived from the merge-extensible {@link DirectoryPickerCapabilities} map. */
export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities]
/** Closed failure vocabulary of the browse primitives (mirrored onto the wire by consumers). */
export type DirectoryPickerErrorCode = 'directory-unreadable' | 'directory-exists' | 'directory-create-failed'
/** 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
}
export default DirectoryPicker

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: 'native', 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('native')
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"
}
]
}

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 140df90571d84320fb4eb888508c67e60aa29a22
README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273
# pnpm run verify-translation-pairing --write packages/util/README.md
README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0
README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0

View File

@@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here
| `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) |
| `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
| `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool |
| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller |
`dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.

View File

@@ -10,6 +10,7 @@
| `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) |
| `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 |
| `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 |
| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 |
`dsh-brand` 是规范示例:它只负责 `Branded<B>` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks``TaskId``dsh-session``SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。

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/util/native-command/README.md
README.md: 7fc8b1f4640ef87ada62b6656854feb37080e4e6
README.zh.md: 4bc66c4047194de03f236fa7591dad88e5c3fb57

View File

@@ -0,0 +1,27 @@
# dsh-native-command
English | [中文](README.zh.md)
A **zero-dependency no-shell `execFile` runner** shared by host-native OS integrations: one `runNativeCommand(command, args, signal)` call spawns the executable directly (never a shell string), captures utf8 stdout/stderr, propagates the caller's abort into child termination, and hides the transient console window on Windows. Failures reject with the exit `code` and both captured streams attached, so callers classify (missing tool, cancelled, real failure) without re-running anything.
Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is the injectable command boundary those callers expose for deterministic tests.
It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events.
## Surface
```ts
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
```
## Model Experience
None, as this is host-side subprocess plumbing; nothing here reaches a model request.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No output bounding** — both streams buffer unbounded in memory; every current caller invokes small native tools whose output is a path or an error line. Adopt `dsh-retention` bounding before pointing this at commands with meaningful output volume.

View File

@@ -0,0 +1,27 @@
# dsh-native-command
[English](README.md) | 中文
宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接派生可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时以附带退出 `code` 与两路已捕获输出的错误拒绝,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。
它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。
它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。
## Surface
```ts
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
```
## Model Experience
无;这是宿主侧子进程管道,这里没有任何东西进入模型请求。
#### KV Cache effect
无;该包既不组装也不发送 provider 请求。
## Known Limitations and Deferred Work
- **不做输出限量**——两路流在内存中无界缓冲;当前每个调用方只运行输出为一个路径或一行错误的小型原生工具。把它指向输出量可观的命令之前,先接入 `dsh-retention` 限量。

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-native-command",
"description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,4 +1,10 @@
/** Shared no-shell `execFile` runner for native host dialogs and openers. */
/**
* Shared no-shell `execFile` runner for host-native OS integrations (the
* native directory chooser, the open-with-default-application hand-off):
* utf8 stdio capture, abort propagation, Windows console hide. A library,
* not a plugin no ctx, no state, no events.
* @module @deepseek-ai/dsh-native-command
*/
import { execFile } from 'node:child_process'

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-native-command`.
* @module @deepseek-ai/dsh-native-command/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-native-command'
/** Cordis companion plugin name. */
export const name = 'native-command-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: each run is one stateless child-process round trip
* with no owned event stream or mutable runtime data; behavior is enforced by
* unit tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { runNativeCommand } from '@deepseek-ai/dsh-native-command'
const node = process.execPath
describe('runNativeCommand', () => {
it('captures utf8 stdout and stderr on exit 0', async () => {
const result = await runNativeCommand(
node,
['-e', 'process.stdout.write("out✓"); process.stderr.write("err")'],
new AbortController().signal,
)
expect(result).toEqual({ stdout: 'out✓', stderr: 'err' })
})
it('rejects a non-zero exit with code, stdout, and stderr attached', async () => {
const failure = await runNativeCommand(
node,
['-e', 'process.stdout.write("partial"); process.stderr.write("boom"); process.exit(3)'],
new AbortController().signal,
).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
expect(failure).toMatchObject({ code: 3, stdout: 'partial', stderr: 'boom' })
expect((failure as Error).cause).toBeInstanceOf(Error)
})
it('rejects a missing executable with the spawn ENOENT code', async () => {
const failure = await runNativeCommand(
'dsh-definitely-missing-command',
[],
new AbortController().signal,
).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
expect(failure).toMatchObject({ code: 'ENOENT' })
})
it('terminates the child when the signal aborts', async () => {
const abort = new AbortController()
const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal)
abort.abort()
const failure = await pending.then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
expect(failure).toBeInstanceOf(Error)
expect((failure as { code?: unknown }).code).toBe('ABORT_ERR')
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../support/invariants"
}
]
}