Merge branch 'worktree-llm-dynamic-config' into worktree-llm-web-config

# Conflicts:
#	apps/cli/cordis.yml
#	apps/web/tests/snapshots/code-mode-round/session.jsonl
#	apps/web/tests/snapshots/cordis-tool-round/session.jsonl
#	apps/web/tests/snapshots/fresh-round-trip/session.jsonl
#	apps/web/tests/snapshots/lifecycle-chrome/session.jsonl
#	apps/web/tests/snapshots/live-interactions/session.jsonl
#	apps/web/tests/snapshots/navigation-panes/seed.jsonl
#	apps/web/tests/snapshots/question-composer/session.jsonl
#	apps/web/tests/snapshots/seeded-history/seed.jsonl
#	apps/web/tests/snapshots/steering/session.jsonl
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/settings.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/src/index.ts
#	packages/client/connection/tests/node-half.spec.ts
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/README.md
#	packages/client/runtime/README.zh.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-models/README.i18n.yaml
#	packages/examples/tui-demo/README.i18n.yaml
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/rpc.schema.ts
#	packages/host/apiproxy/src/api/rpc.ts
#	packages/llm/llm-deepseek/README.i18n.yaml
#	packages/llm/llm-deepseek/README.zh.md
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/sdk/sdk-client/README.i18n.yaml
#	packages/settings/settings/README.i18n.yaml
#	packages/settings/settings/README.md
#	packages/settings/settings/README.zh.md
#	packages/subagent/subagent-dsh-sdk/README.i18n.yaml
#	packages/subagent/subagent-dsh-sdk/README.zh.md
#	packages/support/llm-replay/README.i18n.yaml
#	packages/ui/jsonrpc/README.i18n.yaml
#	packages/ui/jsonrpc/README.zh.md
#	packages/ui/tui/tests/snapshots/model-selector.expected.txt
#	packages/ui/tui/tests/snapshots/model-switching.expected.txt
#	packages/ui/tui/tests/snapshots/resume-sessions.expected.txt
#	packages/ui/tui/tests/snapshots/status-diagnostics-narrow.expected.txt
#	packages/ui/tui/tests/snapshots/status-diagnostics.expected.txt
#	packages/ui/tui/tests/tui.snapshot.ts
#	pnpm-lock.yaml
#	python/sdk/README.i18n.yaml
#	scripts/snapshots/translation-prompt-v4/request-response.expected.json
This commit is contained in:
Yichen Jiang
2026-07-30 15:18:26 +08:00
1346 changed files with 58389 additions and 9936 deletions

View File

