/** * Low-level JSON-RPC client for a DeepSeek Harness SDK runtime subprocess. * {@link HarnessClient} owns the child process: it spawns the runtime, speaks * the `@deepseek-ai/dsh-sdk-protocol` wire over the child's stdio, fans * server notifications out to subscriptions, and tears the child down to * quiescence through a private EOF → SIGTERM → SIGKILL ladder. The design * twin is the Python SDK's `HarnessClient` (`python/sdk`); both drive the * same runtime protocol. This client runs OUTSIDE any harness context, so it * spawns directly rather than through the `dsh-subprocess` service — the * seam's documented exception for SDK-managed transports. * * @module @deepseek-ai/dsh-sdk-client/client */ import { spawn, type ChildProcess } from 'node:child_process' import { JsonRpcLineTransport, JsonRpcResponseError, type InitializeParams, type InitializeResult, type SessionPromptParams, } from '@deepseek-ai/dsh-sdk-protocol' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { disposeRuntimeProcess } from './dispose.ts' import type { HarnessClientOptions, HarnessNotification, NotificationFilter } from './types.ts' /** Retained stderr lines used to diagnose an unexpected runtime death. */ const STDERR_TAIL_LIMIT = 400 /** Grace for the runtime's stdio streams to settle after its exit edge. */ const STREAM_SETTLE_MS = 100 /** * The runtime subprocess is gone or unusable: it exited, its stdio closed, or * it was never launchable. The message carries the exit code and a stderr * tail when available. */ export class TransportClosedError extends Error { /** @param message - the failure description, including any stderr tail. */ constructor(message: string) { super(message) this.name = 'TransportClosedError' } } /** A request exceeded {@link HarnessClientOptions.requestTimeoutMs}. */ export class RequestTimeoutError extends Error { /** @param message - which method timed out. */ constructor(message: string) { super(message) this.name = 'RequestTimeoutError' } } /** * The runtime answered outside its documented protocol (for example a * `session/prompt` response without `accepted: true`). */ export class SdkProtocolError extends Error { /** @param message - the protocol violation description. */ constructor(message: string) { super(message) this.name = 'SdkProtocolError' } } interface SubscriptionState { readonly queue: HarnessNotification[] readonly waiters: { resolve: (item: HarnessNotification) => void; reject: (error: Error) => void }[] readonly filter: NotificationFilter | undefined failure: Error | undefined } /** One client-side notification stream returned by {@link HarnessClient.subscribe}. */ export interface NotificationSubscription extends AsyncIterable { /** * Await the next matching notification. * @returns the notification; after the runtime died, drains what was * already delivered and then rejects; after {@link close}, rejects * immediately (the queue is dropped). */ next(): Promise /** * Drain one already-delivered notification without waiting. * @returns the next queued notification, or `undefined` when none is queued. */ tryNext(): HarnessNotification | undefined /** Detach from the client; queued items drop and pending waiters reject. */ close(): void } /** Internal producer side of a public notification subscription. */ class NotificationSubscriptionImpl implements NotificationSubscription { constructor( private readonly state: SubscriptionState, private readonly unsubscribe: () => void, ) {} /** * Await the next matching notification. * @returns the notification; after the runtime died, drains what was * already delivered and then rejects; after {@link close}, rejects * immediately (the queue is dropped). */ next(): Promise { const queued = this.state.queue.shift() if (queued !== undefined) return Promise.resolve(queued) if (this.state.failure !== undefined) return Promise.reject(this.state.failure) return new Promise((resolve, reject) => { this.state.waiters.push({ resolve, reject }) }) } /** * Drain one already-delivered notification without waiting. * @returns the next queued notification, or `undefined` when none is queued. */ tryNext(): HarnessNotification | undefined { return this.state.queue.shift() } /** Detach from the client; queued items drop and pending waiters reject. */ close(): void { this.unsubscribe() // The drop is part of this method's contract; a runtime-death fail() keeps // the queue so already-delivered notifications remain drainable. this.state.queue.length = 0 this.fail(new TransportClosedError('notification subscription closed')) } /** * Reject pending and future waits (delivery stops; the first failure wins). * Already-queued notifications remain drainable via {@link next}/{@link tryNext}. * @param error - the terminal failure delivered to waiters. */ fail(error: Error): void { this.state.failure ??= error for (const waiter of this.state.waiters.splice(0)) waiter.reject(this.state.failure) } /** * Deliver one notification to a waiter or the queue when the filter * matches. A throwing filter fails only THIS subscription (detached, the * throw becomes its terminal error) — it never disturbs sibling * subscriptions or the transport's read loop, mirroring the Python client. * @param notification - the wire notification to deliver. */ push(notification: HarnessNotification): void { let matches: boolean try { matches = this.state.filter === undefined || this.state.filter(notification) } catch (error) { this.unsubscribe() this.fail(error instanceof Error ? error : new Error(String(error))) return } if (!matches) return const waiter = this.state.waiters.shift() if (waiter !== undefined) waiter.resolve(notification) else this.state.queue.push(notification) } /** * Iterate notifications until the subscription or runtime closes (the * terminating rejection propagates). * @returns an async iterator over {@link next} results. */ async * [Symbol.asyncIterator](): AsyncIterator { for (;;) yield await this.next() } } /** * JSON-RPC client for the DeepSeek Harness SDK runtime over subprocess stdio. * * The subprocess starts lazily on {@link start} and is owned by this instance * until {@link close}, which requests protocol `shutdown` and then walks the * shared EOF → SIGTERM → SIGKILL dispose ladder to quiescence. There is no * wire-level cancel: a timed-out request stays running server-side until the * runtime is closed. */ export class HarnessClient { private child: ChildProcess | undefined private transport: JsonRpcLineTransport | undefined private readonly stderrTail: string[] = [] private readonly subscriptions = new Map() private readonly sessionParents = new Map() private subscriptionSerial = 0 private exitCode: number | null | undefined private spawnError: Error | undefined private streamsSettled: Promise = Promise.resolve() private closeTask: Promise | undefined /** @param options - launch spec, complete child environment, and timeouts. */ constructor(readonly options: HarnessClientOptions) {} /** * Spawn the runtime subprocess and start reading frames. Idempotent while * the process is live; rejects reuse after {@link close}. */ start(): void { if (this.closeTask !== undefined) throw new TransportClosedError('DeepSeek Harness runtime client is closed') if (this.child !== undefined) return const child = spawn(this.options.command, this.options.args ?? [], { cwd: this.options.cwd, env: this.options.env ?? process.env, stdio: ['pipe', 'pipe', 'pipe'], }) this.child = child child.once('error', (error) => { this.spawnError = error // A spawn failure destroys the pipes without an input 'end' edge, so the // transport's pending requests must be failed here. this.transport?.close() this.failSubscriptions(this.closedError('DeepSeek Harness runtime failed to start')) }) // Writes racing the runtime's death EPIPE on stdin; the exit edge below is // the real signal, so the stream-level error only needs to be non-fatal. // The timing of that race is not deterministically reproducible. /* v8 ignore next */ child.stdin.on('error', () => {}) let stderrBuffer = '' child.stderr.setEncoding('utf8') child.stderr.on('data', (chunk: string) => { stderrBuffer += chunk const newline = stderrBuffer.lastIndexOf('\n') if (newline >= 0) { this.appendStderr(stderrBuffer.slice(0, newline).split('\n')) stderrBuffer = stderrBuffer.slice(newline + 1) } }) let signalStreamsSettled!: () => void this.streamsSettled = new Promise((resolve) => { signalStreamsSettled = resolve }) const settled = { stderr: false, exited: false } const maybeSettle = (): void => { if (settled.stderr && settled.exited) signalStreamsSettled() } child.stderr.once('close', () => { if (stderrBuffer.length > 0) this.appendStderr([stderrBuffer]) settled.stderr = true maybeSettle() }) child.once('exit', (code) => { this.exitCode = code settled.exited = true maybeSettle() this.failSubscriptions(this.closedError('DeepSeek Harness runtime exited')) }) child.once('close', () => { // All stdio has settled: stdout 'end' already drained every tail frame, // so closing now cannot drop responses — it only fails requests that // will never be answered. this.transport?.close() }) const transport = new JsonRpcLineTransport(child.stdout, child.stdin) transport.onNotification((method, params) => { this.dispatchNotification({ method, params }) }) transport.start() this.transport = transport } /** * Perform the process-wide handshake. * @param params - workspace cwd plus the provider/model route. * @returns the runtime's wire identity. */ async initialize(params: InitializeParams): Promise { const result = await this.request('initialize', { ...params }) if (!isRecord(result) || !isRecord(result.serverInfo) || typeof result.serverInfo.name !== 'string' || typeof result.serverInfo.version !== 'string') { throw new SdkProtocolError(`initialize returned no server identity: ${JSON.stringify(result)}`) } return { serverInfo: { name: result.serverInfo.name, version: result.serverInfo.version } } } /** * Run one prompt turn to settlement (the response arrives only after the * turn settled; progress streams as notifications meanwhile). * @param sessionId - target session; an unknown id creates it. * @param contentBlocks - the user message, sent verbatim. */ async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise { const params: SessionPromptParams = { sessionId, contentBlocks } const result = await this.request('session/prompt', { ...params }) if (!isRecord(result) || result.accepted !== true) { throw new SdkProtocolError(`session/prompt was not accepted: ${JSON.stringify(result)}`) } } /** * Send one JSON-RPC request and await its result. * @param method - the wire method name. * @param params - the params object; omitted params send `{}`. * @param timeoutMs - per-call override of {@link HarnessClientOptions.requestTimeoutMs}. * @returns the raw result; rejects with {@link JsonRpcResponseError} on a * protocol error response, {@link RequestTimeoutError} on timeout, and * {@link TransportClosedError} when the runtime is gone. */ async request(method: string, params?: object, timeoutMs?: number): Promise { this.start() // A dead runtime cannot answer; fail with process context instead of // writing into a destroyed pipe and hanging until the timeout. if (this.exitCode !== undefined || this.spawnError !== undefined) { await this.settleStreams() throw this.closedError('DeepSeek Harness runtime is not running') } const transport = this.transport /* v8 ignore next -- start() either sets the transport or throws */ if (transport === undefined) throw new TransportClosedError('DeepSeek Harness runtime is not running') const timeout = timeoutMs ?? this.options.requestTimeoutMs try { if (timeout === undefined) return await transport.request(method, params ?? {}) // The abort signal makes the timeout an abandonment: the transport drops // its pending entry, so repeated bounded requests against a hung method // retain no per-call state (the server-side work still runs to close). const abandon = new AbortController() const timer = setTimeout(() => { abandon.abort(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`)) }, timeout) try { return await transport.request(method, params ?? {}, abandon.signal) } finally { clearTimeout(timer) } } catch (error) { if (error instanceof JsonRpcResponseError || error instanceof RequestTimeoutError) throw error // Transport-level failures gain process context: exit code + stderr tail. await this.settleStreams() throw this.closedError(errorMessage(error)) } } /** * Subscribe to server notifications. * @param filter - optional predicate; omitted means every notification. * @returns the subscription handle; close it to stop delivery. After * {@link close} or runtime death the handle is born failed — there is no * producer left, so `next()` rejects instead of waiting forever. */ subscribe(filter?: NotificationFilter): NotificationSubscription { const id = String(this.subscriptionSerial++) const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined } const subscription = new NotificationSubscriptionImpl(state, () => { this.subscriptions.delete(id) }) if (this.closeTask !== undefined || this.exitCode !== undefined || this.spawnError !== undefined) { subscription.fail(this.closedError('DeepSeek Harness runtime closed')) return subscription } this.subscriptions.set(id, subscription) return subscription } /** * Subscribe to one session and the descendants discovered from * `subagent.started` lineage edges (the runtime notifies for every session * in its context; scoping is client-side, mirroring the Python SDK). * @param sessionId - the root session id. * @returns the filtered subscription handle. */ subscribeSessionTree(sessionId: string): NotificationSubscription { return this.subscribe((notification) => { const params = notification.params if (notification.method === 'subagent.started' || notification.method === 'subagent.finished') { const parentId = params.parentSessionId if (typeof parentId === 'string' && this.isDescendantOf(parentId, sessionId)) return true return params.childSessionId === sessionId } const relatedId = params.sessionId return typeof relatedId === 'string' && this.isDescendantOf(relatedId, sessionId) }) } /** * Shut the runtime down and reap it: a best-effort protocol `shutdown` * bounded by `shutdownTimeoutMs`, then the shared stdin-EOF → SIGTERM → * SIGKILL ladder until the process actually exited. Idempotent. * @returns settlement of the complete teardown. */ close(): Promise { this.closeTask ??= this.performClose() return this.closeTask } private async performClose(): Promise { const child = this.child if (child === undefined) return try { await this.request('shutdown', undefined, this.options.shutdownTimeoutMs ?? 1_000) } catch (error) { // Diagnostic only: the dispose ladder below is the authoritative teardown // for a runtime that cannot answer shutdown anymore. this.appendStderr([`shutdown request failed: ${errorMessage(error)}`]) } await disposeRuntimeProcess(child, { disposeEofGraceMs: this.options.disposeEofGraceMs ?? 6_000, disposeGraceMs: this.options.disposeGraceMs ?? 3_000, }) this.transport?.close() this.failSubscriptions(this.closedError('DeepSeek Harness runtime closed')) } private dispatchNotification(notification: HarnessNotification): void { this.recordSessionRelationship(notification) for (const subscription of this.subscriptions.values()) subscription.push(notification) } private recordSessionRelationship(notification: HarnessNotification): void { if (notification.method !== 'subagent.started') return const parentId = notification.params.parentSessionId const childId = notification.params.childSessionId if (typeof parentId === 'string' && parentId !== '' && typeof childId === 'string' && childId !== '' && parentId !== childId) { this.sessionParents.set(childId, parentId) } } private isDescendantOf(sessionId: string, rootSessionId: string): boolean { const visited = new Set() let current = sessionId while (!visited.has(current)) { if (current === rootSessionId) return true visited.add(current) const parent = this.sessionParents.get(current) if (parent === undefined) return false current = parent } // The parent map only ever extends chains upward, so a cycle cannot form. /* v8 ignore next */ return false } private failSubscriptions(error: Error): void { for (const subscription of this.subscriptions.values()) subscription.fail(error) } private appendStderr(lines: string[]): void { const kept = lines.filter(line => line.length > 0) this.stderrTail.push(...kept) if (this.stderrTail.length > STDERR_TAIL_LIMIT) { this.stderrTail.splice(0, this.stderrTail.length - STDERR_TAIL_LIMIT) } } private settleStreams(): Promise { return Promise.race([ this.streamsSettled, new Promise((resolve) => { setTimeout(resolve, STREAM_SETTLE_MS) }), ]) } private closedError(reason: string): TransportClosedError { const parts = [reason] if (this.spawnError !== undefined) parts.push(`spawn error: ${this.spawnError.message}`) if (this.exitCode !== undefined) parts.push(`exit code: ${String(this.exitCode)}`) if (this.stderrTail.length > 0) parts.push(`stderr tail:\n${this.stderrTail.join('\n')}`) return new TransportClosedError(parts.join('\n')) } } /** * Whether `value` is a plain JSON object (the wire-boundary shape probe). * @param value - the wire value to probe. * @returns `true` iff `value` is a non-null, non-array object. */ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } /** The message of a thrown value (the transport only throws `Error`s; `String` covers the rest). */ function errorMessage(error: unknown): string { /* v8 ignore next -- the transport and dispose ladder reject only with Errors */ return error instanceof Error ? error.message : String(error) }