Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/config-catalog.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/src/index.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/chat/ChatView.tsx
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-trajectory/tests/views.spec.tsx
#	packages/compact/compact-basic/README.i18n.yaml
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/index.ts
#	packages/host/apiproxy/src/api/sessions.ts
#	packages/host/apiproxy/src/index.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
#	packages/llm/llm-deepseek/src/adapter.ts
#	packages/llm/llm-deepseek/tests/adapter.spec.ts
#	packages/llm/llm-deepseek/tests/serialize.spec.ts
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/index.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm/src/types.ts
#	packages/ui/tui/README.i18n.yaml
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/tui.spec.ts
This commit is contained in:
Yichen Jiang
2026-07-28 11:41:40 +08:00
1499 changed files with 48621 additions and 21956 deletions

View File

@@ -7,11 +7,15 @@ import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import type { Context } from 'cordis'
import type { Agent, AgentMessage, AgentMessageId, AgentStatus } from '@deepseek-ai/dsh-agent'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus,
} from '@deepseek-ai/dsh-agent'
import { AttachmentError } from '@deepseek-ai/dsh-attachment-local'
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment-local'
import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -22,7 +26,8 @@ import {
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, HistoryEntry, HostFrame, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionSummary, ToolEventView,
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
MuxFrame, PromptContentPart, QuestionResponsePayload, SessionSummary, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
@@ -35,6 +40,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'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -54,6 +60,9 @@ function decodeBase64(data: string): Uint8Array {
}
async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
if (content.every(part => part.type === 'text')) {
return content.map(part => ({ type: 'text', text: part.text }))
}
const limits = ctx.attachments.imageLimits
const prepared = content.map(part => part.type === 'text'
? part
@@ -271,6 +280,8 @@ export interface ApiProxyDefaults {
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** Native single-directory picker; injectable for carrier tests. */
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
}
/** The tool/call payload fields the presenter path reads. */
@@ -360,6 +371,15 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name:
return undefined
}
/** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */
function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i]
if (event !== undefined && event.type === 'todo/write') return event.data.todos
}
return undefined
}
/**
* Thrown by the cold-resume path when the id names no servable session
* (absent from the store, or a pre-project legacy log without a cwd).
@@ -425,6 +445,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
*/
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
const agentOptions = { provider: defaults.provider, model: defaults.model }
type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
const targets = new WeakMap<Agent, WebLlmTargetRef>()
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
const resumes = new Map<SessionId, Promise<Agent>>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
@@ -434,6 +456,40 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/**
* Install or return the session-local target that prompt assembly snapshots.
* Seed order: latest logged request/header, else the host default routing.
* There is no create-time per-session override tier on this wire — if one
* returns (a create-options contribution), it must fold in between the two.
*/
function targetFor(agent: Agent): WebLlmTargetRef {
const installed = targets.get(agent)
if (installed !== undefined) return installed
const logged = agent.session.requestHeader()?.config
const target: WebLlmTargetRef = {
current: logged === undefined
? { provider: defaults.provider, model: defaults.model }
: {
provider: logged.provider,
model: logged.model,
...logged.reasoningEffort === undefined
? {}
: { reasoningEffort: logged.reasoningEffort },
},
assembled: undefined,
}
installAgentLlmTarget(agent.ctx, target)
targets.set(agent, target)
return target
}
/** Pre-publication setup used by both fresh and resumed Web agents. */
function installTarget(agentCtx: Context): void {
const agent = agentCtx.agent
if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
targetFor(agent)
}
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
const envelope = frame(payload)
@@ -447,7 +503,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
* `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
* the mirror needs no consumption heuristics or sweeps beyond disposal.
*/
const queuedMirror = new Map<SessionId, Map<AgentMessageId, AgentMessage>>()
const queuedMirror = new Map<SessionId, Map<AgentMessageId, { message: AgentMessage; steering: boolean }>>()
ctx.effect(() => {
const retire = (agent: Agent, id: AgentMessageId): void => {
const entries = queuedMirror.get(agent.id)
@@ -456,11 +512,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (entries.size === 0) queuedMirror.delete(agent.id)
}
const disposers = [
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage) => {
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage, placement) => {
let entries = queuedMirror.get(agent.id)
if (entries === undefined) queuedMirror.set(agent.id, entries = new Map<AgentMessageId, AgentMessage>())
entries.set(message.id, message)
broadcast({ type: 'session/queued', sessionId: agent.id, content: message.content, source: message.source, steering: message.steering })
if (entries === undefined) {
entries = new Map<AgentMessageId, { message: AgentMessage; steering: boolean }>()
queuedMirror.set(agent.id, entries)
}
const steering = placement === 'steering'
entries.set(message.id, { message, steering })
broadcast({
type: 'session/queued',
sessionId: agent.id,
content: message.content,
source: message.source,
steering,
})
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
retire(agent, message.id)
@@ -547,7 +613,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
resume = (async () => {
try {
await assertServable(sessionId)
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
const handle = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
setup: installTarget,
})
return handle.agent
} finally {
resumes.delete(sessionId)
@@ -582,7 +652,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (stored.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
}
return (await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })).agent
return (await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions,
setup: installTarget,
})).agent
}
try {
@@ -590,7 +664,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
return (await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })).agent
return (await ctx.agents.create({
sessionId,
agentOptions,
meta: { cwd },
setup: installTarget,
})).agent
})().catch((error: unknown) => {
// Another Host entry path may have published the same identity while
// this operation crossed an asynchronous persistence/filesystem step.
@@ -719,7 +798,112 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
return ok(request, { events: entries, hasMore: page.hasMore })
// Tail page carries the session-level todo projection over the FULL
// log (the page window may not contain the last todo/write; a paged
// client cannot reconstruct session-level state from it).
const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } })
},
async models(request) {
const { sessionId } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const current = targetFor(found.agent).current
const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
try {
const advertised = await ctx.llm.listModels(provider.id)
const models = [...advertised]
if (
provider.id === current.provider
&& !models.some(model => model.id === current.model)
) {
models.push({
provider: provider.id,
id: current.model,
name: current.model,
})
}
const entries = await Promise.all(models.map(async (model) => {
const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
? undefined
: {
efforts: resolved.reasoning.efforts.map(effort => ({
id: effort.id,
name: effort.name,
...effort.description === undefined
? {}
: { description: effort.description },
})),
...resolved.reasoning.defaultEffort === undefined
? {}
: { defaultEffort: resolved.reasoning.defaultEffort },
}
return {
id: model.id,
name: model.name,
...model.description === undefined ? {} : { description: model.description },
...provider.id === current.provider
&& model.id === current.model
&& !advertised.some(candidate => candidate.id === current.model)
? { unlisted: true as const }
: {},
...reasoning === undefined ? {} : { reasoning },
}
}))
const group: ModelProviderGroup = {
id: provider.id,
name: provider.name,
models: entries,
}
return { kind: 'group' as const, group }
} catch (error: unknown) {
const failure: ModelCatalogFailure = {
id: provider.id,
name: provider.name,
message: error instanceof Error ? error.message : String(error),
}
return { kind: 'failure' as const, failure }
}
}))
const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
return ok(request, {
current: { ...current },
groups: groups.filter(group => group.models.length > 0),
failures,
})
},
async selectModel(request) {
const { sessionId, provider, model, reasoningEffort } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
try {
const resolved = await ctx.llm.resolveCallConfig({
provider,
model,
...reasoningEffort === undefined
? {}
: { reasoningEffort: ReasoningEffortId(reasoningEffort) },
})
const selected: AgentLlmTarget = {
provider: resolved.provider,
model: resolved.model,
...resolved.reasoningEffort === undefined
? {}
: { reasoningEffort: resolved.reasoningEffort },
}
targetFor(found.agent).current = selected
return ok(request, { selected: { ...selected } })
} catch (error: unknown) {
return err(request, {
code: 'model-unavailable',
message: error instanceof Error ? error.message : String(error),
details: { provider, model },
})
}
},
async prompt(request) {
@@ -731,11 +915,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (content.some(part => part.type === 'image')) {
const routed = agent.session.requestHeader()?.config
const provider = routed?.provider ?? agent.options.provider ?? defaults.provider
const model = routed?.model ?? agent.options.model ?? defaults.model
const activeModel = (await ctx.llm.listModels(provider)).find(candidate => candidate.id === model)
if (activeModel?.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) {
const target = targetFor(agent).current
const provider = target.provider
const model = target.model
const activeModel = await ctx.llm.resolveModelInfo(provider, model)
if (activeModel.inputModalities !== undefined && !activeModel.inputModalities.includes('image')) {
return err(request, {
code: 'attachment-error',
message: `Model "${model}" does not support image input.`,
@@ -744,8 +928,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
const durable = await durablePromptContent(ctx, content)
if (mode === 'steer') agent.steer(durable, { source })
else agent.followup(durable, { source })
if (mode === 'steer') agent.steer({ content: durable, source })
else agent.followup({ content: durable, source })
} catch (error: unknown) {
if (error instanceof AttachmentError) {
return err(request, {
@@ -804,7 +988,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: { sessionId },
}))
}
agent.cancel()
agent.cancel({ kind: 'user' })
return Promise.resolve(ok(request, { accepted: true as const }))
},
},
@@ -896,6 +1080,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return ok(request, { workspace: workspaceView(workspace) })
},
async delete(request) {
const { workspaceId } = request.payload
const operation = workspaceCreationChain.then(() =>
ctx.workspace.delete(brandWorkspaceId(workspaceId)))
workspaceCreationChain = operation.then(() => undefined, () => undefined)
if (!await operation) return workspaceNotFound(request, workspaceId)
return ok(request, { deleted: true as const })
},
async insertSessionBefore(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
@@ -940,6 +1133,26 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
attachedSessions: ctx.agents.list().length,
})
},
async pickDirectory(request, signal) {
try {
const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal)
return ok(request, { path })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'directory picker was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
},
},
commands: {
@@ -1045,7 +1258,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// queue view from these alone.
for (const [sessionId, entries] of queuedMirror) {
for (const entry of entries.values()) {
queue.push(frame({ type: 'session/queued', sessionId, content: entry.content, source: entry.source, steering: entry.steering }))
queue.push(frame({
type: 'session/queued',
sessionId,
content: entry.message.content,
source: entry.message.source,
steering: entry.steering,
}))
}
}
// Per-session open-call table for result-view pairing. Bounded by the
@@ -1109,15 +1328,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
}),
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
if (status === 'disposed') return
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
}),
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => {
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
}),
ctx.on('domain/changed', (change) => {
if (change.domain !== 'workspace' || change.operation !== 'put') return
if (change.domain !== 'workspace') return
if (change.table === '') {
if (change.operation !== 'put') return
const state = workspaceDomainState.parse(change.value)
for (const workspaceId of state.workspaceIds) {
if (committedWorkspaceIds.has(workspaceId)) continue
@@ -1130,7 +1349,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
return
}
if (change.table !== 'workspaces' || !committedWorkspaceIds.has(change.key)) return
if (change.table !== 'workspaces') return
if (change.operation === 'deleted') {
if (!committedWorkspaceIds.delete(change.key)) return
queue.push(frame({
type: 'host/workspace-removed',
workspaceId: change.key as WorkspaceId,
}))
return
}
if (!committedWorkspaceIds.has(change.key)) return
// Existing-entity table writes are complete attach/touch commits.
// A new entity's first put waits for the global registry write above.
queue.push(frame({

View File

@@ -11,7 +11,7 @@ import type { Wire } from './rpc.schema.ts'
import { rpcErrorSchema, rpcIdSchema } from './rpc.schema.ts'
import { approvalRequestIdSchema } from './approvals.schema.ts'
import { contentBlockSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema } from './sessions.schema.ts'
import { workspaceViewSchema } from './workspace.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
export const askUserQuestionItemSchema = z.object({
@@ -47,6 +47,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }),
z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }),
z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }),
z.object({ type: z.literal('host/commands-changed') }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<HostFrame>

View File

@@ -63,14 +63,16 @@ export type MuxFrame =
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
| { type: 'question/resolved'; sessionId: SessionId; questionRpcId: RpcId; outcome: 'answered' | 'cancelled' }
/**
* A message entered the addressed agent's inbox (`agent/queued` passthrough:
* a queued message is not model-visible, so there is no session event to
* ride — this transient frame is the only wire signal). On stream open the
* A message entered the addressed agent's inbox. A queued message is not
* model-visible, so there is no session event to carry it; this transient
* frame is the only wire signal. On stream open the
* host replays the current queue snapshot for every attached session (same
* refresh-recovery baseline as pending questions); queue clearing on cancel
* has no dedicated frame — clients fold it from the status flip.
* source carries the prompt's rpcId when the message came over this wire
* (the client's provisional-echo reconciliation key).
* `steering` is the host's acceptance-time queue classification and remains
* authoritative in reconnect snapshots. `source` carries the prompt's rpcId
* when the message came over this wire (the client's provisional-echo
* reconciliation key).
*/
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
| { type: 'stream/error'; error: RpcError }
@@ -85,7 +87,9 @@ export type MuxFrame =
* agent-error is the only outlet for live failures with no turn position;
* workspace-changed pushes the full new snapshot after every durable
* workspace mutation (create/attach/order change — the client upserts, while
* `workspace.list` provides the reconnect baseline).
* `workspace.list` provides the reconnect baseline); workspace-removed is the
* committed registration-deletion increment and never implies directory or
* session-log deletion.
*/
export type HostFrame =
| { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string }
@@ -93,6 +97,7 @@ export type HostFrame =
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
| { type: 'host/agent-error'; sessionId: SessionId; message: string }
| { type: 'host/workspace-changed'; workspace: WorkspaceView }
| { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] }
/**
* The command registry changed (`commands/change` passthrough). Pure
* invalidation signal, no payload: clients refetch `command.list` in the

View File

@@ -37,3 +37,11 @@ export const hostDescribeValueSchema = z.object({
}).optional(),
attachedSessions: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */
export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.pickDirectory'>>>
/** host.pickDirectory response value; null means the user cancelled. */
export const hostPickDirectoryValueSchema = z.object({
path: z.string().nullable(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>

View File

@@ -28,4 +28,10 @@ export interface HostApi {
imageLimits?: ImageAttachmentLimits
attachedSessions: number
}>>
/** Open the operating system's single-directory picker; cancellation returns null. */
pickDirectory(
request: RpcRequest<{}>,
signal: AbortSignal,
): Promise<RpcResponse<{ path: string | null }>>
}

View File

@@ -25,7 +25,10 @@ export interface ApiProxy {
}
// ---- Domain interfaces and payload entities ----
export type { HistoryEntry, PromptContentPart, SessionsApi, SessionSummary } from './sessions.ts'
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, PromptContentPart, SessionModels, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.ts'

View File

@@ -20,13 +20,17 @@ export interface RpcMethodMap {
'session.list': SessionsApi['list']
'session.create': SessionsApi['create']
'session.history': SessionsApi['history']
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.prompt': SessionsApi['prompt']
'session.attachment': SessionsApi['attachment']
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
'workspace.delete': WorkspaceApi['delete']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
'command.list': CommandsApi['list']
'command.execute': CommandsApi['execute']

View File

@@ -35,6 +35,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),

View File

@@ -32,6 +32,7 @@ export interface RpcErrorDetailsMap {
'bad-request': { issues: ZodIssue[] }
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'model-unavailable': { provider: string; model: string }
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
'workspace-not-found': { workspaceId: string }

View File

@@ -9,7 +9,10 @@ import { z } from 'zod'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { HistoryEntry, SessionSummary } from './sessions.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
import type { WorkspaceId } from './workspace.ts'
@@ -77,6 +80,49 @@ export const sessionHistoryRequestSchema = z.object({
maxMessages: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.history'>>>
/** Complete provider/model target. */
export const modelTargetSchema = z.object({
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<ModelTarget>>
/** One adapter-owned reasoning effort. */
export const modelReasoningEffortSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
}) satisfies z.ZodType<Wire<ModelReasoningEffort>>
/** Exact-model reasoning metadata. */
export const modelReasoningSchema = z.object({
efforts: z.array(modelReasoningEffortSchema).min(1),
defaultEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<ModelReasoning>>
/** One advisory model entry inside a provider group. */
export const modelCatalogModelSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
unlisted: z.literal(true).optional(),
reasoning: modelReasoningSchema.optional(),
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
/** One successfully loaded provider group. */
export const modelProviderGroupSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
models: z.array(modelCatalogModelSchema),
}) satisfies z.ZodType<Wire<ModelProviderGroup>>
/** One provider-local catalog failure. */
export const modelCatalogFailureSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
message: z.string(),
}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
/**
* ToolEventView passthrough: lock only the `for` discriminant and the presence
* of a card-tagged `view` object. The view interior is a host-computed product
@@ -94,12 +140,44 @@ export const historyEntrySchema = z.object({
view: toolEventViewSchema.optional(),
}) satisfies z.ZodType<Wire<HistoryEntry>>
/** One todo item of the tail page's session-level projection (the todo/write payload shape). */
export const todoItemSchema = z.object({
content: z.string(),
status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]),
})
/** session.history response value. */
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
todos: z.array(todoItemSchema).optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** session.models request payload. */
export const sessionModelsRequestSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'session.models'>>>
/** session.models response value. */
export const sessionModelsValueSchema = z.object({
current: modelTargetSchema,
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'session.models'>>>
/** session.selectModel request payload. */
export const sessionSelectModelRequestSchema = z.object({
sessionId: sessionIdSchema,
provider: z.string().min(1),
model: z.string().min(1),
reasoningEffort: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.selectModel'>>>
/** session.selectModel response value. */
export const sessionSelectModelValueSchema = z.object({
selected: modelTargetSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.selectModel'>>>
/** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */
export const contentBlockSchema = z.looseObject({ type: z.string() })

View File

@@ -5,7 +5,7 @@
*/
import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -32,6 +32,78 @@ export interface HistoryEntry {
view?: ToolEventView
}
/** Complete model target selected for one session. */
export interface ModelTarget {
/** Registered provider route. */
provider: string
/** Provider-owned model id. */
model: string
/** Adapter-owned reasoning effort; absence preserves adapter/provider default behavior. */
reasoningEffort?: string
}
/** One adapter-owned reasoning effort displayed for an exact model route. */
export interface ModelReasoningEffort {
/** Opaque value submitted back to the owning adapter. */
id: string
/** Adapter-supplied display name. */
name: string
/** Optional adapter-supplied description. */
description?: string
}
/** Selectable reasoning metadata for one exact model route. */
export interface ModelReasoning {
/** Efforts in adapter-preferred display order. */
efforts: ModelReasoningEffort[]
/** Adapter-configured default; absence preserves the provider default. */
defaultEffort?: string
}
/** One model displayed inside its provider group. */
export interface ModelCatalogModel {
/** Provider-owned model id. */
id: string
/** Provider-supplied display name. */
name: string
/** Optional provider-supplied description. */
description?: string
/** The current model was inserted because the advisory catalog omitted it. */
unlisted?: true
/** Exact-route reasoning metadata when the adapter exposes it. */
reasoning?: ModelReasoning
}
/** One provider and the models it advertised successfully. */
export interface ModelProviderGroup {
/** Provider route id used for requests. */
id: string
/** Provider display name. */
name: string
/** Models in provider-preferred order. */
models: ModelCatalogModel[]
}
/** A provider whose asynchronous catalog lookup failed. */
export interface ModelCatalogFailure {
/** Provider route id. */
id: string
/** Provider display name. */
name: string
/** Lookup failure diagnostic. */
message: string
}
/** Detached model-directory snapshot for one session. */
export interface SessionModels {
/** Target selected for the session's next assembled step. */
current: ModelTarget
/** Successfully loaded provider groups. */
groups: ModelProviderGroup[]
/** Provider-local failures; successful groups remain usable. */
failures: ModelCatalogFailure[]
}
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
@@ -82,9 +154,29 @@ export interface SessionsApi {
* Each entry pairs the raw SessionEvent with the host-computed view (tool events whose
* presenter produced one, evaluated against the registry at pagination time); the client
* rebuilds the surface from the events with the shared fold.
* The tail page (beforeSeq absent) also carries `todos` — the session's current todo
* projection (latest `todo/write` over the FULL log, independent of the page window) —
* so a paged client restores the plan without walking history; absent when the session
* never wrote one. Older pages omit it (the projection is session-level, not per-page).
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean }>>
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; todos?: TodoItem[] }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>
/**
* Selects the complete target for this session. Exact model metadata
* validates an optional reasoning effort, while catalog membership remains
* advisory.
*/
selectModel(request: RpcRequest<{
sessionId: SessionId
provider: string
model: string
reasoningEffort?: string
}>):
Promise<RpcResponse<{ selected: ModelTarget }>>
/** Sends text plus temporary base64 image uploads; the host persists images before calling the agent. */
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>):

View File

@@ -59,6 +59,16 @@ export const workspaceRenameValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
/** workspace.delete request payload. */
export const workspaceDeleteRequestSchema = z.object({
workspaceId: workspaceIdSchema,
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.delete'>>>
/** workspace.delete response value. */
export const workspaceDeleteValueSchema = z.object({
deleted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.delete'>>>
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
export const workspaceInsertSessionBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,

View File

@@ -65,6 +65,14 @@ export interface WorkspaceApi {
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView }>>
/**
* Removes one Workspace registration. The directory, every user file, and
* every session log remain untouched; those Sessions consequently become
* ungrouped. An unknown id fails with `workspace-not-found`.
*/
delete(request: RpcRequest<{ workspaceId: WorkspaceId }>):
Promise<RpcResponse<{ deleted: true }>>
/**
* Moves an accounted session within its workspace's manual order,
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted

View File

@@ -13,17 +13,20 @@ import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
import { hostDescribeValueSchema } from '../api/host.schema.ts'
import { hostDescribeValueSchema, hostPickDirectoryValueSchema } from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
sessionAttachmentValueSchema,
sessionCreateValueSchema,
sessionHistoryValueSchema,
sessionListValueSchema,
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionSelectModelValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
workspaceDeleteValueSchema,
workspaceInsertSessionBeforeValueSchema,
workspaceListValueSchema,
workspaceRenameValueSchema,
@@ -51,17 +54,21 @@ export interface IApiClient {
list(payload: RequestPayload<'session.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.list'>>>
create(payload: RequestPayload<'session.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.create'>>>
history(payload: RequestPayload<'session.history'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.history'>>>
models(payload: RequestPayload<'session.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.models'>>>
selectModel(payload: RequestPayload<'session.selectModel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.selectModel'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.attachment'>>>
cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.cancel'>>>
}
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'>>>
}
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.delete'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
}
commands: {
@@ -87,13 +94,17 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.list': sessionListValueSchema,
'session.create': sessionCreateValueSchema,
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.attachment': sessionAttachmentValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
'workspace.delete': workspaceDeleteValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
'command.list': commandListValueSchema,
'command.execute': commandExecuteValueSchema,
@@ -177,13 +188,22 @@ export abstract class AbstractApiClient implements IApiClient {
* Shared POST leg of both C→S carriers (callUnary/respond): JSON body,
* timeout merged with the caller's optional external signal, non-2xx → transport throw.
*/
private async postJson(path: string, body: ClientRequest | ClientResponse, signal: AbortSignal | undefined): Promise<Response> {
const timeout = AbortSignal.timeout(this.timeoutMs)
private async postJson(
path: string,
body: ClientRequest | ClientResponse,
signal: AbortSignal | undefined,
useDefaultTimeout = true,
): Promise<Response> {
const requestSignal = useDefaultTimeout
? signal === undefined
? AbortSignal.timeout(this.timeoutMs)
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
: signal
const response = await this.doFetch(new URL(path, this.resolveBase()), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
signal: signal === undefined ? timeout : AbortSignal.any([timeout, signal]),
...requestSignal === undefined ? {} : { signal: requestSignal },
})
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
return response
@@ -198,10 +218,11 @@ export abstract class AbstractApiClient implements IApiClient {
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
useDefaultTimeout = true,
): Promise<RpcResponse<ResponseValue<K>>> {
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
this.onEnvelope(message)
const response = await this.postJson(`/api/${method}`, message, signal)
const response = await this.postJson(`/api/${method}`, message, signal, useDefaultTimeout)
const full = serverResponseSchema.parse(await response.json())
this.onEnvelope(full)
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
@@ -276,6 +297,8 @@ export abstract class AbstractApiClient implements IApiClient {
list: (payload, signal) => this.callUnary('session.list', payload, signal),
create: (payload, signal) => this.callUnary('session.create', payload, signal),
history: (payload, signal) => this.callUnary('session.history', payload, signal),
models: (payload, signal) => this.callUnary('session.models', payload, signal),
selectModel: (payload, signal) => this.callUnary('session.selectModel', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
@@ -283,12 +306,16 @@ export abstract class AbstractApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
// 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),
}
readonly workspace: IApiClient['workspace'] = {
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
}

View File

@@ -20,11 +20,14 @@ import {
sessionCreateRequestSchema,
sessionHistoryRequestSchema,
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import { hostDescribeRequestSchema, hostPickDirectoryRequestSchema } from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceDeleteRequestSchema,
workspaceInsertSessionBeforeRequestSchema,
workspaceListRequestSchema,
workspaceRenameRequestSchema,
@@ -52,13 +55,17 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.list': { schema: sessionListRequestSchema, invoke: (api, r) => api.sessions.list(r) },
'session.create': { schema: sessionCreateRequestSchema, invoke: (api, r) => api.sessions.create(r) },
'session.history': { schema: sessionHistoryRequestSchema, invoke: (api, r) => api.sessions.history(r) },
'session.models': { schema: sessionModelsRequestSchema, invoke: (api, r) => api.sessions.models(r) },
'session.selectModel': { schema: sessionSelectModelRequestSchema, invoke: (api, r) => api.sessions.selectModel(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) },
'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) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },

View File

@@ -0,0 +1,136 @@
/** Cross-platform native single-directory picker used by the local GUI carrier. */
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/** Injectable platform facts for deterministic adapter tests. */
export interface DirectoryPickerInternals {
platform?: NodeJS.Platform
run?: DirectoryPickerRunner
}
const runCommand: DirectoryPickerRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})
function outputPath(stdout: string): string | null {
const path = stdout.replace(/[\r\n]+$/, '')
return path === '' ? null : path
}
function errorCode(error: unknown): string | number | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) return undefined
const code = (error as { code?: unknown }).code
return typeof code === 'string' || typeof code === 'number' ? code : undefined
}
function errorStderr(error: unknown): string {
if (typeof error !== 'object' || error === null || !('stderr' in error)) return ''
const stderr = (error as { stderr?: unknown }).stderr
return typeof stderr === 'string' ? stderr : ''
}
function isMissingCommand(error: unknown): boolean {
return errorCode(error) === 'ENOENT'
}
function rethrowIfAborted(signal: AbortSignal, error: unknown): void {
if (signal.aborted) throw error
}
/**
* Open the platform directory picker.
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
* @returns the selected path, or null when the user cancels.
*/
export async function pickNativeDirectory(
signal: AbortSignal,
internals: DirectoryPickerInternals = {},
): Promise<string | null> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runCommand
if (platform === 'darwin') {
try {
const result = await run('osascript', [
'-e', 'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
'-e', 'POSIX path of selectedFolder',
], signal)
return outputPath(result.stdout)
} catch (error: unknown) {
if (!signal.aborted && errorCode(error) === 1
&& /(?:User canceled|-128)/i.test(errorStderr(error))) return null
throw error
}
}
if (platform === 'win32') {
const script = [
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName System.Windows.Forms',
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
"$dialog.Description = 'Select Workspace Directory'",
'$dialog.ShowNewFolderButton = $true',
'$result = $dialog.ShowDialog()',
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
' [Console]::WriteLine($dialog.SelectedPath)',
'}',
].join('; ')
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
return outputPath(result.stdout)
}
if (platform === 'linux') {
try {
const result = await run('zenity', [
'--file-selection', '--directory', '--title=Select Workspace Directory',
], signal)
return outputPath(result.stdout)
} catch (error: unknown) {
rethrowIfAborted(signal, error)
if (errorCode(error) === 1) return null
if (!isMissingCommand(error)) throw error
}
try {
const result = await run('kdialog', [
'--getexistingdirectory', '.', '--title', 'Select Workspace Directory',
], signal)
return outputPath(result.stdout)
} catch (error: unknown) {
rethrowIfAborted(signal, error)
if (errorCode(error) === 1) return null
if (isMissingCommand(error)) {
throw new Error('no supported native directory picker found (install zenity or kdialog)')
}
throw error
}
}
throw new Error(`native directory picker is unsupported on ${platform}`)
}