From 2d4393d842139f16f4ae32b8ae31476a597cdd22 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:06:58 +0800 Subject: [PATCH] refactor(api): expose remaining domain remotes --- packages/api/session-controller/package.json | 6 + .../session-controller/src/file-references.ts | 42 ++++ packages/api/session-controller/src/index.ts | 95 +++++++- .../session-controller/src/skill-catalog.ts | 120 +++++++++++ packages/api/session-controller/src/types.ts | 34 +++ .../api/session-controller/tsconfig.host.json | 8 +- packages/api/settings-controller/package.json | 8 +- packages/api/settings-controller/src/index.ts | 152 ++++++++++++- packages/api/settings-controller/src/types.ts | 10 + .../api/settings-controller/tsconfig.json | 9 + packages/context/file-reference/package.json | 16 +- packages/context/file-reference/src/index.ts | 22 +- packages/llm/llm-pi-ai/src/discovery.ts | 4 +- packages/llm/llm-pi-ai/src/index.ts | 5 +- packages/llm/llm/package.json | 19 +- packages/llm/llm/src/index.ts | 50 ++++- packages/llm/llm/src/types.ts | 14 ++ packages/llm/llm/tsconfig.json | 3 + packages/util/native-command/src/index.ts | 52 ++--- .../util/native-command/src/path-opener.ts | 203 ++++++++++++++++++ packages/util/native-command/src/runner.ts | 41 ++++ 21 files changed, 821 insertions(+), 92 deletions(-) create mode 100644 packages/api/session-controller/src/file-references.ts create mode 100644 packages/api/session-controller/src/skill-catalog.ts create mode 100644 packages/util/native-command/src/path-opener.ts create mode 100644 packages/util/native-command/src/runner.ts diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index dabfd8fd6a..191ad25909 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -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:^", diff --git a/packages/api/session-controller/src/file-references.ts b/packages/api/session-controller/src/file-references.ts new file mode 100644 index 0000000000..44d3417928 --- /dev/null +++ b/packages/api/session-controller/src/file-references.ts @@ -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 { + return this.ctx.fileReferences.list(agent, query, signal) + } +} + +export default SessionFileReferences diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index a3ce201aaf..dadf1db607 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -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 +} + /** 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 private readonly promotions = new Set>() /** * @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 { + 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 { + 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 diff --git a/packages/api/session-controller/src/skill-catalog.ts b/packages/api/session-controller/src/skill-catalog.ts new file mode 100644 index 0000000000..3cd15669ff --- /dev/null +++ b/packages/api/session-controller/src/skill-catalog.ts @@ -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 { + 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 { + 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 = {}, +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +export default SessionSkillCatalog diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index e9e416777c..24e9232d07 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -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'> diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index d3256426e2..e3f40c885d 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -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" } ] } diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index ed518756fb..4f4326f47e 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -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:^" } } diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index e893d06649..a61dfd329e 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -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 + readonly openTextFile?: (path: string, signal: AbortSignal) => Promise + 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 = Schema.object({ nativeOpen: Schema.boolean() }) + + private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly openTextFile: (path: string, signal: AbortSignal) => Promise + 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 { + 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 { + 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 diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 81249e3065..5fde28ae89 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -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 { /** diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json index 5b854dbe64..763aa37777 100644 --- a/packages/api/settings-controller/tsconfig.json +++ b/packages/api/settings-controller/tsconfig.json @@ -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" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 7d34d416cf..f1e45418b6 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -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": { diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts index 45da48c929..bf1025714f 100644 --- a/packages/context/file-reference/src/index.ts +++ b/packages/context/file-reference/src/index.ts @@ -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 - - /** - * 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 { - return this.list(agent, query, signal) - } } export default FileReferenceService diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index 014c9c2f3e..bb9915b116 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -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, ): Promise { // A catalog route already has its answer, and a better one: the installed diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index c9752b764e..58d5f620c8 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -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 diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index cf032be43e..1ec340226e 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -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:^" } } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index c1ec12e3c9..37b6795a13 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -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() private directory = new Map() private discoveries = new Map< string, - (request: LlmModelDiscoveryRequest) => Promise + (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise >() 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, + discover: ( + request: LlmModelDiscoveryRequest, + signal?: AbortSignal, + ) => Promise, ): () => 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 { 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() 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 { + 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. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index e7dadc6b20..438fcaa67b 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -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; diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 2206a6028e..f561d7b5e0 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../util/crypto" + }, + { + "path": "../../typert/protocol" } ] } diff --git a/packages/util/native-command/src/index.ts b/packages/util/native-command/src/index.ts index 8ad8b81749..14100af799 100644 --- a/packages/util/native-command/src/index.ts +++ b/packages/util/native-command/src/index.ts @@ -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' diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts new file mode 100644 index 0000000000..97d2084e61 --- /dev/null +++ b/packages/util/native-command/src/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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return openNativePathWithIntent(path, signal, 'text-editor', internals) +} diff --git a/packages/util/native-command/src/runner.ts b/packages/util/native-command/src/runner.ts new file mode 100644 index 0000000000..58003baa55 --- /dev/null +++ b/packages/util/native-command/src/runner.ts @@ -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 }) + }, + ) + })