Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker

# Conflicts:
#	packages/client/connection/src/client/fixture.ts
#	packages/client/connection/tests/fake-api.ts
#	packages/client/runtime/src/client/workspaces/service.ts
#	packages/client/runtime/tests/fake-api.ts
#	packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
#	packages/client/ui-workspace/src/client/WorkspacePicker.tsx
#	packages/client/ui-workspace/tests/workspace-picker.spec.tsx
#	packages/host/apiproxy/README.i18n.yaml
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/host.schema.ts
#	packages/host/apiproxy/src/api/host.ts
#	packages/host/apiproxy/src/api/rpc-map.ts
#	packages/host/apiproxy/src/fetch/client.ts
#	packages/host/apiproxy/src/fetch/handler.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/host/apiproxy/tests/client-handler.spec.ts
#	packages/host/apiproxy/tests/fetch-carrier.spec.ts
This commit is contained in:
creatixchu
2026-07-28 21:21:21 +08:00
750 changed files with 15381 additions and 5577 deletions

View File

@@ -9,14 +9,13 @@ import { join } from 'node:path'
import type { Context } from 'cordis'
import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
import type {
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentMessage, AgentMessageId, AgentStatus,
Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxPlacement,
} from '@deepseek-ai/dsh-agent'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
import type { MessageId, 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 { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
@@ -26,9 +25,11 @@ import {
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
WorkspaceId, WorkspaceView,
} from './api/index.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
// Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
import type {} from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-skill'
@@ -40,6 +41,7 @@ import type {
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -129,26 +131,9 @@ function frame<F>(payload: F): RpcRequest<F> {
return { rpcId: RpcId(randomUUID()), payload }
}
type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
/** Project the latest durable title without exposing title-generation policy. */
function titleFrame(session: Session): SessionTitleFrame | undefined {
const title = foldSessionTitle(session.events)
if (title === undefined) return undefined
return {
type: 'session/title',
sessionId: session.id,
title: title.title,
eventSeq: title.eventSeq,
updatedAt: title.updatedAt,
}
}
/** Queue the subscription baseline followed by its optional title snapshot. */
/** Queue the subscription baseline frame. */
function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
const title = titleFrame(session)
if (title !== undefined) queue.push(frame(title))
}
/** SessionSummary projection for attached (in-memory) sessions. */
@@ -209,13 +194,12 @@ export interface ApiProxyDefaults {
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** 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 }
/** The tool/result payload fields the presenter path reads. */
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
/** One host-owned question wait, addressed by the stable server-request id. */
interface PendingQuestion {
rpcId: RpcId
@@ -262,10 +246,16 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
const { callId, content, isError, meta } = event.data as ToolResultData
const { message, meta } = event.data
const [result] = message.content
const callId = message.source.callId
const call = argsFor(callId) as { name: string; args: unknown } | undefined
if (call === undefined) return undefined
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
content: result.content,
isError: result.isError === true,
...meta === undefined ? {} : { meta },
})
return view === undefined ? undefined : { for: 'result', view }
}
} catch (error: unknown) {
@@ -298,13 +288,19 @@ 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
/**
* The projection baseline for one history tail page: the registry's
* watermark-cache snapshot — one fully synchronous read (no await between the
* page slice and this), so all values and `asOfSeq` form a single consistent
* cut and `asOfSeq` equals the window tail event seq. The carrier holds zero
* domain knowledge (each value passed its unit's own schema inside the
* registry). An absent registry means the deployment has no projection seam:
* the whole block is absent and clients treat every key as capability-absent.
*/
function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
return registry.snapshot(agent.session)
}
/**
@@ -423,42 +419,53 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
for (const queue of muxQueues) queue.push(envelope)
}
// Projection change feed → session/projection push frames. The carrier
// mints the wire frame (the seam package holds no wire vocabulary); the
// child activates only when a projection registry is composed, and the
// subscription unwinds with this gateway's fiber.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq })
})
})
/**
* Per-session inbox mirror serving the mux-open queue snapshot (the same
* refresh-recovery baseline as pending questions). Keyed by the stable
* AgentMessageId: every enqueued id receives exactly one terminal
* `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
* the mirror needs no consumption heuristics or sweeps beyond disposal.
* Per-session inbox 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
* identified message remain visible until every occurrence is claimed.
*/
const queuedMirror = new Map<SessionId, Map<AgentMessageId, { message: AgentMessage; steering: boolean }>>()
const queuedMirror = new Map<SessionId, { message: UserMessage; steering: boolean }[]>()
ctx.effect(() => {
const retire = (agent: Agent, id: AgentMessageId): void => {
const retire = (agent: Agent, id: MessageId, placement?: InboxPlacement): void => {
const entries = queuedMirror.get(agent.id)
if (entries === undefined) return
entries.delete(id)
if (entries.size === 0) queuedMirror.delete(agent.id)
const index = entries.findIndex(entry =>
entry.message.id === id
&& (placement === undefined || entry.steering === (placement === 'steering')))
if (index !== -1) entries.splice(index, 1)
if (entries.length === 0) queuedMirror.delete(agent.id)
}
const disposers = [
ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage, placement) => {
ctx.on('agent/inbox/enqueue', (agent: Agent, message: UserMessage, placement) => {
let entries = queuedMirror.get(agent.id)
if (entries === undefined) {
entries = new Map<AgentMessageId, { message: AgentMessage; steering: boolean }>()
entries = []
queuedMirror.set(agent.id, entries)
}
const steering = placement === 'steering'
entries.set(message.id, { message, steering })
entries.push({ message, steering })
broadcast({
type: 'session/queued',
sessionId: agent.id,
content: message.content,
source: message.source,
message,
steering,
})
}),
ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
retire(agent, message.id)
ctx.on('agent/inbox/dequeue', (agent: Agent, message: UserMessage, placement) => {
retire(agent, message.id, placement)
}),
ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
ctx.on('agent/inbox/discard', (agent: Agent, messages: UserMessage[]) => {
for (const message of messages) retire(agent, message.id)
}),
ctx.on('session/disposed', (session: Session) => {
@@ -717,6 +724,8 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const { sessionId, beforeSeq, maxMessages } = request.payload
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
// Everything below the resume above is synchronous: the page slice,
// the seq read, and the projection walk see one un-torn session state.
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
// Views are computed against the registry at pagination time; result
// pairing scans within the page only (message-boundary pagination keeps
@@ -725,11 +734,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
return { event, ...view === undefined ? {} : { view } }
})
// 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 } })
// Baseline rider: tail page only — loadOlder (beforeSeq present) is
// the one path that never needs a fresh projection baseline.
const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
return ok(request, {
events: entries,
hasMore: page.hasMore,
...projections === undefined ? {} : { projections },
})
},
async models(request) {
@@ -841,8 +853,9 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
try {
if (mode === 'steer') agent.steer({ content, source })
else agent.followup({ content, source })
const message: UserMessage = createUserMessage({ content, source })
if (mode === 'steer') agent.steer(message)
else agent.followup(message)
} catch (error: unknown) {
// A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
@@ -1059,6 +1072,28 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, directoryError(error))
}
},
async openPath(request, signal) {
try {
const open = defaults.openPath
?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
await open(request.payload.path, signal)
return ok(request, { opened: true as const })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'path open was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
},
},
commands: {
@@ -1086,12 +1121,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
try {
const result = await commands.execute(found.agent, line, signal)
if (result === undefined) return ok(request, { matched: false })
return ok(request, {
matched: true,
result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
})
// Pure admission: the executor's durable command/run + command/done
// pair (broadcast on the mux stream) carries the outcome; the
// response reports whether the line resolved to a handler, plus the
// minted pairing id so the issuing client can correlate its request
// with the flow node the lifecycle events produce.
const execution = await commands.execute(found.agent, line, signal)
return ok(request, execution === undefined
? { matched: false }
: { matched: true, commandId: execution.commandId })
} catch (error: unknown) {
if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
@@ -1163,12 +1201,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// 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.values()) {
for (const entry of entries) {
queue.push(frame({
type: 'session/queued',
sessionId,
content: entry.message.content,
source: entry.message.source,
message: entry.message,
steering: entry.steering,
}))
}
@@ -1194,10 +1231,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
if (event.type === 'session/title') {
// The accepted raw event is already in session.events, so the fold must find it.
queue.push(frame(titleFrame(session) as SessionTitleFrame))
}
}),
ctx.on('session/created', (session: Session) => {
subscribeSession(queue, session)

View File

@@ -4,10 +4,11 @@
*/
import { z } from 'zod'
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import { sessionIdSchema } from './sessions.schema.ts'
import type { CommandDescriptor, CommandExecuteResult } from './commands.ts'
import type { CommandDescriptor } from './commands.ts'
/** CommandDescriptor row of command.list. */
export const commandDescriptorSchema = z.object({
@@ -32,14 +33,12 @@ export const commandExecuteRequestSchema = z.object({
line: z.string(),
}) satisfies z.ZodType<Wire<RequestPayload<'command.execute'>>>
/** Detached command outcome (result slot of command.execute's value). */
export const commandExecuteResultSchema = z.object({
kind: z.union([z.literal('success'), z.literal('error')]),
text: z.string().optional(),
}) satisfies z.ZodType<Wire<CommandExecuteResult>>
/** CommandId: one brand cast after shape validation (the only cast point in this domain). */
export const commandIdSchema = z.string().min(1) as unknown as z.ZodType<CommandId>
/** command.execute response value (matched=false carries no result). */
/** command.execute response value: pure admission — outcomes ride the logged
* lifecycle events; commandId (present exactly when matched) correlates with them. */
export const commandExecuteValueSchema = z.object({
matched: z.boolean(),
result: commandExecuteResultSchema.optional(),
commandId: commandIdSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'command.execute'>>>

View File

@@ -5,6 +5,7 @@
* together), so there is no agent-less surface on this wire.
*/
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RpcRequest, RpcResponse } from './rpc.ts'
@@ -22,12 +23,6 @@ export interface CommandDescriptor {
readonly input?: { readonly hint: string }
}
/** Detached command outcome rendered directly by the requesting client. */
export interface CommandExecuteResult {
readonly kind: 'success' | 'error'
readonly text?: string
}
/** Command-domain unary methods (the map keys command.* of RpcMethodMap). */
export interface CommandsApi {
/**
@@ -38,11 +33,16 @@ export interface CommandsApi {
/**
* Parses and executes one slash-command line against the addressed agent
* without sending it to the model. matched=false when syntax or name does
* not resolve (the client falls back to its default sink). The signal rides
* beside the request, never on the wire: the fetch carrier's request signal
* cancels the running handler.
* without sending it to the model — pure admission semantics. matched=false
* when syntax or name does not resolve (the client falls back to its
* default sink). The handler's outcome does NOT ride the response: the host
* executor durably logs the lifecycle (`command/run`/`command/done`), which
* broadcasts on the mux stream and renders as a persistent flow node.
* `commandId` is present exactly when matched — the minted lifecycle
* pairing id, letting the issuing client correlate this acknowledgment
* with that flow node. The signal rides beside the request, never on the
* wire: the fetch carrier's request signal cancels the running handler.
*/
execute(request: RpcRequest<{ sessionId: SessionId; line: string }>, signal: AbortSignal):
Promise<RpcResponse<{ matched: boolean; result?: CommandExecuteResult }>>
Promise<RpcResponse<{ matched: boolean; commandId?: CommandId }>>
}

View File

@@ -23,11 +23,18 @@ export const askUserQuestionItemSchema = z.object({
multiSelect: z.boolean().optional(),
}) satisfies z.ZodType<Wire<AskUserQuestionItem>>
/** Unified message envelope carried by transient queue frames. */
const messageSchema = z.object({
id: z.string().min(1),
role: z.union([z.literal('system'), z.literal('user'), z.literal('assistant')]),
content: z.array(contentBlockSchema),
source: z.looseObject({ kind: z.string() }),
})
/** MuxFrame union (payload slot of a mux-stream ServerRequest). */
export const muxFrameSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('session/event'), sessionId: sessionIdSchema, event: sessionEventSchema, view: toolEventViewSchema.optional() }),
z.object({ type: z.literal('session/subscribed'), sessionId: sessionIdSchema, lastSeq: z.number().int() }),
z.object({ type: z.literal('session/title'), sessionId: sessionIdSchema, title: z.string().min(1), eventSeq: z.number().int().nonnegative(), updatedAt: z.number() }),
z.object({ type: z.literal('approval/requested'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, toolName: z.string(), callId: z.string().optional(), reason: z.string().optional() }),
z.object({ type: z.literal('approval/resolved'), sessionId: sessionIdSchema, approvalId: approvalRequestIdSchema, outcome: z.union([z.literal('allowed-once'), z.literal('rejected'), z.literal('cancelled'), z.literal('unavailable')]) }),
// Non-empty by wire contract: the user-interaction service rejects empty
@@ -35,8 +42,10 @@ 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')]) }),
// content/source reuse the wide passthroughs (both are merge-extensible in core).
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, content: z.array(contentBlockSchema), source: z.looseObject({ kind: z.string() }), steering: z.boolean() }),
z.object({ type: z.literal('session/queued'), sessionId: sessionIdSchema, message: messageSchema, steering: z.boolean() }),
// 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() }),
z.object({ type: z.literal('stream/error'), error: rpcErrorSchema }),
]) as unknown as z.ZodType<MuxFrame>

View File

@@ -8,7 +8,7 @@
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction/types'
import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval/types'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm/types'
import type { Message } from '@deepseek-ai/dsh-llm/types'
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'
@@ -35,9 +35,9 @@ export type ToolEventView =
export interface EventsApi {
/**
* All-session aggregated mux stream. On open, emits a subscribed control frame for every
* attached session followed by its optional latest title snapshot, then replays each
* session's still-pending approval/question requested frames (rpcId reused verbatim — the
* refresh-recovery baseline).
* attached session, then replays each session's still-pending approval/question requested
* frames (rpcId reused verbatim — the refresh-recovery baseline). Session titles ride the
* generic projection pair (history-tail projections block + session/projection frames).
* since: resume seam, unimplemented in v1 (ignored if passed); reconnection = reopen the
* stream + refetch history.
*/
@@ -57,7 +57,6 @@ export interface EventsApi {
export type MuxFrame =
| { type: 'session/event'; sessionId: SessionId; event: SessionEvent; view?: ToolEventView }
| { type: 'session/subscribed'; sessionId: SessionId; lastSeq: number }
| { type: 'session/title'; sessionId: SessionId; title: string; eventSeq: number; updatedAt: number }
| { type: 'approval/requested'; sessionId: SessionId; approvalId: ApprovalRequestId; toolName: string; callId?: CallId; reason?: string }
| { type: 'approval/resolved'; sessionId: SessionId; approvalId: ApprovalRequestId; outcome: ApprovalOutcome }
| { type: 'question/requested'; sessionId: SessionId; questions: AskUserQuestionItem[] }
@@ -70,11 +69,20 @@ export type MuxFrame =
* 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. `source` carries the prompt's rpcId
* 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).
*/
| { type: 'session/queued'; sessionId: SessionId; content: ContentBlock[]; source: MessageSource; steering: boolean }
| { type: 'session/queued'; sessionId: SessionId; message: Message; steering: boolean }
/**
* One projection unit's finished value changed (session-projection RFC).
* Live push state, never logged — replay recomputes on the host (the
* tool-view posture). `value` is the unit's schema-validated view output;
* `seq` is the unit's watermark at emission. Clients keep one generic
* per-session value store under higher-seq-wins, seeded by the history
* tail page's projections block.
*/
| { type: 'session/projection'; sessionId: SessionId; key: string; value: unknown; seq: number }
| { type: 'stream/error'; error: RpcError }
/**

View File

@@ -64,3 +64,12 @@ export const hostCreateDirectoryRequestSchema = z.object({
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),
}) satisfies z.ZodType<Wire<RequestPayload<'host.openPath'>>>
/** host.openPath response value. */
export const hostOpenPathValueSchema = z.object({
opened: z.literal(true),
}) satisfies z.ZodType<Wire<ResponseValue<'host.openPath'>>>

View File

@@ -89,4 +89,15 @@ export interface HostApi {
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's
* prefix-wide trust fence covers this privileged method like every other
* `/api` request.
*/
openPath(
request: RpcRequest<{ path: string }>,
signal: AbortSignal,
): Promise<RpcResponse<{ opened: true }>>
}

View File

@@ -27,11 +27,11 @@ export interface ApiProxy {
// ---- Domain interfaces and payload entities ----
export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionsApi, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { DirectoryEntry, DirectoryListing, DirectoryPickerKind, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor, CommandExecuteResult } from './commands.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 { ApprovalResponsePayload } from './approvals.ts'

View File

@@ -28,6 +28,7 @@ export interface RpcMethodMap {
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']
'host.createDirectory': HostApi['createDirectory']
'host.openPath': HostApi['openPath']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']

View File

@@ -11,7 +11,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionSummary,
ModelReasoningEffort, ModelTarget, SessionProjectionsBlock, SessionSummary,
} from './sessions.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -139,17 +139,22 @@ 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')]),
})
/**
* Projection baseline passthrough: `values` stays a wide record — each value
* was already parsed by its provider's own schema on the host side, and
* deep-validating here would import every domain's schema into the carrier.
*/
export const sessionProjectionsBlockSchema = z.object({
// -1 = empty log (the lastSeq convention of session/subscribed).
asOfSeq: z.number().int().min(-1),
values: z.record(z.string(), z.unknown()),
}) as unknown as z.ZodType<SessionProjectionsBlock>
/** session.history response value. */
/** session.history response value (projections rides the tail page only). */
export const sessionHistoryValueSchema = z.object({
events: z.array(historyEntrySchema),
hasMore: z.boolean(),
todos: z.array(todoItemSchema).optional(),
projections: sessionProjectionsBlockSchema.optional(),
}) satisfies z.ZodType<Wire<ResponseValue<'session.history'>>>
/** session.models request payload. */