@@ -9,11 +9,11 @@ import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement,
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, InboxItemId,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
@@ -44,6 +44,14 @@ import type {} from '@deepseek-ai/dsh-skill'
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
// Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
// Side-effect type import: resolves the `approval/request` waterfall and
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
import type {} from '@deepseek-ai/dsh-user-approval'
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
import { RpcId } from './api/rpc.ts'
@@ -51,7 +59,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. */
@@ -210,9 +218,9 @@ class FrameQueue<F> {
}
/**
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
* for answerable frames belong to the approval/question registry, absent in
* this minimal version).
* Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
* frames — approval/question requested — mint their stable id in their
* pending registries instead).
*/
function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
@@ -277,6 +285,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
@@ -285,14 +301,42 @@ 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>
}
/** The tool/call payload fields the presenter path reads. */
interface ToolCallData { callId: string; name: string; arguments: string }
/**
* One outstanding approval question: the stable server-request id, the frame
* material replayed to late mux subscribers, and the resolver that settles the
* answerer's promise back into `ctx.approval`.
*/
interface PendingApproval {
rpcId: RpcId
sessionId: SessionId
approvalId: ApprovalRequestId
toolName: string
callId?: CallId
reason?: string
resolve(outcome: ApprovalOutcome): void
}
/** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
return {
rpcId: pending.rpcId,
payload: {
type: 'approval/requested',
sessionId: pending.sessionId,
approvalId: pending.approvalId,
toolName: pending.toolName,
...pending.callId === undefined ? {} : { callId: pending.callId },
...pending.reason === undefined ? {} : { reason: pending.reason },
},
}
}
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
@@ -492,6 +536,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
/** Serializes path ownership checks with record creation across spellings. */
let workspaceCreationChain = Promise.resolve()
const pendingQuestions = new Map<RpcId, PendingQuestion>()
const pendingApprovals = new Map<RpcId, PendingApproval>()
const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
/**
@@ -545,46 +590,106 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
/**
* Per-session inbox occurrence mirror serving the mux-open queue snapshot
* Per-session queued-occurrence mirror serving the mux-open queue snapshot
* (the same refresh-recovery baseline as pending questions). Each terminal
* inbox event retires one matching occurrence, so repeated sends of the same
* queue event retires one matching occurrence, so repeated sends of the same
* identified message remain visible until every occurrence is claimed.
*/
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean }[]>()
const queuedMirror = new Map<SessionId, InboxItem[]>()
type UnseenQueueEvent =
| { readonly kind: 'update'; readonly item: InboxItem }
| { readonly kind: 'terminal' }
const unseenQueueEvents = new Map<SessionId, Map<InboxItemId, UnseenQueueEvent>>()
const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => {
let events = unseenQueueEvents.get(sessionId)
if (events === undefined) {
events = new Map()
unseenQueueEvents.set(sessionId, events)
}
events.set(itemId, event)
// Only synchronous re-entrancy may deliver a mutation before its outer
// enqueue observer. Drop unmatched protocol-invalid observations instead
// of retaining process-local ids indefinitely.
queueMicrotask(() => {
const current = unseenQueueEvents.get(sessionId)
if (current?.get(itemId) !== event) return
current.delete(itemId)
if (current.size === 0) unseenQueueEvents.delete(sessionId)
})
}
const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => {
const events = unseenQueueEvents.get(sessionId)
const event = events?.get(itemId)
if (event === undefined) return undefined
events?.delete(itemId)
if (events?.size === 0) unseenQueueEvents.delete(sessionId)
return event
}
const publishQueue = (sessionId: SessionId): void => {
const items = queuedMirror.get(sessionId) ?? []
broadcast({
type: 'session/queue',
sessionId,
items: items.map(item => ({
id: item.id,
message: item.message,
})),
})
}
ctx.effect(() => {
const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => {
const retire = (agent: Agent, item: InboxItem): boolean => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) return
const index = entries.findIndex(entry =>
entry.message.id === id
&& (placement === undefined || entry.steering === (placement === 'steering')))
if (index !== -1) entries.splice(index, 1)
if (entries === undefined) {
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
return false
}
const index = entries.findIndex(entry => entry.id === item.id)
if (index === -1) {
rememberUnseen(agent.id, item.id, { kind: 'terminal' })
return false
}
entries.splice(index, 1)
if (entries.length === 0) queuedMirror.delete(agent.id)
return true
}
const disposers = [
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
if (item.placement !== 'queued') return
const unseen = takeUnseen(agent.id, item.id)
if (unseen?.kind === 'terminal') return
let entries = queuedMirror.get(agent.id)
if (entries === undefined) {
entries = []
queuedMirror.set(agent.id, entries)
}
const steering = placement === 'steering'
entries.push({ message, steering })
broadcast({
type: 'session/queued',
sessionId: agent.id,
message,
steering,
})
entries.push(unseen?.kind === 'update' ? unseen.item : item)
publishQueue(agent.id)
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => {
retire(agent, message.id, placement)
ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) {
rememberUnseen(agent.id, item.id, { kind: 'update', item })
return
}
const index = entries.findIndex(entry => entry.id === item.id)
if (index === -1) {
rememberUnseen(agent.id, item.id, { kind: 'update', item })
return
}
entries.splice(index, 1, item)
publishQueue(agent.id)
}),
ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => {
for (const message of messages) retire(agent, message.id)
ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
if (retire(agent, item)) publishQueue(agent.id)
}),
ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => {
let changed = false
for (const item of items) changed = retire(agent, item) || changed
if (changed) publishQueue(agent.id)
}),
ctx.on('session/disposed', (session: Session) => {
queuedMirror.delete(session.id)
unseenQueueEvents.delete(session.id)
}),
]
return () => { for (const dispose of disposers) dispose() }
@@ -640,6 +745,90 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}, 'api-proxy: user-interaction provider')
// --- Approval pending registry ------------------------------------------
// The proxy is the approval channel for every agent this host owns: an ask
// through `ctx.approval` becomes an answerable server-request on the mux
// stream (stable rpcId), settled by POST /api/respond. The entry survives
// client disconnects — mux-open replays still-pending requested frames with
// the same rpcId (the refresh-recovery baseline) — and withdraws on the
// ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
if (ctx.get('approval') !== undefined) {
// Teardown parity with the question provider above: a gateway disposed
// while approvals are pending settles every entry as 'cancelled' (the
// service's fail-closed vocabulary), so no ask promise dangles past the
// proxy's lifetime and subscribers see the withdrawal.
ctx.effect(() => () => {
for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
}, 'api-proxy: approval registry teardown')
ctx.on('approval/request', (req, next) => {
// Dispatch rides a microtask behind the service's own signal check: an
// abort landing in that window would register the abort listener AFTER
// the signal fired — never invoked, entry pending forever, zombie frame
// on every mux replay. Settle synchronously instead of publishing.
if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
// The audit pair `approval/asked` is already appended by the service
// before dispatch, but dispatch rides a microtask: parallel tool calls
// can append several asked events before any answerer runs. THIS
// request's event is therefore the newest asked event that is still
// undecided, unclaimed by another pending entry, and — when the ask
// names a call — carries the same callId.
const events = req.agent.session.events
const claimed = new Set<ApprovalRequestId>()
for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
const decided = new Set<ApprovalRequestId>()
let approvalId: ApprovalRequestId | undefined
for (let i = events.length - 1; i >= 0; i -= 1) {
const event = events[i] as SessionEvent
if (event.type === 'approval/decided') {
decided.add(event.data.id)
} else if (event.type === 'approval/asked') {
if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
// Symmetric pairing: a callId-bearing ask only takes its own call's
// record, and a callId-less ask only takes a callId-less record —
// so neither shape can steal the other's audit id under parallel
// asks. (Today every producer — the tool executor — passes callId;
// the callId-less arm guards any future non-tool asker.)
if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
approvalId = event.data.id
break
}
}
// No asked event means the request bypassed the service's audit path —
// not this channel's question; delegate to the fail-closed default.
if (approvalId === undefined) return next()
const id = approvalId
return new Promise<ApprovalOutcome>((resolve) => {
const settle = (outcome: ApprovalOutcome): void => {
/* v8 ignore next 3 -- defensive double-settle guard: respond() routes
through the pending table (a settled id is not-pending before it can
re-settle) and the first settle removes the abort listener, so no
reachable path settles twice; kept against future settle callers. */
if (!pendingApprovals.delete(pending.rpcId)) return
req.signal?.removeEventListener('abort', onAbort)
broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
// A cancelled ask was already settled by the service's own signal
// race, which discards this late resolution; resolving is a no-op
// there and keeps this promise from dangling forever.
resolve(outcome)
}
const onAbort = (): void => { settle('cancelled') }
const pending: PendingApproval = {
rpcId: RpcId(randomUUID()),
sessionId: req.agent.session.id,
approvalId: id,
toolName: req.toolName,
...req.callId === undefined ? {} : { callId: req.callId },
...req.reason === undefined ? {} : { reason: req.reason },
resolve: settle,
}
pendingApprovals.set(pending.rpcId, pending)
req.signal?.addEventListener('abort', onAbort, { once: true })
const envelope = requestedFrame(pending)
for (const queue of muxQueues) queue.push(envelope)
})
})
}
/**
* Gate the cold path on the store: an id absent from it, or naming a legacy
* log without a cwd (pre-release stance: not served, no compatibility), is
@@ -1005,6 +1194,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async rename(request) {
const { sessionId, title } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const titles = ctx.get('sessionTitle')
if (titles === undefined) {
return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
}
try {
const accepted = titles.rename(found.agent.session, title)
return ok(request, { title: accepted.title, seq: accepted.eventSeq })
} catch (error: unknown) {
// Only the input's fault maps to title-invalid (the message is
// product-user-visible in the rename dialog); liveness and disposal
// races are deployment trouble, not a bad title.
if (error instanceof SessionTitleInvalidError) {
return err(request, {
code: 'title-invalid',
message: error.message,
details: { sessionId },
})
}
return err(request, {
code: 'internal',
message: `failed to rename session "${sessionId}": ${String(error)}`,
details: {},
})
}
},
async prompt(request) {
const { sessionId, mode, content } = request.payload
const found = await agentFor(sessionId)
@@ -1023,6 +1242,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return ok(request, { accepted: true as const })
},
updateQueue(request) {
const { sessionId, itemId, action } = request.payload
const agent = ctx.agents.get(sessionId)
if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
return Promise.resolve(err(request, {
code: 'queue-item-not-found',
message: 'queued item is no longer pending',
details: { itemId },
}))
}
return Promise.resolve(ok(request, { accepted: true as const }))
},
cancel(request) {
const { sessionId } = request.payload
const agent = ctx.agents.get(sessionId)
@@ -1173,8 +1405,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) {
@@ -1192,6 +1432,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
@@ -1334,7 +1613,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
}
try {
const skills = await skillRegistry.list({ cwd })
const skills = (await skillRegistry.list({ cwd }))
.filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,
@@ -1459,18 +1739,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
})
}
// Refresh recovery: still-pending approval questions replay with their
// stable rpcId so a reconnecting client can still answer them.
for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
// Queue snapshot baseline (pendingQuestions precedent): frames replayed
// in arrival order per session; a reconnecting client rebuilds its
// queue view from these alone.
for (const [sessionId, entries] of queuedMirror) {
for (const entry of entries) {
queue.push(frame({
type: 'session/queued',
sessionId,
message: entry.message,
steering: entry.steering,
}))
}
for (const [sessionId, items] of queuedMirror) {
queue.push(frame({
type: 'session/queue',
sessionId,
items: items.map(item => ({
id: item.id,
message: item.message,
})),
}))
}
// Per-session open-call table for result-view pairing. Bounded by the
// per-turn call count: entries clear on turn/end; a table miss (stream
@@ -1585,6 +1868,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
respond(message: ClientResponse): Promise<RpcReceipt> {
// Route by the echoed rpcId (the wire correlation): approvals first,
// then questions — the two registries share one id space of UUIDs.
const approval = pendingApprovals.get(message.rpcId)
if (approval !== undefined) {
if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
// The payload's audit correlation must match the entry the rpcId routed
// to — a mismatched answer is malformed, not merely late.
if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
return Promise.resolve({ accepted: false, reason: 'bad-response' })
}
approval.resolve(parsed.data.outcome)
return Promise.resolve({ accepted: true })
}
const pending = pendingQuestions.get(message.rpcId)
if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
if (!message.result.ok) {

View File

@@ -10,7 +10,9 @@ import type { HostFrame, MuxFrame } from './events.ts'
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 {
contentBlockSchema, inboxItemIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
} from './sessions.schema.ts'
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
/** Question shape validated strictly against core dsh-user-interaction. */
@@ -42,7 +44,14 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
// and must fail loud here, not reach the composer.
z.object({ type: z.literal('question/requested'), sessionId: sessionIdSchema, questions: z.array(askUserQuestionItemSchema).min(1) }),
z.object({ type: z.literal('question/resolved'), sessionId: sessionIdSchema, questionRpcId: rpcIdSchema, outcome: z.union([z.literal('answered'), z.literal('cancelled')]) }),
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }),
z.object({
type: z.literal('session/queue'),
sessionId: sessionIdSchema,
items: z.array(z.object({
id: inboxItemIdSchema,
message: messageSchema,
})),
}),
// value stays wide: it already passed its unit's own schema on the host,
// and deep-validating here would import every domain's schema into the carrier.
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),

