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:
@@ -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) => {
|
||||
|
||||
88
packages/host/apiproxy/src/api/agent-presets.schema.ts
Normal file
88
packages/host/apiproxy/src/api/agent-presets.schema.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* agent-presets domain zod schemas (names derived from map keys:
|
||||
* agentPresetListRequestSchema / agentPresetListValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
import type { AgentPresetEntry } from './agent-presets.ts'
|
||||
|
||||
/** AgentPresetEntry row of agentPreset.list. */
|
||||
export const agentPresetEntrySchema = z.object({
|
||||
id: z.string().min(1),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
isDefault: z.boolean(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
broken: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<AgentPresetEntry>>
|
||||
|
||||
/** agentPreset.list request payload. */
|
||||
export const agentPresetListRequestSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.list response value. */
|
||||
export const agentPresetListValueSchema = z.object({
|
||||
presets: z.array(agentPresetEntrySchema),
|
||||
authorable: z.boolean(),
|
||||
hasDocument: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.list'>>>
|
||||
|
||||
/** agentPreset.select request payload. */
|
||||
export const agentPresetSelectRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.select response value. */
|
||||
export const agentPresetSelectValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.select'>>>
|
||||
|
||||
/** agentPreset.read request payload. */
|
||||
export const agentPresetReadRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.read response value. */
|
||||
export const agentPresetReadValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
trust: z.union([z.literal('system'), z.literal('user')]),
|
||||
content: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.read'>>>
|
||||
|
||||
/** agentPreset.copy request payload. */
|
||||
export const agentPresetCopyRequestSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
agentPreset: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.copy'>>>
|
||||
|
||||
/** agentPreset.copy response value. */
|
||||
export const agentPresetCopyValueSchema = z.object({
|
||||
agentPreset: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.copy'>>>
|
||||
|
||||
/** agentPreset.openDocument request payload. */
|
||||
export const agentPresetOpenDocumentRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.openDocument'>>>
|
||||
|
||||
/** agentPreset.openDocument response value. */
|
||||
export const agentPresetOpenDocumentValueSchema = z.union([
|
||||
z.object({ opened: z.literal(true) }),
|
||||
z.object({ opened: z.literal(false), path: z.string() }),
|
||||
]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
|
||||
|
||||
/** agentPreset.remove request payload. */
|
||||
export const agentPresetRemoveRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.remove'>>>
|
||||
|
||||
/** agentPreset.remove response value. */
|
||||
export const agentPresetRemoveValueSchema = z.object({
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.remove'>>>
|
||||
116
packages/host/apiproxy/src/api/agent-presets.ts
Normal file
116
packages/host/apiproxy/src/api/agent-presets.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* agent-presets domain contract: the roster a browser offers when starting a
|
||||
* session, plus the authoring calls behind it.
|
||||
*
|
||||
* `list` is ordinary: it carries ids and trust, and every preset picker needs
|
||||
* it. The authoring calls are privileged and loopback-pinned — a composition
|
||||
* names the plugins a session runs, so reading one is reconnaissance, and
|
||||
* although authoring is copy-only (no caller supplies composition text or a
|
||||
* path), copying and deleting still rearrange what the deployment offers.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** One preset the deployment can compose a session's agent from. */
|
||||
export interface AgentPresetEntry {
|
||||
/** Stable identifier, also the display name until presets carry metadata. */
|
||||
readonly id: string
|
||||
/**
|
||||
* Whether the preset ships with the deployment or was authored locally.
|
||||
* A `user` preset is exactly as privileged as the plugins it names, so a
|
||||
* surface offering one should say so rather than present it as vetted.
|
||||
*/
|
||||
readonly trust: 'system' | 'user'
|
||||
/** Whether a session that names no preset gets this one. */
|
||||
readonly isDefault: boolean
|
||||
/**
|
||||
* Display name the preset published, absent when it published none. A
|
||||
* surface falls back to {@link id}; it is never a second identity, and it
|
||||
* never decides trust — a locally authored preset cannot name itself into
|
||||
* the shipped set.
|
||||
*/
|
||||
readonly name?: string
|
||||
/** One sentence on what the preset is for, when it published one. */
|
||||
readonly description?: string
|
||||
/**
|
||||
* Why this preset cannot compose a session, absent when it can. A broken
|
||||
* preset stays listed — its directory still occupies the id, so a surface
|
||||
* must be able to show and delete it — but offering it for selection would
|
||||
* only defer this reason to a failed session start.
|
||||
*/
|
||||
readonly broken?: string
|
||||
}
|
||||
|
||||
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
||||
export interface AgentPresetsApi {
|
||||
/**
|
||||
* Lists every preset the deployment currently supplies, in root-precedence
|
||||
* order — the roots as configured, each root's own presets sorted by id,
|
||||
* and the first root to supply an id wins. The order is not globally
|
||||
* sorted: a user root's preset sits in that root's block, not among the
|
||||
* shipped ids.
|
||||
* An empty roster means the deployment composes no presets at all, and
|
||||
* every session shares the host composition. `authorable` reports whether
|
||||
* the deployment configures a root new presets can be written to, and
|
||||
* `hasDocument` whether `openDocument` can hand a preset directory to a
|
||||
* native opener — both deployment facts rather than per-preset ones, and
|
||||
* neither exposes a Host path.
|
||||
*/
|
||||
list(request: RpcRequest<{}>):
|
||||
Promise<RpcResponse<{ presets: readonly AgentPresetEntry[]; authorable: boolean; hasDocument: boolean }>>
|
||||
|
||||
/**
|
||||
* Recompose one session's agent from a different preset.
|
||||
*
|
||||
* Allowed only while the session is blank — no turn has run. Once a
|
||||
* conversation starts, its history was produced under that preset's tools,
|
||||
* and swapping them would leave logged tool calls the new composition cannot
|
||||
* make; the attempt answers `agent-preset-locked`.
|
||||
*/
|
||||
select(request: RpcRequest<{ sessionId: SessionId; agentPreset: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Read one preset's composition text, for the read-only viewer.
|
||||
*
|
||||
* Privileged: a composition names the plugins a session runs, so reading
|
||||
* one is reconnaissance.
|
||||
*/
|
||||
read(request: RpcRequest<{ agentPreset: string }>):
|
||||
Promise<RpcResponse<{
|
||||
agentPreset: string
|
||||
trust: 'system' | 'user'
|
||||
content: string
|
||||
name?: string
|
||||
description?: string
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Create a locally authored preset by copying an existing one whole.
|
||||
*
|
||||
* The only authoring write. No composition text and no path crosses the
|
||||
* wire: `from` and `agentPreset` are ids the Host resolves against its own
|
||||
* roots, so a copy is exactly as loadable as its source and grants nothing
|
||||
* the roster did not already carry. The copy keeps the source's description
|
||||
* (the file is the author's to edit afterwards) but not its name — `name`
|
||||
* here or the id fallback is what distinguishes the rows.
|
||||
*/
|
||||
copy(request: RpcRequest<{ from: string; agentPreset: string; name?: string }>):
|
||||
Promise<RpcResponse<{ agentPreset: string }>>
|
||||
|
||||
/**
|
||||
* Hand one locally authored preset's DIRECTORY to the platform opener, for
|
||||
* editing the files that are now the only composition editor. The request
|
||||
* carries an id, never a path — the Host resolves it — so no browser
|
||||
* payload can select an arbitrary filesystem target. Where the deployment
|
||||
* has no native opener (`hasDocument: false` on `list`), the reply carries
|
||||
* the resolved directory for the surface to show as text instead. Shipped
|
||||
* presets are refused: their install is not the user's to manage.
|
||||
*/
|
||||
openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
|
||||
|
||||
/** Delete a locally authored preset. Shipped presets are refused. */
|
||||
remove(request: RpcRequest<{ agentPreset: string }>): Promise<RpcResponse<{}>>
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
}),
|
||||
z.object({ type: z.literal('host/session-removed'), sessionId: sessionIdSchema }),
|
||||
z.object({ type: z.literal('host/session-status'), sessionId: sessionIdSchema, running: z.boolean() }),
|
||||
|
||||
@@ -116,6 +116,7 @@ export type HostFrame =
|
||||
parentSessionId?: SessionId
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
agentPreset?: string
|
||||
}
|
||||
| { type: 'host/session-removed'; sessionId: SessionId }
|
||||
| { type: 'host/session-status'; sessionId: SessionId; running: boolean }
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SubagentsApi } from './subagents.ts'
|
||||
import type { EventsApi } from './events.ts'
|
||||
@@ -25,6 +26,7 @@ export interface ApiProxy {
|
||||
workspace: WorkspaceApi
|
||||
commands: CommandsApi
|
||||
skills: SkillsApi
|
||||
agentPresets: AgentPresetsApi
|
||||
events: EventsApi
|
||||
goals: GoalsApi
|
||||
settings: SettingsApi
|
||||
@@ -49,6 +51,7 @@ export type {
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { AgentPresetsApi, AgentPresetEntry } from './agent-presets.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, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SessionsApi } from './sessions.ts'
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { WorkspaceApi } from './workspace.ts'
|
||||
import type { CommandsApi } from './commands.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
@@ -52,6 +53,12 @@ export interface RpcMethodMap {
|
||||
'command.list': CommandsApi['list']
|
||||
'command.execute': CommandsApi['execute']
|
||||
'skill.list': SkillsApi['list']
|
||||
'agentPreset.list': AgentPresetsApi['list']
|
||||
'agentPreset.select': AgentPresetsApi['select']
|
||||
'agentPreset.read': AgentPresetsApi['read']
|
||||
'agentPreset.copy': AgentPresetsApi['copy']
|
||||
'agentPreset.openDocument': AgentPresetsApi['openDocument']
|
||||
'agentPreset.remove': AgentPresetsApi['remove']
|
||||
'goal.create': GoalsApi['create']
|
||||
'goal.edit': GoalsApi['edit']
|
||||
'goal.pause': GoalsApi['pause']
|
||||
|
||||
@@ -46,6 +46,11 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
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-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-preset-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedPreset: z.string(), existingPreset: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
|
||||
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('attachment-error'), 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() }) }),
|
||||
|
||||
@@ -44,6 +44,11 @@ export interface RpcErrorDetailsMap {
|
||||
'directory-exists': { path: string }
|
||||
'directory-create-failed': { path: string }
|
||||
'directory-picker-unavailable': { capability: string }
|
||||
'agent-preset-read-only': { agentPreset: string; reason: string }
|
||||
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
|
||||
'agent-preset-conflict': { sessionId: SessionId; requestedPreset: string; existingPreset?: string }
|
||||
'agent-preset-not-found': { agentPreset: string; available: string[] }
|
||||
'agent-preset-invalid': { agentPreset: string; reason: string }
|
||||
'agent-busy': { reason: string }
|
||||
'attachment-error': { reason: string }
|
||||
'queue-item-not-found': { itemId: MessageId }
|
||||
|
||||
@@ -56,6 +56,7 @@ export const sessionSummarySchema = z.object({
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
|
||||
@@ -101,6 +102,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
workspaceId: workspaceIdSchema.optional(),
|
||||
cwd: z.string().optional(),
|
||||
sessionId: sessionIdSchema.optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
}).refine(
|
||||
payload => payload.workspaceId === undefined || payload.cwd === undefined,
|
||||
{ message: 'session.create accepts workspaceId or cwd, not both' },
|
||||
@@ -109,6 +111,7 @@ export const sessionCreateRequestSchema = z.object({
|
||||
/** session.create response value. */
|
||||
export const sessionCreateValueSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
agentPreset: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'session.create'>>>
|
||||
|
||||
/** session.rename request payload (raw title; host-side normalization decides acceptance). */
|
||||
|
||||
@@ -171,6 +171,13 @@ export interface SessionSummary {
|
||||
origin?: 'subagent'
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from (header passthrough);
|
||||
* absent when the deployment composes no presets. A surface offering a
|
||||
* switch reads this to show what the session actually runs rather than what
|
||||
* the deployment currently defaults to.
|
||||
*/
|
||||
agentPreset?: string
|
||||
/**
|
||||
* Projection baseline for this row, with zero log loads: attached sessions
|
||||
* read the registry's live watermark cut; cold sessions read the persisted
|
||||
@@ -214,9 +221,16 @@ export interface SessionsApi {
|
||||
* session, while a different cwd fails with `session-conflict`. Workspace
|
||||
* creation attaches the session after publication; an attach failure
|
||||
* returns `workspace-attach-failed` with the published session id.
|
||||
*
|
||||
* `agentPreset` names the composition the new session's agent is built
|
||||
* from; omitted, the effective default applies — the user's stored choice
|
||||
* where one exists, else the deployment's own. The resolved id is stored on
|
||||
* the session header, so a later resume rebuilds the same agent. An unknown
|
||||
* id fails with `agent-preset-not-found`, and a preset whose composition
|
||||
* cannot be mounted fails with `agent-preset-invalid`.
|
||||
*/
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId }>>
|
||||
create(request: RpcRequest<{ workspaceId?: WorkspaceId; cwd?: string; sessionId?: SessionId; agentPreset?: string }>):
|
||||
Promise<RpcResponse<{ sessionId: SessionId; agentPreset?: string }>>
|
||||
|
||||
/**
|
||||
* Reads a window of history events; page boundaries align to append-origin message
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteValueSchema, commandListValueSchema } from '../api/commands.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetCopyValueSchema, agentPresetListValueSchema, agentPresetOpenDocumentValueSchema,
|
||||
agentPresetReadValueSchema, agentPresetRemoveValueSchema, agentPresetSelectValueSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateValueSchema,
|
||||
goalEditValueSchema,
|
||||
@@ -123,6 +127,14 @@ export interface IApiClient {
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
agentPresets: {
|
||||
list(payload: RequestPayload<'agentPreset.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.list'>>>
|
||||
select(payload: RequestPayload<'agentPreset.select'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.select'>>>
|
||||
read(payload: RequestPayload<'agentPreset.read'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.read'>>>
|
||||
copy(payload: RequestPayload<'agentPreset.copy'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.copy'>>>
|
||||
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
|
||||
remove(payload: RequestPayload<'agentPreset.remove'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.remove'>>>
|
||||
}
|
||||
events: {
|
||||
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
|
||||
host(payload: Parameters<ApiProxy['events']['host']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<HostFrame>>
|
||||
@@ -191,6 +203,12 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
|
||||
'command.list': commandListValueSchema,
|
||||
'command.execute': commandExecuteValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'agentPreset.list': agentPresetListValueSchema,
|
||||
'agentPreset.select': agentPresetSelectValueSchema,
|
||||
'agentPreset.read': agentPresetReadValueSchema,
|
||||
'agentPreset.copy': agentPresetCopyValueSchema,
|
||||
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
|
||||
'agentPreset.remove': agentPresetRemoveValueSchema,
|
||||
'goal.create': goalCreateValueSchema,
|
||||
'goal.edit': goalEditValueSchema,
|
||||
'goal.pause': goalPauseValueSchema,
|
||||
@@ -451,6 +469,20 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
// Annotated like every sibling, and load-bearing rather than cosmetic:
|
||||
// inferring this member inlines `AgentPresetEntry` into the emitted
|
||||
// declaration by the specifier TS picks — the host `index.ts` — which drags
|
||||
// the whole gateway, and with it the host `Context` merges, into every
|
||||
// Client program that imports this carrier.
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload, signal) => this.callUnary('agentPreset.list', payload, signal),
|
||||
select: (payload, signal) => this.callUnary('agentPreset.select', payload, signal),
|
||||
read: (payload, signal) => this.callUnary('agentPreset.read', payload, signal),
|
||||
copy: (payload, signal) => this.callUnary('agentPreset.copy', payload, signal),
|
||||
openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
|
||||
remove: (payload, signal) => this.callUnary('agentPreset.remove', payload, signal),
|
||||
}
|
||||
|
||||
readonly goals: IApiClient['goals'] = {
|
||||
create: (payload, signal) => this.callUnary('goal.create', payload, signal),
|
||||
edit: (payload, signal) => this.callUnary('goal.edit', payload, signal),
|
||||
|
||||
@@ -43,6 +43,10 @@ import {
|
||||
} from '../api/workspace.schema.ts'
|
||||
import { commandExecuteRequestSchema, commandListRequestSchema } from '../api/commands.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetCopyRequestSchema, agentPresetListRequestSchema, agentPresetOpenDocumentRequestSchema,
|
||||
agentPresetReadRequestSchema, agentPresetRemoveRequestSchema, agentPresetSelectRequestSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
goalCreateRequestSchema,
|
||||
goalEditRequestSchema,
|
||||
@@ -113,6 +117,12 @@ const UNARY_ROUTES: UnaryRoutes = {
|
||||
'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) },
|
||||
'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'agentPreset.list': { schema: agentPresetListRequestSchema, invoke: (api, r) => api.agentPresets.list(r) },
|
||||
'agentPreset.select': { schema: agentPresetSelectRequestSchema, invoke: (api, r) => api.agentPresets.select(r) },
|
||||
'agentPreset.read': { schema: agentPresetReadRequestSchema, invoke: (api, r) => api.agentPresets.read(r) },
|
||||
'agentPreset.copy': { schema: agentPresetCopyRequestSchema, invoke: (api, r) => api.agentPresets.copy(r) },
|
||||
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
|
||||
'agentPreset.remove': { schema: agentPresetRemoveRequestSchema, invoke: (api, r) => api.agentPresets.remove(r) },
|
||||
'goal.create': { schema: goalCreateRequestSchema, invoke: (api, r) => api.goals.create(r) },
|
||||
'goal.edit': { schema: goalEditRequestSchema, invoke: (api, r) => api.goals.edit(r) },
|
||||
'goal.pause': { schema: goalPauseRequestSchema, invoke: (api, r) => api.goals.pause(r) },
|
||||
|
||||
@@ -38,6 +38,14 @@ declare module 'cordis' {
|
||||
export interface Config {
|
||||
/** Parent directory for name-created Workspaces; defaults to the Host cwd. */
|
||||
workspaceRoot?: string
|
||||
/**
|
||||
* Whether this deployment can hand paths to a native desktop opener —
|
||||
* the `hasDocument` capability the agent-preset roster reports. Absent,
|
||||
* the platform is asked (macOS/Windows/WSL yes; Linux only with a display
|
||||
* server); set it explicitly where detection misleads, e.g. `false` in a
|
||||
* container whose DISPLAY points nowhere a user can see.
|
||||
*/
|
||||
nativeOpen?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +61,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
workspaceRoot: z.string(),
|
||||
nativeOpen: z.boolean(),
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
@@ -62,6 +71,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly commands: ApiProxy['commands']
|
||||
readonly goals: ApiProxy['goals']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly agentPresets: ApiProxy['agentPresets']
|
||||
readonly settings: ApiProxy['settings']
|
||||
readonly credentials: ApiProxy['credentials']
|
||||
readonly llm: ApiProxy['llm']
|
||||
@@ -76,6 +86,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
|
||||
cwd,
|
||||
workspaceRoot: resolve(config.workspaceRoot ?? cwd),
|
||||
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
@@ -84,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.commands = api.commands
|
||||
this.goals = api.goals
|
||||
this.skills = api.skills
|
||||
this.agentPresets = api.agentPresets
|
||||
this.settings = api.settings
|
||||
this.credentials = api.credentials
|
||||
this.llm = api.llm
|
||||
|
||||
@@ -152,6 +152,25 @@ async function openNativePathWithIntent(
|
||||
throw new Error(`native path opener is unsupported on ${platform}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link openNativePath} plausibly reaches a desktop on this host.
|
||||
*
|
||||
* macOS and Windows always carry a desktop opener; Linux does when it is WSL
|
||||
* (the Windows desktop takes the path) or a display server is announced.
|
||||
* A headless or containerised Linux host answers false, which is what lets a
|
||||
* surface show a path as text instead of offering a button that would spawn
|
||||
* `xdg-open` into nothing.
|
||||
* @param internals - platform and environment seam for deterministic tests.
|
||||
* @returns true when handing a path to the native opener can work at all.
|
||||
*/
|
||||
export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean {
|
||||
const platform = internals.platform ?? process.platform
|
||||
if (platform === 'darwin' || platform === 'win32') return true
|
||||
if (platform !== 'linux') return false
|
||||
const env = internals.env ?? process.env
|
||||
return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application, or
|
||||
* with the default browser when the path names a document a browser renders.
|
||||
|
||||
Reference in New Issue
Block a user