View File

@@ -5,7 +5,10 @@
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types'
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.
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
import type { ToolEventView } from './events.ts'
import type { WorkspaceId } from './workspace.ts'
@@ -32,6 +35,23 @@ export interface HistoryEntry {
view?: ToolEventView
}
/**
* The projection baseline riding the history tail page: one synchronous cut
* over every registered projection unit, read from the registry's watermark
* cache. `asOfSeq` is the seq of the last committed event every value
* reflects — the window tail event seq (`-1` for an empty log, mirroring
* `session/subscribed.lastSeq`), directly comparable with
* `session/projection` frame seqs under the client's higher-seq-wins rule. A
* key absent from `values` means the capability is absent (its domain plugin
* is unmounted).
*/
export interface SessionProjectionsBlock {
/** Seq of the last event the values reflect; -1 for an empty log. */
asOfSeq: number
/** Whole current value per registered projection key. */
values: Partial<SessionProjectionMap>
}
/** Complete model target selected for one session. */
export interface ModelTarget {
/** Registered provider route. */
@@ -149,13 +169,14 @@ 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).
* The tail page — and only the tail page — additionally carries `projections`
* when the deployment mounts the session-projection registry: every moment
* the client needs a fresh baseline already pulls the tail page, and
* loadOlder (the only beforeSeq path) is the only path that never needs one.
* A deployment without the registry serves histories without the block.
*/
history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>):
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; todos?: TodoItem[] }>>
Promise<RpcResponse<{ events: HistoryEntry[]; hasMore: boolean; projections?: SessionProjectionsBlock }>>
/** Reads a fresh advisory model directory for this session. Provider lookups run independently. */
models(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<SessionModels>>

View File

@@ -15,7 +15,7 @@ import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
import {
hostCreateDirectoryValueSchema, hostDescribeValueSchema,
hostListDirectoryValueSchema, hostPickDirectoryValueSchema,
hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
@@ -66,6 +66,7 @@ export interface IApiClient {
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: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
@@ -105,6 +106,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
'host.createDirectory': hostCreateDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
@@ -314,6 +316,7 @@ export abstract class AbstractApiClient implements IApiClient {
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),
}
readonly workspace: IApiClient['workspace'] = {

View File

@@ -25,7 +25,8 @@ import {
} from '../api/sessions.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
hostListDirectoryRequestSchema, hostPickDirectoryRequestSchema,
hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
@@ -65,6 +66,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r) => api.host.listDirectory(r) },
'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) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },

View File

@@ -0,0 +1,38 @@
/** 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

@@ -0,0 +1,53 @@
/** Cross-platform open-with-default-application 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 PathOpenerRunner = NativeCommandRunner
/** Injectable platform facts for deterministic adapter tests. */
export interface PathOpenerInternals {
platform?: NodeJS.Platform
run?: PathOpenerRunner
}
/** PowerShell single-quoted literal (doubles embedded quotes). */
function powershellLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/**
* Open a filesystem path with the operating system's default application.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - platform and runner seam for deterministic tests.
*/
export async function openNativePath(
path: string,
signal: AbortSignal,
internals: PathOpenerInternals = {},
): Promise<void> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runNativeCommand
if (platform === 'darwin') {
await run('open', [path], signal)
return
}
if (platform === 'win32') {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
return
}
if (platform === 'linux') {
await run('xdg-open', [path], signal)
return
}
throw new Error(`native path opener is unsupported on ${platform}`)
}