View File

@@ -9,6 +9,7 @@
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { Message } from '@deepseek-ai/dsh-llm/types'
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
import type { CallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
@@ -31,6 +32,14 @@ export type ToolEventView =
| { for: 'call'; view: ToolCallView }
| { for: 'result'; view: ToolResultView }
/** One pending queued occurrence in an authoritative queue snapshot. */
export interface QueuedInboxItem {
/** Agent-owned occurrence identity used by queue mutations. */
id: InboxItemId
/** Complete pending message; it is not durable until the Agent claims it. */
message: Message
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
export interface EventsApi {
/**
@@ -62,18 +71,13 @@ 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. 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.
* `steering` is the host's acceptance-time queue classification and remains
* authoritative in reconnect snapshots. `message.source` carries the prompt's rpcId
* when the message came over this wire (the client's provisional-echo
* reconciliation key).
* Complete transient queue state after every enqueue, mutation, claim, or
* discard. Pending work is not model-visible and therefore has no durable
* session event; the whole snapshot makes edit, deletion, cancel, and
* reconnect converge through one authoritative signal. Pending steering is
* outside this Web queue projection.
*/
| { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean }
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the

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

@@ -35,13 +35,13 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, QueueAction, 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'
export type { EventsApi, MuxFrame, HostFrame, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { EventsApi, MuxFrame, HostFrame, QueuedInboxItem, ToolCallView, ToolEventView, ToolResultView } from './events.ts'
export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsSecretView } from './settings.ts'
export type { CredentialsApi, CredentialView } from './credentials.ts'
@@ -65,6 +65,7 @@ export type {
// ---- Errors and ids ----
export { RpcId, transportError } from './rpc.ts'
export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
export type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
// ---- Method registry and derived generics ----
export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'

View File

@@ -26,10 +26,14 @@ export interface RpcMethodMap {
'session.history': SessionsApi['history']
'session.models': SessionsApi['models']
'session.selectModel': SessionsApi['selectModel']
'session.rename': SessionsApi['rename']
'session.prompt': SessionsApi['prompt']
'session.updateQueue': SessionsApi['updateQueue']
'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,11 +42,17 @@ 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('queue-item-not-found'), message: z.string(), details: z.object({ itemId: 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({}) }),
z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -9,6 +9,7 @@ import type { z as zCore } from 'zod'
type ZodIssue = zCore.core.$ZodIssue
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
/**
* Message correlation id: the initiator mints it on a request; a response
@@ -39,7 +40,12 @@ 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 }
'queue-item-not-found': { itemId: InboxItemId }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}
/** A leading-/ prompt named no registered command; the message names the token. */
@@ -51,6 +57,7 @@ export interface RpcErrorDetailsMap {
'settings-rejected': { ns: string }
/** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */
'credential-rejected': { ref: string }
'title-invalid': { sessionId: SessionId }
'internal': {}
}

View File

@@ -7,6 +7,7 @@
import { z } from 'zod'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
@@ -19,6 +20,9 @@ import type { WorkspaceId } from './workspace.ts'
/** SessionId: one brand cast after shape validation (the only cast point in this domain). */
export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>
/** InboxItemId: one brand cast after non-empty string validation. */
export const inboxItemIdSchema = z.string().min(1) as unknown as z.ZodType<InboxItemId>
/**
* WorkspaceId: the workspace domain's one brand cast. Hosted here rather
* than in workspace.schema because session.create references it while
@@ -73,6 +77,18 @@ export const sessionCreateValueSchema = z.object({
sessionId: sessionIdSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
/** session.rename request payload (raw title; host-side normalization decides acceptance). */
export const sessionRenameRequestSchema = z.object({
sessionId: sessionIdSchema,
title: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'session.rename'>>>
/** session.rename response value (the normalized accepted title and its event seq). */
export const sessionRenameValueSchema = z.object({
title: z.string().min(1),
seq: z.number().int().nonnegative(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.rename'>>>
/** session.history request payload (beforeSeq/maxMessages page backwards from the window tail). */
export const sessionHistoryRequestSchema = z.object({
sessionId: sessionIdSchema,
@@ -202,6 +218,21 @@ export const sessionPromptValueSchema = z.object({
}).optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.prompt'>>>
/** session.updateQueue request payload. */
export const sessionUpdateQueueRequestSchema = z.object({
sessionId: sessionIdSchema,
itemId: inboxItemIdSchema,
action: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('edit'), content: z.array(contentBlockSchema) }),
z.object({ kind: z.literal('remove') }),
]),
}) as unknown as z.ZodType<RequestPayload<'session.updateQueue'>>
/** session.updateQueue response value. */
export const sessionUpdateQueueValueSchema = z.object({
accepted: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'session.updateQueue'>>>
/** session.cancel request payload. */
export const sessionCancelRequestSchema = z.object({
sessionId: sessionIdSchema,

View File

@@ -5,6 +5,7 @@
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
// The pure-type outlet: api/ is browser-importable, and the package root's
// cordis Context merge (via dsh-agent) must not enter client aggregates.
@@ -124,6 +125,11 @@ export interface SessionModels {
failures: ModelCatalogFailure[]
}
/** A client-requested mutation of one still-pending queue item. */
export type QueueAction =
| { kind: 'edit'; content: ContentBlock[] }
| { kind: 'remove' }
/** Session list entry (v1 builds no index: list does readdir+stat). */
export interface SessionSummary {
sessionId: SessionId
@@ -208,6 +214,16 @@ export interface SessionsApi {
}>):
Promise<RpcResponse<{ selected: ModelTarget }>>
/**
* Renames a session: appends a `session/title` event with the `user`
* source, which pins the title against automatic regeneration. The
* normalized accepted title and the title event's seq return so the caller
* can settle its projection cell without waiting for the push frame. A
* title that normalizes to empty fails with `title-invalid`.
*/
rename(request: RpcRequest<{ sessionId: SessionId; title: string }>):
Promise<RpcResponse<{ title: string; seq: number }>>
/**
* Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer.
* A prompt whose content is exactly one text block starting with '/' is a slash command: the host
@@ -219,6 +235,13 @@ export interface SessionsApi {
prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>):
Promise<RpcResponse<{ accepted: true; command?: { kind: 'success'; text?: string } }>>
/**
* Edits or removes one pending queued occurrence.
*/
updateQueue(request: RpcRequest<{ sessionId: SessionId; itemId: InboxItemId; action: QueueAction }>):
Promise<RpcResponse<{ accepted: true }>>
/** Stops: clears both FIFOs + aborts the current step (1:1 with agent.cancel). */
cancel(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ accepted: true }>>
}

View File

@@ -20,6 +20,6 @@ export interface SkillEntry {
/** Skill-domain unary methods (the map key skill.* of RpcMethodMap). */
export interface SkillsApi {
/** Lists model-invocable skills for the addressed session's project root. */
/** Lists skills usable by the browser's user-selected model-reference path. */
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
}

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,
@@ -23,7 +24,9 @@ import {
sessionListValueSchema,
sessionModelsValueSchema,
sessionPromptValueSchema,
sessionRenameValueSchema,
sessionSelectModelValueSchema,
sessionUpdateQueueValueSchema,
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
@@ -72,12 +75,16 @@ export interface IApiClient {
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'>>>
rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.rename'>>>
prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.prompt'>>>
updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'session.updateQueue'>>>
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'>>>
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: {
@@ -134,10 +141,14 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.history': sessionHistoryValueSchema,
'session.models': sessionModelsValueSchema,
'session.selectModel': sessionSelectModelValueSchema,
'session.rename': sessionRenameValueSchema,
'session.prompt': sessionPromptValueSchema,
'session.updateQueue': sessionUpdateQueueValueSchema,
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
'host.createDirectory': hostCreateDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
@@ -351,7 +362,9 @@ export abstract class AbstractApiClient implements IApiClient {
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),
rename: (payload, signal) => this.callUnary('session.rename', payload, signal),
prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal),
updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal),
cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal),
}
@@ -360,6 +373,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

@@ -2,8 +2,8 @@
* Server side of the fetch carrier: maps an ApiProxy onto a pure
* WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
* path==method) -> payload dispatched per method. HTTP status expresses only the carrier
* (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always
* 200 + ServerResponse.
* (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash);
* business errors are always 200 + ServerResponse.
*/
import { randomUUID } from 'node:crypto'
@@ -21,10 +21,14 @@ import {
sessionListRequestSchema,
sessionModelsRequestSchema,
sessionPromptRequestSchema,
sessionRenameRequestSchema,
sessionSelectModelRequestSchema,
sessionUpdateQueueRequestSchema,
} from '../api/sessions.schema.ts'
import {
hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema,
hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
@@ -73,10 +77,14 @@ const UNARY_ROUTES: UnaryRoutes = {
'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.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) },
'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) },
'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(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) },
'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) },
@@ -134,7 +142,7 @@ function fullResponse(narrow: RpcResponse<unknown>): Response {
*/
// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
async function handleUnary<K extends keyof RpcMethodMap>(
api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
): Promise<Response> {
@@ -220,6 +228,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
return new Response('not found', { status: 404 })
}
// Cross-site write fence: browsers send "simple" POSTs (text/plain,
// form encodings) without a CORS preflight, so a malicious page could
// otherwise execute side-effectful RPCs blind — the response stays
// unreadable cross-origin, but session.prompt would still run. Only the
// JSON media type is accepted; anything else is forced into a preflight
// this server never answers. 415 = carrier layer, like the 400 below.
const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') {
return new Response('content type must be application/json', { status: 415 })
}
let body: unknown
try {
body = await req.json()

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,38 +0,0 @@
/** Shared no-shell `execFile` runner for native host dialogs and openers. */
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type NativeCommandRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/**
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
* @param command - executable path or PATH name.
* @param args - argv (never a shell string).
* @param signal - caller/connection lifetime; abort terminates the child.
* @returns captured stdout/stderr on exit 0.
*/
export const runNativeCommand: NativeCommandRunner = (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 })
},
)
})

View File

@@ -1,111 +0,0 @@
/** Cross-platform native single-directory picker used by the local GUI carrier. */
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = NativeCommandRunner
/** Injectable platform facts for deterministic adapter tests. */
export interface DirectoryPickerInternals {
platform?: NodeJS.Platform
run?: DirectoryPickerRunner
}
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 ?? runNativeCommand
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}`)
}

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