refactor(api): expose remaining domain remotes

This commit is contained in:
imccyu
2026-08-27 21:57:55 +08:00
parent 12efd638d4
commit 2d4393d842
21 changed files with 821 additions and 92 deletions
@@ -85,15 +85,18 @@
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-file-reference": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
@@ -116,9 +119,11 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-file-reference": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -126,6 +131,7 @@
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
@@ -0,0 +1,42 @@
/** Session Controller adapter for Agent-scoped file-reference discovery. */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-file-reference'
import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host owner of the `fileReferences` Remote namespace. */
sessionFileReferences: SessionFileReferences
}
}
/** Host Remote adapter over the composed file-reference provider. */
export class SessionFileReferences extends TypertRemoteService {
static inject = ['fileReferences', 'typert']
/** @param ctx - Host context carrying the selected file-reference provider. */
constructor(ctx: Context) {
super(ctx, 'sessionFileReferences', { namespace: 'fileReferences' })
}
/**
* List file and directory candidates for one Agent's working directory.
* @param agent - target Agent resolved from the Session identity on the wire.
* @param query - path text following `@` or `@"`.
* @param signal - caller cancellation.
* @returns deterministic path-only candidates from the composed provider.
*/
@Remote
list(
agent: Agent,
query: string,
signal: AbortSignal,
): Promise<FileReferenceCandidate[]> {
return this.ctx.fileReferences.list(agent, query, signal)
}
}
export default SessionFileReferences
+92 -3
View File
@@ -3,10 +3,13 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import {
ApiSessionNotFound,
ApiSessionAgentController,
inspectApiSession,
type ApiSessionAgentResult,
@@ -14,9 +17,13 @@ import {
import { SessionCommandController } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { SessionFileReferences } from './file-references.ts'
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
import { buildModelCatalog } from './catalog.ts'
import { installModelSelectionProjection } from './model-selection-projection.ts'
import { SessionSkillCatalog } from './skill-catalog.ts'
import type {
ModelCatalog,
SessionAttachmentRequest,
SessionAttachmentValue,
SessionCancelRequest,
@@ -30,6 +37,8 @@ import type {
SessionForkValue,
SessionListRequest,
SessionListValue,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionPage,
SessionPageRequest,
SessionPromptRequest,
@@ -46,6 +55,8 @@ import type {
export type * from './types.ts'
export { ApiSessionNotFound } from './agent.ts'
export { SessionFileReferences } from './file-references.ts'
export { SessionSkillCatalog } from './skill-catalog.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -60,6 +71,12 @@ export interface Config {
readonly coldBlankProbeMaxBytes?: number
}
/** Host integrations replaceable by direct unit tests. */
export interface SessionControllerInternals {
/** Native default-application handoff. */
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
}
/** Host service backing the generated `ctx.remote.session` namespace. */
export class SessionController extends TypertRemoteService {
static inject = [
@@ -83,13 +100,14 @@ export class SessionController extends TypertRemoteService {
private readonly controlState: SessionControlController
private readonly history: SessionHistoryController
private readonly listState: ApiSessionList
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly promotions = new Set<Promise<void>>()
/**
* @param ctx - Host context containing the Session capability assembly.
* @param config - cold-list observation policy.
*/
constructor(ctx: Context, config: Config) {
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
super(ctx, 'sessionController', { namespace: 'session' })
installModelSelectionProjection(ctx)
this.agents = new ApiSessionAgentController(ctx)
@@ -105,6 +123,9 @@ export class SessionController extends TypertRemoteService {
ctx,
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
)
this.openPath = internals.openPath ?? openNativePath
ctx.plugin(SessionFileReferences)
ctx.plugin(SessionSkillCatalog)
ctx.on('session/created', (session) => {
ctx.emit('api-session/added', this.listState.summaryFor(session))
@@ -214,6 +235,74 @@ export class SessionController extends TypertRemoteService {
return this.commands.selectModel(request)
}
/**
* Describe every currently routable model for Host-generation selectors.
* @returns provider-grouped models, the deployment default, and isolated provider failures.
*/
@Remote('modelCatalog')
modelCatalog(): Promise<ModelCatalog> {
return buildModelCatalog(this.ctx)
}
/**
* Open a path resolved against one Session's workspace on the Host desktop.
* @param request - Session identity and absolute or workspace-relative path.
* @param signal - caller lifetime; abort terminates inspection or the native command.
* @returns confirmation after the native opener accepts the path.
* @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails.
*/
@Remote('openWorkspacePath')
async openWorkspacePath(
request: SessionOpenWorkspacePathRequest,
signal: AbortSignal,
): Promise<SessionOpenWorkspacePathValue> {
if (request.path.length === 0) {
throw new TypertRemoteFailure({
code: 'bad-request',
message: 'session.openWorkspacePath requires a non-empty path',
details: {},
})
}
signal.throwIfAborted()
let cwd: string | undefined
try {
cwd = (await this.inspect(request.sessionId, signal)).meta.cwd
} catch (error: unknown) {
if (signal.aborted) {
throw new TypertRemoteFailure({
code: 'cancelled', message: 'path open was aborted', details: {},
})
}
if (error instanceof ApiSessionNotFound) {
throw new TypertRemoteFailure({
code: 'session-not-found',
message: error.message,
details: { sessionId: request.sessionId },
})
}
throw new TypertRemoteFailure({
code: 'internal',
message: `session "${request.sessionId}" could not be inspected: ${String(error)}`,
details: {},
})
}
try {
await this.openPath(resolveWorkspacePath(cwd, request.path), signal)
return { opened: true }
} catch (error: unknown) {
if (signal.aborted) {
throw new TypertRemoteFailure({
code: 'cancelled', message: 'path open was aborted', details: {},
})
}
throw new TypertRemoteFailure({
code: 'internal',
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
}
/**
* Rename one Session after explicitly resuming it.
* @param request - Session identity and proposed title.
@@ -310,5 +399,5 @@ export class SessionController extends TypertRemoteService {
}
export { buildModelCatalog } from './catalog.ts'
export { buildModelCatalog }
export default SessionController
@@ -0,0 +1,120 @@
/** Session-addressed, cold-readable skill catalog Remote. */
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent-presets/types'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { SkillListRequest, SkillListValue } from './types.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host owner of the Session-addressed `skills` Remote namespace. */
sessionSkillCatalog: SessionSkillCatalog
}
}
/** Host service backing `ctx.remote.skills` without activating a cold Agent. */
export class SessionSkillCatalog extends TypertRemoteService {
static inject = ['agents', 'sessionQuery', 'typert']
/** @param ctx - Host context carrying Session reads and optional skill/preset services. */
constructor(ctx: Context) {
super(ctx, 'sessionSkillCatalog', { namespace: 'skills' })
}
/**
* List the user-invocable skills visible to one Session composition.
* @param request - Session identity whose cwd and preset select the catalog view.
* @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics.
* @returns user-invocable skill metadata without loading skill bodies.
* @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it.
*/
@Remote
async list(request: SkillListRequest, signal: AbortSignal): Promise<SkillListValue> {
void signal
const { sessionId } = request
let cwd: string | undefined
let agentPreset: string | undefined
try {
using observation = await this.ctx.sessionQuery.observeSession(sessionId)
if (observation.projections === undefined) {
throw new Error('skill catalog requires a projected Session observation')
}
cwd = observation.header.cwd
agentPreset = observation.projections.values.agentPreset ?? undefined
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
throw failure(
'session-not-found',
`session "${sessionId}" not found`,
{ sessionId },
)
}
throw failure(
'internal',
`session "${sessionId}" could not be inspected: ${String(error)}`,
)
}
if (cwd === undefined) {
throw failure('internal', `session "${sessionId}" has no project cwd`)
}
const live = this.ctx.agents.get(sessionId)
const presets = this.ctx.get('agentPresets')
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
const skillRegistry = scoped ?? this.ctx.get('skills')
if (skillRegistry === undefined) {
throw failure(
'internal',
'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill',
)
}
const scope = await this.scopeFor(sessionId, agentPreset)
try {
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
return {
skills: skills.map(skill => ({
name: skill.name,
description: skill.description,
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
modelInvocable: skill.invocation.modelInvocable,
})),
}
} catch (error: unknown) {
throw failure('internal', `skill listing failed: ${String(error)}`)
}
}
/** Resolve a live or standing preset scope without creating an Agent. */
private async scopeFor(
sessionId: SessionId,
agentPreset: string | undefined,
): Promise<ScopeKey | undefined> {
const live = this.ctx.agents.get(sessionId)
if (live !== undefined) return live
const presets = this.ctx.get('agentPresets')
if (presets === undefined) return undefined
try {
return await presets.standingKeyFor(agentPreset)
} catch {
// An unknown or unusable recorded preset falls back to the global registry.
return undefined
}
}
}
/** Build one stable Remote failure with optional typed details. */
function failure(
code: 'session-not-found' | 'internal',
message: string,
details: { readonly sessionId: SessionId } | Record<never, never> = {},
): TypertRemoteFailure {
return new TypertRemoteFailure({ code, message, details })
}
export default SessionSkillCatalog
@@ -223,6 +223,28 @@ export type SessionError = {
}
}[keyof SessionErrorDetailsMap]
/** Session-addressed request for the human-invocable skill catalog. */
export interface SkillListRequest {
readonly sessionId: SessionId
}
/** One skill available to the Session's human-facing composer. */
export interface SkillEntry {
/** Kebab-case identifier referenced as `/name`. */
readonly name: string
/** Short routing description. */
readonly description: string
/** Optional extra routing guidance. */
readonly whenToUse?: string
/** Whether the same skill is also advertised to the model. */
readonly modelInvocable: boolean
}
/** Human-invocable skills visible through one Session's composition. */
export interface SkillListValue {
readonly skills: readonly SkillEntry[]
}
/** Session list request. */
export interface SessionListRequest {
readonly cursor?: string
@@ -340,6 +362,18 @@ export interface SessionCancelValue {
readonly accepted: true
}
/** Session-addressed request to open one workspace path on the Host desktop. */
export interface SessionOpenWorkspacePathRequest {
readonly sessionId: SessionId
/** Absolute or Session-workspace-relative path. */
readonly path: string
}
/** Confirmation that the Host handed a workspace path to its native opener. */
export interface SessionOpenWorkspacePathValue {
readonly opened: true
}
/** Client-minted prompt identity used to reconcile optimistic and durable messages. */
export type SessionRequestId = Branded<'session-request-id'>
@@ -14,9 +14,11 @@
"src/catalog.ts",
"src/commands.ts",
"src/control.ts",
"src/file-references.ts",
"src/history.ts",
"src/list.ts",
"src/model-selection-projection.ts"
"src/model-selection-projection.ts",
"src/skill-catalog.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
@@ -25,10 +27,12 @@
{ "path": "../../core/agent-default-model" },
{ "path": "../../core/scope" },
{ "path": "../../core/session" },
{ "path": "../../context/file-reference" },
{ "path": "../../attachment/attachment" },
{ "path": "../../interaction/permission-presets" },
{ "path": "../../jobs/jobs" },
{ "path": "../../llm/llm" },
{ "path": "../../util/native-command" },
{ "path": "../../preset/agent-presets" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../session/session-persistence" },
@@ -36,9 +40,11 @@
{ "path": "../../session/session-projection-cache" },
{ "path": "../../session/session-title" },
{ "path": "../../session-query/session-query" },
{ "path": "../../skill/skill" },
{ "path": "../../subagent/subagent" },
{ "path": "../../typert/protocol" },
{ "path": "../../typert/registry" },
{ "path": "../../util/workspace-path" },
{ "path": "../../workspace/workspace" }
]
}
@@ -49,22 +49,28 @@
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
}
}
+151 -1
View File
@@ -7,7 +7,20 @@
* @module @deepseek-ai/dsh-api-settings-controller
*/
import { dirname } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import {
InvalidPresetIdError,
PresetExistsError,
PresetNotWritableError,
UnknownPresetError,
} from '@deepseek-ai/dsh-agent-presets'
import {
canOpenNativePath,
openNativePath,
openNativeTextFile,
} from '@deepseek-ai/dsh-native-command'
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
import type {
@@ -17,12 +30,26 @@ import type { JsonValue } from '@deepseek-ai/dsh-session/types'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { z } from 'zod'
import { CredentialsController } from './credentials.ts'
import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts'
export { CredentialsController } from './credentials.ts'
export type * from './types.ts'
const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
/** Native document-opening policy. */
export interface Config {
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
}
/** Host integrations replaceable by direct unit tests. */
export interface SettingsControllerInternals {
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
readonly openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
readonly canOpenPath?: () => boolean
}
/**
* Project one redacted descriptor onto its wire view, field by field. The
* Gateway returns a business result without decoding it, so a provider whose
@@ -59,14 +86,24 @@ declare module '@deepseek-ai/cordis' {
* `settings-conflict` or `settings-rejected` with the service's message.
*/
export class SettingsController extends TypertRemoteService {
static Config: Schema<Config> = Schema.object({ nativeOpen: Schema.boolean() })
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly openTextFile: (path: string, signal: AbortSignal) => Promise<void>
private readonly canOpenPath: () => boolean
/**
* Register the settings namespace and mount the credentials namespace beside
* it. Both namespaces stay registered when a provider is absent so calls can
* return the configuration API's actionable missing-provider diagnostic.
* @param ctx - Host context where settings and credential providers may be mounted.
*/
constructor(ctx: Context) {
constructor(ctx: Context, config: Config = {}, internals: SettingsControllerInternals = {}) {
super(ctx, 'settingsController', { namespace: 'settings' })
this.openPath = internals.openPath ?? openNativePath
this.openTextFile = internals.openTextFile ?? openNativeTextFile
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
ctx.plugin(CredentialsController)
}
@@ -139,6 +176,81 @@ export class SettingsController extends TypertRemoteService {
return this.write(ns, 'mutate', ops, expectedRevision)
}
/**
* Materialize the provider-owned settings document and open it in a native text editor.
* @param signal - caller lifetime; abort terminates preparation or the native command.
* @returns confirmation after the native opener accepts the document.
* @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails.
*/
@Remote
async openSettingsDocument(signal: AbortSignal): Promise<SettingsDocumentOpenValue> {
const settings = this.provider()
if (signal.aborted) throw cancelled('settings document open was aborted')
let path: string | undefined
try {
path = await settings.prepareDocument()
} catch (error: unknown) {
if (signal.aborted) throw cancelled('settings document preparation was aborted')
throw internal(`settings document preparation failed: ${messageOf(error)}`)
}
if (path === undefined) {
throw internal('settings provider has no local document to open')
}
if (signal.aborted) throw cancelled('settings document open was aborted')
try {
await this.openTextFile(path, signal)
return { opened: true }
} catch (error: unknown) {
if (signal.aborted) throw cancelled('settings document open was aborted')
throw internal(`path open failed: ${messageOf(error)}`)
}
}
/**
* Open one user-authored Agent preset directory or return its path when no native opener exists.
* @param agentPreset - preset id resolved against Host-owned roots.
* @param signal - caller lifetime; abort terminates the native command.
* @returns an opened confirmation or the resolved directory for text display.
* @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened.
*/
@Remote
async openAgentPresetDirectory(
agentPreset: string,
signal: AbortSignal,
): Promise<AgentPresetDirectoryOpenValue> {
if (agentPreset.length === 0) {
throw new TypertRemoteFailure({
code: 'bad-request', message: 'agent preset id must not be empty', details: {},
})
}
const presets = this.ctx.get('agentPresets')
if (presets === undefined) {
throw new TypertRemoteFailure({
code: 'agent-preset-not-found',
message: 'this deployment composes no agent presets',
details: { agentPreset, available: [] },
})
}
let directory: string
try {
const preset = await presets.resolve(agentPreset)
if (preset.trust !== 'user') {
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
}
directory = dirname(preset.path)
} catch (error: unknown) {
throw presetFailure(agentPreset, error)
}
if (!this.canOpenPath()) return { opened: false, path: directory }
try {
await this.openPath(directory, signal)
return { opened: true }
} catch (error: unknown) {
if (signal.aborted) throw cancelled('path open was aborted')
throw internal(`path open failed: ${messageOf(error)}`)
}
}
private async write(
ns: string,
mode: 'update' | 'replace' | 'mutate',
@@ -196,6 +308,44 @@ export class SettingsController extends TypertRemoteService {
}
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function internal(message: string): TypertRemoteFailure {
return new TypertRemoteFailure({ code: 'internal', message, details: {} })
}
function cancelled(message: string): TypertRemoteFailure {
return new TypertRemoteFailure({ code: 'cancelled', message, details: {} })
}
function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure {
if (error instanceof UnknownPresetError) {
return new TypertRemoteFailure({
code: 'agent-preset-not-found',
message: error.message,
details: { agentPreset: error.presetId, available: [...error.available] },
})
}
if (error instanceof PresetNotWritableError) {
return new TypertRemoteFailure({
code: 'agent-preset-read-only',
message: error.message,
details: { agentPreset, reason: error.message },
})
}
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
return new TypertRemoteFailure({
code: 'agent-preset-invalid',
message: error.message,
details: { agentPreset, reason: error.message },
})
}
if (error instanceof TypertRemoteFailure) return error
return internal(`agent preset "${agentPreset}": ${String(error)}`)
}
/**
* Classify one seam refusal. A stale writer is its own outcome, not a malformed
* request: the client must re-read and re-apply rather than treat the write as
@@ -30,6 +30,16 @@ export type SettingsError = {
}
}[keyof SettingsErrorDetailsMap]
/** Confirmation that the settings document was handed to the native editor. */
export interface SettingsDocumentOpenValue {
readonly opened: true
}
/** Result of opening or revealing one locally authored Agent preset directory. */
export type AgentPresetDirectoryOpenValue =
| { readonly opened: true }
| { readonly opened: false; readonly path: string }
/** Stable credential failure details returned by the `credentials` namespace. */
export interface CredentialErrorDetailsMap {
/**
@@ -11,6 +11,12 @@
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../preset/agent-presets"
},
{
"path": "../../credentials/credentials"
},
@@ -20,6 +26,9 @@
{
"path": "../../runtime-diagnostics/invariants"
},
{
"path": "../../util/native-command"
},
{
"path": "../../settings/settings"
},
+1 -15
View File
@@ -30,14 +30,6 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -45,23 +37,17 @@
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts"
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
+2 -20
View File
@@ -4,9 +4,8 @@
* @module @deepseek-ai/dsh-file-reference
*/
import type { Context } from '@deepseek-ai/cordis'
import { Service, type Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { FileReferenceCandidate } from './types.ts'
@@ -24,7 +23,7 @@ declare module '@deepseek-ai/cordis' {
}
/** Host capability for cancellable file-reference discovery. */
export abstract class FileReferenceService extends TypertRemoteService {
export abstract class FileReferenceService extends Service {
constructor(ctx: Context) {
super(ctx, 'fileReferences')
}
@@ -41,23 +40,6 @@ export abstract class FileReferenceService extends TypertRemoteService {
query: string,
signal: AbortSignal,
): Promise<FileReferenceCandidate[]>
/**
* Remote face of {@link list}; the decorator cannot mark the abstract
* member, so this concrete adapter carries the identical contract.
* @param agent - target agent whose session cwd bounds discovery.
* @param query - path text following `@` or `@"`.
* @param signal - caller cancellation.
* @returns deterministic path-only candidates.
*/
@Remote('list')
remoteExportList(
agent: Agent,
query: string,
signal: AbortSignal,
): Promise<FileReferenceCandidate[]> {
return this.list(agent, query, signal)
}
}
export default FileReferenceService
+2 -2
View File
@@ -23,7 +23,7 @@
*/
import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm'
import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
import type { LlmDiscoveredModel, LlmModelDiscoveryOperation } from '@deepseek-ai/dsh-llm'
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
import { catalogModels } from './catalog.ts'
@@ -193,7 +193,7 @@ function usableProbeKey(raw: string): string {
* refuses or fails the request, or the reply is not a model listing.
*/
export async function discoverModels(
request: LlmModelDiscoveryRequest,
request: LlmModelDiscoveryOperation,
storedApiKey?: () => Promise<string | undefined>,
): Promise<readonly LlmDiscoveredModel[]> {
// A catalog route already has its answer, and a better one: the installed
+4 -1
View File
@@ -257,7 +257,10 @@ export function apply(ctx: Context, config: Config): void {
// except the credential: a configuration surface edits a redacted descriptor
// and never holds a stored secret, so an already-configured route supplies
// its own here rather than being interrogated unauthenticated.
ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider)))
ctx.llm.registerModelDiscovery(NS, (request, signal) => discoverModels(
{ ...request, ...signal === undefined ? {} : { signal } },
() => storedApiKey(request.provider),
))
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below. A bare
// mount (zero routes) is the dormant posture: nothing registers until a
+17 -2
View File
@@ -34,6 +34,14 @@
"types": "./lib/types/message.d.ts",
"default": "./lib/types/message.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
@@ -41,7 +49,11 @@
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
"lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts"
],
"license": "MIT",
"peerDependencies": {
@@ -49,17 +61,20 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/dsh-util-crypto": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^"
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-attachment": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
+44 -6
View File
@@ -6,7 +6,8 @@
* @module @deepseek-ai/dsh-llm
*/
import { Context, Service } from '@deepseek-ai/cordis'
import { Context } from '@deepseek-ai/cordis'
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type {
GenerateOptions,
LlmConfigurableProvider,
@@ -322,12 +323,12 @@ export interface DirectoryRegistrationHandle {
* The abstract `llm` service: an adapter registry plus a streaming model-call
* API, interceptable via the `llm/stream` waterfall.
*/
export class LlmRuntime extends Service {
export class LlmRuntime extends TypertRemoteService {
private adapters = new Map<string, AdapterRegistration>()
private directory = new Map<string, LlmConfigurableProvider>()
private discoveries = new Map<
string,
(request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>
(request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise<readonly LlmDiscoveredModel[]>
>()
constructor(ctx: Context) {
@@ -457,6 +458,7 @@ export class LlmRuntime extends Service {
* Describe provider routes with a registered adapter.
* @returns detached provider metadata in registration order.
*/
@Remote
listProviders(): LlmProviderInfo[] {
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
}
@@ -528,6 +530,7 @@ export class LlmRuntime extends Service {
* List every declared configurable provider, registered or dormant.
* @returns detached directory entries in declaration order.
*/
@Remote
listConfigurableProviders(): LlmConfigurableProvider[] {
return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))
}
@@ -539,12 +542,15 @@ export class LlmRuntime extends Service {
* directory, and because a provider being *added* has no route to name yet.
* Disposed with the fiber.
* @param settingsNs - the namespace whose profiles this discovery serves.
* @param discover - interrogates one endpoint; must honor `request.signal`.
* @param discover - interrogates one endpoint and must honor the supplied signal.
* @returns the disposer that withdraws the offer.
*/
registerModelDiscovery(
settingsNs: string,
discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>,
discover: (
request: LlmModelDiscoveryRequest,
signal?: AbortSignal,
) => Promise<readonly LlmDiscoveredModel[]>,
): () => void {
const dispose = this.ctx.effect(function* (this: LlmRuntime) {
if (settingsNs.length === 0) {
@@ -568,11 +574,13 @@ export class LlmRuntime extends Service {
* candidate metadata a surface may offer for adoption.
* @param settingsNs - namespace whose registered discovery serves this draft.
* @param request - the endpoint, protocol, and one-shot credential to use.
* @param signal - caller cancellation.
* @returns the advertised models, deduplicated in endpoint order.
*/
async discoverModels(
settingsNs: string,
request: LlmModelDiscoveryRequest,
signal?: AbortSignal,
): Promise<LlmDiscoveredModel[]> {
const discover = this.discoveries.get(settingsNs)
if (discover === undefined) {
@@ -583,7 +591,9 @@ export class LlmRuntime extends Service {
if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) {
throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY')
}
const discovered = await discover(request)
const discovered = signal === undefined
? await discover(request)
: await discover(request, signal)
const seen = new Set<string>()
const models: LlmDiscoveredModel[] = []
for (const model of discovered) {
@@ -599,6 +609,34 @@ export class LlmRuntime extends Service {
return models
}
/**
* Remote adapter for one draft provider interrogation.
* @param settingsNs - namespace whose registered discovery serves this draft.
* @param request - endpoint, protocol, and one-shot credential to use.
* @param signal - caller cancellation supplied by the Remote carrier.
* @returns advertised models in endpoint order.
* @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails.
*/
@Remote('discoverModels')
async remoteDiscoverModels(
settingsNs: string,
request: LlmModelDiscoveryRequest,
signal: AbortSignal,
): Promise<LlmDiscoveredModel[]> {
try {
return await this.discoverModels(settingsNs, request, signal)
} catch (error: unknown) {
throw new TypertRemoteFailure({
code: 'model-discovery-failed',
message: error instanceof Error ? error.message : String(error),
details: {
settingsNs,
...request.baseURL === undefined ? {} : { baseURL: request.baseURL },
},
})
}
}
/**
* Resolve the retry policy captured when one provider route was registered.
* @param provider - registered provider route to inspect.
+14
View File
@@ -247,10 +247,24 @@ export interface LlmModelDiscoveryRequest {
api?: string
/** Credential for this interrogation alone; the harness never stores it. */
apiKey?: string
}
/** Provider-side discovery request with operation-local cancellation attached. */
export interface LlmModelDiscoveryOperation extends LlmModelDiscoveryRequest {
/** Caller cancellation; implementations must settle promptly after it aborts. */
signal?: AbortSignal
}
/** Stable failure returned by the `llm/discoverModels` Remote method. */
export interface LlmModelDiscoveryError {
readonly code: 'model-discovery-failed'
readonly message: string
readonly details: {
readonly settingsNs: string
readonly baseURL?: string
}
}
/**
* One model an endpoint reports about itself. Every field but the id is
* optional because most provider listings disclose an id and nothing else;
+3
View File
@@ -28,6 +28,9 @@
},
{
"path": "../../util/crypto"
},
{
"path": "../../typert/protocol"
}
]
}
+12 -40
View File
@@ -1,44 +1,16 @@
/**
* Shared no-shell `execFile` runner for host-native OS integrations (the
* native directory chooser, the open-with-default-application hand-off):
* utf8 stdio capture, abort propagation, Windows console hide. A library,
* not a plugin — no ctx, no state, no events.
* Host-native command execution and path-opening utilities.
* @module @deepseek-ai/dsh-native-command
*/
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type NativeCommandRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/**
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
* @param command - executable path or PATH name.
* @param args - argv (never a shell string).
* @param signal - caller/connection lifetime; abort terminates the child.
* @returns captured stdout/stderr on exit 0.
*/
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})
export { runNativeCommand } from './runner.ts'
export type { NativeCommandRunner } from './runner.ts'
export {
canOpenNativePath,
openNativePath,
openNativeTextFile,
} from './path-opener.ts'
export type {
PathOpenerInternals,
PathOpenerRunner,
} from './path-opener.ts'
@@ -0,0 +1,203 @@
/**
* Cross-platform native path and text-document openers for Host UI
* integrations.
*
* The default intent prefers the default browser for documents it renders when
* the platform can name one, then falls back to the default application. WSL
* translates every path for the Windows desktop instead of assuming a Linux
* GUI. The text-editor intent never consults the browser.
* @module @deepseek-ai/dsh-native-command/path-opener
*/
import { release as osRelease } from 'node:os'
import { extname } from 'node:path'
import { runNativeCommand, type NativeCommandRunner } from './runner.ts'
/** Testable command boundary; native implementations never invoke a shell. */
export type PathOpenerRunner = NativeCommandRunner
/** Injectable platform facts for deterministic adapter tests. */
export interface PathOpenerInternals {
platform?: NodeJS.Platform
/** Kernel release override used to distinguish WSL from desktop Linux. */
osRelease?: string
/** Environment used for WSL markers and the desktop Linux browser convention. */
env?: NodeJS.ProcessEnv
run?: PathOpenerRunner
}
/** Documents a browser renders, as opposed to ones an editor merely edits. */
const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg'])
/**
* The macOS bundle registered for `https` — the default browser, as
* LaunchServices records it. The nested version dict is stripped first
* because it carries its own `LSHandlerRoleAll`.
*/
function macBundleForHttps(plist: string): string | undefined {
const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '')
const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0]
if (block === undefined) return undefined
return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1]
}
/**
* Open one browser-renderable document with the default browser.
* @returns true when a browser took it; false when this platform cannot name
* one, or naming it failed — the caller then uses the default application.
*/
async function openInBrowser(
path: string, signal: AbortSignal, platform: NodeJS.Platform,
run: PathOpenerRunner, env: NodeJS.ProcessEnv,
): Promise<boolean> {
if (platform === 'darwin') {
let bundle: string | undefined
try {
const { stdout } = await run(
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
bundle = macBundleForHttps(stdout)
} catch {
// No LaunchServices record (a fresh account never changed a default):
// the content-type handler is then the system's own choice anyway.
return false
}
if (bundle === undefined) return false
await run('open', ['-b', bundle, path], signal)
return true
}
if (platform === 'linux') {
// $BROWSER is the portable convention; desktop-entry resolution through
// xdg-settings needs a launcher this package has no business shipping.
const browser = env.BROWSER
if (browser === undefined || browser === '') return false
await run(browser, [path], signal)
return true
}
// Windows names no browser without reading the UserChoice registry, and its
// .html association is the browser in the ordinary case.
return false
}
/** Native path-open intent; macOS distinguishes text editing from file association. */
type PathOpenIntent = 'default' | 'text-editor'
/** PowerShell single-quoted literal (doubles embedded quotes). */
function powershellLiteral(path: string): string {
return `'${path.replace(/'/g, "''")}'`
}
/** Whether one environment marker is set to a non-empty value. */
function present(value: string | undefined): boolean {
return value !== undefined && value !== ''
}
/** Distinguish WSL from desktop Linux using its process and kernel markers. */
function isWsl(internals: PathOpenerInternals): boolean {
const env = internals.env ?? process.env
if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true
return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft')
}
/** Open one Windows-resolvable path through its registered desktop application. */
async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
await run('powershell.exe', [
'-NoProfile',
'-Command',
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
], signal)
}
/** Translate a WSL path before handing it to the Windows desktop. */
async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
const translated = await run('wslpath', ['-w', path], signal)
signal.throwIfAborted()
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
await openWindowsPath(windowsPath, signal, run)
}
/** Dispatch one shell-free platform command for the requested open intent. */
async function openNativePathWithIntent(
path: string,
signal: AbortSignal,
intent: PathOpenIntent,
internals: PathOpenerInternals = {},
): Promise<void> {
const platform = internals.platform ?? process.platform
const run = internals.run ?? runNativeCommand
const env = internals.env ?? process.env
const wsl = platform === 'linux' && isWsl(internals)
if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
&& await openInBrowser(path, signal, platform, run, env)) return
if (platform === 'darwin') {
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
return
}
if (platform === 'win32') {
await openWindowsPath(path, signal, run)
return
}
if (platform === 'linux') {
if (wsl) {
await openWslPath(path, signal, run)
return
}
await run('xdg-open', [path], signal)
return
}
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.
* @param path - absolute or host-resolvable path (caller owns resolution).
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - Platform, environment, and runner hooks for deterministic tests.
*/
export function openNativePath(
path: string,
signal: AbortSignal,
internals: PathOpenerInternals = {},
): Promise<void> {
return openNativePathWithIntent(path, signal, 'default', internals)
}
/**
* Open a text document for editing; macOS bypasses the file-type association
* so a YAML association with a browser cannot consume the gesture.
* @param path - absolute or host-resolvable text-document path.
* @param signal - caller/connection lifetime; abort terminates the native command.
* @param internals - Platform and runner hooks for deterministic tests.
*/
export function openNativeTextFile(
path: string,
signal: AbortSignal,
internals: PathOpenerInternals = {},
): Promise<void> {
return openNativePathWithIntent(path, signal, 'text-editor', internals)
}
@@ -0,0 +1,41 @@
/**
* Shared no-shell `execFile` runner for host-native OS integrations.
* @module @deepseek-ai/dsh-native-command/runner
*/
import { execFile } from 'node:child_process'
/** Testable command boundary; native implementations never invoke a shell. */
export type NativeCommandRunner = (
command: string,
args: readonly string[],
signal: AbortSignal,
) => Promise<{ stdout: string; stderr: string }>
/**
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
* @param command - executable path or PATH name.
* @param args - argv (never a shell string).
* @param signal - caller/connection lifetime; abort terminates the child.
* @returns captured stdout/stderr on exit 0.
*/
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
new Promise((resolve, reject) => {
execFile(
command,
[...args],
{ encoding: 'utf8', signal, windowsHide: true },
(error, stdout, stderr) => {
if (error !== null) {
const failure = Object.assign(new Error(error.message, { cause: error }), {
code: error.code,
stdout,
stderr,
})
reject(failure)
return
}
resolve({ stdout, stderr })
},
)
})