/** * SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides * whether to load it; see the single-executable Agent Note and package README. * Stdout is reserved for protocol frames, so the tree must not load a stdout logger. * This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin * owns EOF and signal exits. Keep named plugin exports with no default export so * Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`. * * @module @deepseek-ai/dsh-jsonrpc */ import type { Context } from 'cordis' import type { Readable, Writable } from 'node:stream' import Schema from 'schemastery' import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' import { HarnessSdkServer } from './server.ts' export * from './server.ts' export const name = 'jsonrpc' // Only the agent factory is required; initialize reads the optional LLM seam with ctx.get(). export const inject = ['agents'] /** JSON-RPC deployment config plus runtime-only test seams. */ export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ output?: Writable /** Process-exit override; production uses `process.exit`. */ exit?: (code: number) => void } export const Config: Schema = Schema.object({ maxTokensAsSuccess: Schema.boolean().default(false), }) /** * Serve SDK requests over the configured streams. Effect disposal shuts down * SDK-created agents and closes the transport. A `shutdown` response is flushed * before this plugin's fiber is disposed and the process exits 0; the app bin * owns root-context disposal for EOF and signals. */ export function apply(ctx: Context, config: JsonRpcConfig): void { // Cordis applies the schema default before invoking the plugin. const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean } // The later transport callback must dispose this plugin's fiber, not its ambient context. const fiber = ctx.fiber /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ const input = config.input ?? process.stdin /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ const output = config.output ?? process.stdout /* v8 ignore next -- production exit wiring; tests always inject the runtime seams */ const exit = config.exit ?? ((code: number): void => { process.exit(code) }) const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkServer(ctx, transport, { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, }) // Share one exit task and attempt flush and disposal independently before exiting. let exitTask: Promise | undefined const disposeAndExit = (): Promise => { exitTask ??= (async () => { await Promise.allSettled([Promise.resolve().then(() => transport.flush())]) await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())]) exit(0) })() return exitTask } transport.onRequest(async (method, params) => { const result = await server.handleRequest(method, params) if (method === 'shutdown') { // Run after the handler result is written; the task then flushes, disposes, and exits. setImmediate(() => { void disposeAndExit() }) } return result }) ctx.effect(() => { transport.start() return async () => { await server.shutdown() transport.close() } }, 'jsonrpc.serve') }