Merge remote-tracking branch 'origin/master' into codex/pr-555-ci-fix

# Conflicts:
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/module-graph.i18n.yaml
#	docs/module-graph.md
#	docs/module-graph.zh.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/client/connection/src/client/fixture.ts
#	packages/host/apiproxy/src/api-proxy.ts
This commit is contained in:
creatixchu
2026-08-10 12:33:36 +08:00
331 files changed
+16610 -557

No files matched your search

+501 -50
View File
@@ -5,7 +5,7 @@
import { randomUUID } from 'node:crypto'
import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import type { Context } from 'cordis'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
@@ -28,6 +28,11 @@ import {
WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import {
InvalidPresetIdError, PresetExistsError, PresetMountError,
PresetNotWritableError, resolveSessionPreset,
SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import type {} from '@deepseek-ai/dsh-tools'
import type {
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
@@ -60,6 +65,7 @@ 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 { ScopeKey } from '@deepseek-ai/dsh-scope'
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).
@@ -81,7 +87,7 @@ import {
hasApiRemoteSubagentOwner,
inspectApiRemoteSession,
} from '@deepseek-ai/dsh-api-remotes'
import { openNativePath, openNativeTextFile } from './native-path-opener.ts'
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
const DEFAULT_MAX_MESSAGES = 50
@@ -205,8 +211,15 @@ function referencedImage(events: readonly SessionEvent[], attachmentId: string):
return undefined
}
/** Product settings intentionally exposed beside model-provider namespaces. */
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
/**
* Product settings intentionally exposed beside model-provider namespaces.
*
* The agent-preset namespace carries one field — which preset a session with
* no explicit choice is composed from — and both browser surfaces that offer
* that choice write it through `settings.update`, so it has to cross the
* configuration boundary or the pickers silently fail to persist.
*/
const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE])
/** Read live abort state across awaits without treating it as synchronously immutable. */
function isAborted(signal: AbortSignal): boolean {
@@ -315,6 +328,35 @@ function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: false, error } }
}
/**
* The RPC refusal a preset failure becomes, or undefined when the failure is
* about something else.
*
* Both the session-create path and the switch path can be handed the same two
* failures, and a client that has to branch on the code needs them worded the
* same from either.
* @param request - the request being answered.
* @param error - the thrown value.
* @returns the refusal, or undefined when the caller should keep handling.
*/
function presetFailure(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> | undefined {
if (error instanceof UnknownPresetError) {
return err(request, {
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
})
}
if (error instanceof PresetMountError) {
return err(request, {
code: 'agent-preset-invalid',
message: error.message,
details: { agentPreset: error.presetId, reason: error.reason },
})
}
return undefined
}
/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
class FrameQueue<F> {
private buffer: F[] = []
@@ -375,15 +417,21 @@ function sessionBlank(session: Session): boolean {
}
/** Shared Session-header projection for list baselines and creation frames. */
function sessionListFields(header: SessionHeader): {
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
parentSessionId?: SessionId
origin?: 'subagent'
cwd?: string
agentPreset?: string
} {
// The preset comes from the log, not the header: a session that switched
// while blank ran its turns under the newer composition, and a picker
// showing the creation-time value would contradict what the model saw.
const agentPreset = resolveSessionPreset({ header, events })
return {
...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
...header.origin === undefined ? {} : { origin: header.origin },
...header.cwd === undefined ? {} : { cwd: header.cwd },
...agentPreset === undefined ? {} : { agentPreset },
}
}
@@ -396,7 +444,7 @@ function summarize(session: Session, running: boolean): SessionSummary {
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
running,
blank: sessionBlank(session),
...sessionListFields(session.header),
...sessionListFields(session.header, session.events),
}
}
@@ -430,12 +478,10 @@ async function summarizeCold(
// a cold log to check for turns would defeat the index read, so a listed
// cold session is served as not-blank (its log holds its conversation).
blank: false,
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
...meta.origin === undefined ? {} : { origin: meta.origin },
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
filters those out (legacy logs are not served); the conditional mirrors
summarize() shape. */
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
// Header-only: reading the log for a blank-window preset switch would
// defeat the same index read, and attaching the session replaces this row
// with `summarize()`, which resolves the switch from the events.
...sessionListFields(meta),
}
}
@@ -472,6 +518,14 @@ export interface ApiProxyDefaults {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Native text-editor handoff; injectable for settings-document tests. */
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
/**
* Whether handing a path to the native opener can work at all — the
* `hasDocument` capability the preset roster reports, and the switch
* between opening a preset directory and answering its path as text.
* Absent, an injected `openPath` counts as openable and everything else
* falls back to platform detection ({@link canOpenNativePath}).
*/
canOpenPath?: () => boolean
}
/** The tool/call payload fields the presenter path reads. */
@@ -546,11 +600,21 @@ function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQues
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
* the client's documented default (generic JSON card) covers every miss.
*/
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
function viewFor(
ctx: Context,
event: SessionEvent,
argsFor: (callId: string) => unknown,
// Presenters live with the definitions, and definitions live in the scope
// chain: a preset registers its tools into its standing layer. A live agent
// is a scope whose chain passes through its preset; a cold read passes the
// preset's standing key directly — no agent, no resume. An undefined scope
// sees only the global layer, which is the pre-preset deployment shape.
scope?: ScopeKey,
): ToolEventView | undefined {
try {
if (event.type === 'tool/call') {
const { name, arguments: raw } = event.data as ToolCallData
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw))
return view === undefined ? undefined : { for: 'call', view }
}
if (event.type === 'tool/result') {
@@ -559,7 +623,7 @@ function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) =>
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, {
const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, {
content: result.content,
isError: result.isError === true,
...meta === undefined ? {} : { meta },
@@ -602,11 +666,12 @@ function historyPage(
events: readonly SessionEvent[],
beforeSeq: number | undefined,
maxMessages: number | undefined,
scope?: ScopeKey,
): { events: HistoryEntry[]; hasMore: boolean } {
const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
return {
events: page.events.map((event) => {
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope)
return { event, ...view === undefined ? {} : { view } }
}),
hasMore: page.hasMore,
@@ -774,6 +839,57 @@ async function catalogChild(
}
}
/**
* The requested preset differs from the one this session already runs.
*
* A session's composition is fixed at creation: its history was produced under
* that preset's tools, so adopting the identity under a different one would
* replay tool calls the rebuilt agent cannot make. Naming a different preset
* is therefore a caller error rather than a switch.
*/
/** The roster is absent: this deployment composes no agent presets at all. */
function noRoster(agentPreset: string): RpcError {
return {
code: 'agent-preset-not-found',
message: 'this deployment composes no agent presets',
details: { agentPreset, available: [] },
}
}
/** Map one authoring/roster failure onto its wire code. */
function presetError(agentPreset: string, error: unknown): RpcError {
if (error instanceof UnknownPresetError) {
return {
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
}
}
if (error instanceof PresetNotWritableError) {
return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
}
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
}
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
}
class AgentPresetConflict extends Error {
constructor(
readonly sessionId: SessionId,
readonly requestedPreset: string,
readonly existingPreset: string | undefined,
) {
super(
existingPreset === undefined
? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; `
+ 'a deployment composing no roster records none on any session — '
: `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; `
+ `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`,
)
}
}
/** Requested identity already belongs to a session with another project cwd. */
class SessionCwdConflict extends Error {
constructor(
@@ -847,6 +963,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection }
const selections = new WeakMap<Agent, WebModelSelectionRef>()
/**
* Serializes `agentPreset.select` per session. Two concurrent selects both
* pass the blank check, and the second `unmountPresetFor` then finds nothing
* to unmount because the first already removed the record — leaving two
* compositions registered into one agent layer. The client's `busy` flag is
* not enforcement: the wire is reachable directly.
*/
const presetSwitches = new Map<SessionId, Promise<unknown>>()
/** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
const sessionCreations = new Map<SessionId, Promise<Agent>>()
/** Serializes path ownership and explicit title checks with Workspace mutations. */
@@ -911,6 +1035,64 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
selectionFor(agent)
}
/**
* Reject an attempt to run an existing session under a different preset.
*
* A caller that names no preset always adopts the session as it is, so the
* common paths — reconnecting, resuming, retrying a create — are unaffected.
* @param sessionId - the identity being adopted.
* @param requested - the preset the request named, if any.
* @param existing - the preset the session was created under, if any.
* @throws when both are present and differ.
*/
function assertPresetUnchanged(
sessionId: SessionId,
requested: string | undefined,
existing: string | undefined,
): void {
if (requested === undefined || requested === existing) return
throw new AgentPresetConflict(sessionId, requested, existing)
}
/**
* Resolve the preset an agent will be composed from, and the setup that
* installs it.
*
* The id is resolved BEFORE the session exists because the session boundary
* snapshots `meta` before asynchronous setup begins — a preset discovered
* during setup could never reach the header. Mounting still happens in
* setup, where a failure rolls the whole creation back rather than leaving a
* published session whose capabilities are half-installed.
*
* A deployment with no preset roster composes nothing and every session
* shares the host composition, which is the behavior before presets existed.
* @param presetId - the requested preset, or `undefined` for the default.
* @returns the id to record on the header (absent without a roster) and the setup callback.
* @throws when the roster supplies no such preset.
*/
async function composeAgent(presetId: string | undefined): Promise<{
agentPreset?: string
setup: (agentCtx: Context) => Promise<void>
}> {
const presets = ctx.get('agentPresets')
if (presets === undefined) {
return {
setup: (agentCtx: Context) => {
installSelection(agentCtx)
return Promise.resolve()
},
}
}
const resolvedId = (await presets.resolve(presetId)).id
return {
agentPreset: resolvedId,
setup: async (agentCtx: Context) => {
installSelection(agentCtx)
await presets.mount(agentCtx, resolvedId)
},
}
}
const hasSubagentOwner = (
session: Pick<Session, 'header'>,
agent: Agent | undefined,
@@ -919,7 +1101,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
apiRemoteSubagentOwnershipError(sessionId)
const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> =>
inspectApiRemoteSession(ctx, sessionId)
const agentFor = createApiRemoteAgentResolver(ctx, { agentOptions, setup: installSelection })
// Cold resume composes the preset the session recorded, for the same reason
// `session.create` does: its history was produced under that composition.
// Every generic entry point — prompt, models, commands — arrives here, so
// leaving it out meant a session opened after a restart ran on host tools
// and the deployment persona. Resolved from the LOG, not the header: a
// session that switched while blank ran its turns under the newer
// composition, and the header is written once at creation. Reading the
// header here would silently undo the switch on the next restart and
// restore that history under the old tool set.
const agentFor = createApiRemoteAgentResolver(ctx, {
agentOptions,
setup: async ({ meta, events }) =>
(await composeAgent(resolveSessionPreset({ header: meta, events }))).setup,
})
/** Send one transient frame to every connected mux consumer. */
function broadcast(payload: MuxFrame): void {
@@ -1140,23 +1335,61 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async function historyStateFor(
sessionId: SessionId,
includeProjections: boolean,
): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
const attached = ctx.sessions.get(sessionId)
if (attached !== undefined) {
const events = [...attached.events]
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
return { events, ...projections === undefined ? {} : { projections } }
return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
}
const inspected = await inspectServable(sessionId)
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
return {
header: inspected.meta,
events: inspected.events,
...projections === undefined ? {} : { projections },
}
}
/**
* The registry view scope a transcript's presenters resolve in.
*
* A live agent is that scope itself (its chain passes through its preset's
* standing layer). A cold session names its preset on the header, and the
* preset's STANDING key serves without resuming anything — ensuring the
* mount composes plugins but starts no agent, session, or turn. No roster,
* no recorded preset, or a preset the roster no longer supplies all fall
* back to the global layer: the transcript still serves, with the generic
* cards a viewless entry renders.
* @param sessionId - the transcript being read.
* @param header - that session's header (attached or inspected).
* @returns the scope to pass to presenter lookups, or undefined for global.
*/
async function presenterScopeFor(sessionId: SessionId, header: SessionHeader): Promise<ScopeKey | undefined> {
const live = ctx.get('agents')?.get(sessionId)
if (live !== undefined) return live
const presets = ctx.get('agentPresets')
if (presets === undefined) return undefined
try {
// An unrecorded preset (a log from before the roster existed) renders
// through the DEFAULT preset's standing layer: that is the composition
// an unnamed session composes today, and presenters are pure display,
// so the worst a mismatch produces is the generic card it had anyway.
return await presets.standingKeyFor(header.agentPreset)
} catch {
// Swallows only the unknown/unusable-preset rejection from the roster:
// a deleted or broken preset must degrade this read, never fail it.
return undefined
}
}
/** Resolve one requested identity to a live agent, creating or resuming it once. */
async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
async function ensureSession(
sessionId: SessionId,
cwd: string,
checkPersistedIdentity: boolean,
presetId?: string,
): Promise<Agent> {
let creation = sessionCreations.get(sessionId)
if (creation === undefined) {
creation = (async () => {
@@ -1182,10 +1415,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
if (inspected.meta.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
}
// Resolved from the log, not the header: a session that switched
// while blank ran every turn under the newer composition.
const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
assertPresetUnchanged(sessionId, presetId, storedPreset)
// The stored preset wins over anything the request names: a resumed
// session's history was produced under that composition, and
// rebuilding it differently would replay tool calls the model can no
// longer make.
return (await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: agentOptions(),
setup: installSelection,
setup: (await composeAgent(storedPreset)).setup,
})).agent
}
@@ -1194,11 +1435,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} catch (error: unknown) {
throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
}
const composition = await composeAgent(presetId)
return (await ctx.agents.create({
sessionId,
agentOptions: agentOptions(),
meta: { cwd },
setup: installSelection,
meta: {
cwd,
...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
},
setup: composition.setup,
})).agent
})().catch((error: unknown) => {
// Another Host entry path may have published the same identity while
@@ -1220,6 +1465,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
const agent = await creation
if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
// Beside the cwd check for the same reason, and after the await so it
// covers every path that yields a live agent — freshly created, adopted
// live, resumed from disk, or recovered by the concurrent-creation catch.
assertPresetUnchanged(sessionId, presetId, agent.session.header.agentPreset)
if (agent.session.header.cwd !== cwd) {
throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
}
@@ -1311,11 +1560,20 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return items
}
/** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
const goals = ctx.get('goals')
/**
* Resolve the goal service THIS agent runs.
*
* The service is per session: an agent preset mounts it behind an `isolate`
* realm, which no host context resolves. Reading it from the root would
* answer "absent" for a session whose composition mounts it — so the lookup
* is keyed by the agent, and only a deployment composing it nowhere is
* genuinely absent.
*/
function goalServiceFor(agent: Agent): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
const presets = ctx.get('agentPresets')
const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals')
if (goals === undefined) {
return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } }
}
return goals
}
@@ -1331,10 +1589,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
request: RpcRequest<{ sessionId: SessionId }>,
mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
): Promise<RpcResponse<{ ref: GoalRef }>> {
const goals = goalService()
if ('error' in goals) return err(request, goals.error)
const found = await agentFor(request.payload.sessionId)
if ('error' in found) return err(request, found.error)
const goals = goalServiceFor(found.agent)
if ('error' in goals) return err(request, goals.error)
try {
const ref = mutation(goals, found.agent)
return ok(request, { ref: { id: ref.id, revision: ref.revision } })
@@ -1431,6 +1689,13 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return openTarget(request, path, signal, open)
}
/** Whether this deployment can hand a path to a native opener at all. */
function canOpenPaths(): boolean {
if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
// An injected opener is by definition usable; otherwise ask the platform.
return defaults.openPath !== undefined || canOpenNativePath()
}
/** Missing-service report shared by the credentials domain. */
function credentialsAbsent(): RpcError {
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
@@ -1689,9 +1954,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
}
const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
const requestedPreset = request.payload.agentPreset
try {
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
} catch (error: unknown) {
if (error instanceof AgentPresetConflict) {
return err(request, {
code: 'agent-preset-conflict',
message: error.message,
details: {
sessionId: error.sessionId,
requestedPreset: error.requestedPreset,
...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset },
},
})
}
const refused = presetFailure(request, error)
if (refused !== undefined) return refused
if (error instanceof SessionCwdConflict) {
return err(request, {
code: 'session-conflict',
@@ -1723,12 +2002,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
}
return ok(request, { sessionId })
// Echo the RESOLVED composition so a client can label the session it
// just created without waiting for the next list refresh — the create
// is the commit point that knows it (a caller that named none gets
// the default the header recorded).
const created = ctx.agents.get(sessionId)
const createdPreset = created?.session.header.agentPreset
return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
},
async history(request) {
const { sessionId, beforeSeq, maxMessages } = request.payload
let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
try {
state = await historyStateFor(sessionId, beforeSeq === undefined)
} catch (error: unknown) {
@@ -1741,7 +2026,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
details: {},
})
}
const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state.header))
return ok(request, {
events: page.events,
hasMore: page.hasMore,
@@ -1893,6 +2178,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
})
}
const childId = `session-${randomUUID()}` as SessionId
// The child inherits the parent's composition for the same reason a
// resumed session keeps its own: the seeded history was produced under
// those tools, and composing anything else would strand the tool calls
// it already carries. Now that no model-facing row sits in the host
// plane, composing nothing would leave the child with no tools at all.
const forkComposition = await composeAgent(resolveSessionPreset(source))
try {
await ctx.agents.create({
sessionId: childId,
@@ -1901,9 +2192,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
parentSession: source.id,
seedLength: cut,
...forkComposition.agentPreset === undefined
? {}
: { agentPreset: forkComposition.agentPreset },
},
agentOptions: agentOptions(),
setup: installSelection,
setup: forkComposition.setup,
})
} catch (error: unknown) {
return err(request, {
@@ -2558,10 +2852,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async clear(request) {
const goals = goalService()
if ('error' in goals) return err(request, goals.error)
const found = await agentFor(request.payload.sessionId)
if ('error' in found) return err(request, found.error)
const goals = goalServiceFor(found.agent)
if ('error' in goals) return err(request, goals.error)
try {
goals.clear(found.agent, request.payload.ref)
return ok(request, { cleared: true as const })
@@ -2571,10 +2865,154 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
},
agentPresets: {
// A deployment with no roster answers with an empty list rather than an
// error: composing no presets is a valid deployment, and the browser
// simply offers no choice.
async list(request) {
const presets = ctx.get('agentPresets')
if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false })
const defaultId = presets.defaultId
return ok(request, {
presets: (await presets.list()).map(preset => ({
id: preset.id,
trust: preset.trust,
isDefault: preset.id === defaultId,
...preset.name === undefined ? {} : { name: preset.name },
...preset.description === undefined ? {} : { description: preset.description },
...preset.broken === undefined ? {} : { broken: preset.broken },
})),
authorable: presets.authorable,
hasDocument: canOpenPaths(),
})
},
// Recomposing is limited to a blank session because a started
// conversation's history was produced under its preset's tools; the
// agent and the session survive, only the composition is swapped.
async select(request) {
const { sessionId, agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) {
return err(request, {
code: 'agent-preset-not-found',
message: 'this deployment composes no agent presets',
details: { agentPreset, available: [] },
})
}
const found = await agentFor(sessionId)
if ('error' in found) return err(request, found.error)
const { agent } = found
const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => {
// Re-read inside the queue: an earlier switch may have run, and a
// conversation may have started, since this request arrived.
if (!sessionBlank(agent.session)) {
return err(request, {
code: 'agent-preset-locked',
message: `session "${sessionId}" has already started; its agent preset is fixed`,
details: { sessionId, agentPreset },
})
}
try {
const preset = await presets.recompose(agent.ctx, agentPreset)
// Recorded only after the swap committed: the log states what the
// agent runs, and a rejected mount leaves the previous composition.
agent.session.append('agent-preset/selected', { agentPreset: preset.id })
return ok(request, { agentPreset: preset.id })
} catch (error: unknown) {
const refused = presetFailure(request, error)
if (refused !== undefined) return refused
return err(request, {
code: 'internal',
message: `failed to select agent preset "${agentPreset}": ${String(error)}`,
details: {},
})
}
}
const queued = presetSwitches.get(sessionId) ?? Promise.resolve()
const turn = queued.then(swap)
presetSwitches.set(sessionId, turn.catch(() => undefined))
try {
return await turn
} finally {
if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId)
}
},
// Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection):
// a composition names the plugins a session runs, so reading one is
// reconnaissance, and copy/remove/openDocument manage the roster and
// drive the host desktop.
async read(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
const preset = await presets.resolve(agentPreset)
return ok(request, {
agentPreset: preset.id,
trust: preset.trust,
content: await presets.read(preset.id),
...preset.name === undefined ? {} : { name: preset.name },
...preset.description === undefined ? {} : { description: preset.description },
})
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async copy(request) {
const { from, agentPreset, name } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
await presets.copy(from, agentPreset, name)
return ok(request, { agentPreset })
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async openDocument(request, signal) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
const preset = await presets.resolve(agentPreset)
// Same line as copy/remove draw: the shipped install is not the
// user's to manage, and pointing an editor into it invites edits an
// upgrade will silently overwrite.
if (preset.trust !== 'user') {
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
}
// The id resolved against the Host's own roots is what selects the
// directory — no browser payload carries a path in either direction
// unless the deployment has no opener to hand it to.
const directory = dirname(preset.path)
if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
return await openPath(request, directory, signal)
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
async remove(request) {
const { agentPreset } = request.payload
const presets = ctx.get('agentPresets')
if (presets === undefined) return err(request, noRoster(agentPreset))
try {
await presets.remove(agentPreset)
return ok(request, {})
} catch (error: unknown) {
return err(request, presetError(agentPreset, error))
}
},
},
skills: {
// Skill lookup never touches the Agent registry: the session address
// resolves to a canonical cwd from the host-resident session header, so
// listing skills cannot create or resume an agent as a side effect.
// Skill lookup never creates or resumes an agent: the session address
// resolves to a canonical cwd from the host-resident session header, and
// the view scope is the live agent or the preset's standing key.
async list(request) {
const { sessionId } = request.payload
const session = ctx.sessions.get(sessionId)
@@ -2591,17 +3029,27 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
}
const cwd = session.header.cwd
// Same stance as the commands domain: a missing service means the
// deployment omitted dsh-skill from its composition, not an empty
// catalog. ctx.get also keeps this handler independent of the gateway
// plugin's inject list (an undeclared `ctx.skills` property read
// fails the reflect proxy).
const skillRegistry = ctx.get('skills')
// The host registry is layered per scope and serves every session. A
// composition may still realm-mount its own registry instead; that
// instance is invisible to host contexts, so address it through the
// live agent (`agents.get` keeps the no-side-effect stance above).
const live = ctx.agents.get(sessionId)
const presets = ctx.get('agentPresets')
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
// Same stance as the commands domain: a missing service means no
// composition mounts dsh-skill, not an empty catalog. `ctx.get` also
// keeps this handler independent of the gateway plugin's inject list
// (an undeclared `ctx.skills` property read fails the reflect proxy).
const skillRegistry = scoped ?? ctx.get('skills')
if (skillRegistry === undefined) {
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: {} })
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
}
// The scope presenters resolve in — the live agent, else the recorded
// preset's standing key, else the global layer — so a cold session's
// '/' popup lists the catalog its composition actually serves.
const scope = await presenterScopeFor(sessionId, session.header)
try {
const skills = (await skillRegistry.list({ cwd })).filter(isUserInvocable)
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
return ok(request, {
skills: skills.map(skill => ({
name: skill.name,
@@ -2831,8 +3279,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
} else if (event.type === 'turn/end') {
openCalls.delete(session.id)
}
const view = viewFor(ctx, event, callId =>
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
const view = viewFor(
ctx, event,
callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId),
ctx.agents.get(session.id),
)
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
}),
ctx.on('session/created', (session: Session) => {
@@ -2866,7 +3317,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
// has run no turn yet, so this is constantly true in practice.
blank: sessionBlank(session),
// Including cwd lets the client group the new session without refreshing the list.
...sessionListFields(session.header),
...sessionListFields(session.header, session.events),
}))
}),
ctx.on('session/disposed', (session: Session) => {