feat(sdk): TypeScript SDK client + shared wire protocol + SDK subagent backend

- @deepseek-ai/dsh-sdk-protocol: extract the line transport from dsh-jsonrpc
  and name the request/result/notification wire types both ends share; error
  responses preserve wire code/data via JsonRpcResponseError.
- @deepseek-ai/dsh-sdk-client: TypeScript twin of the Python SDK — spawns the
  dsh-jsonrpc-agent runtime as a subprocess, drives stdio JSON-RPC turns
  (DeepSeekHarness high-level API + HarnessClient protocol client), scopes
  notifications to session trees client-side, and reaps the child through the
  shared subprocess dispose ladder.
- @deepseek-ai/dsh-subagent-sdk: out-of-process subagent backend driving a
  child harness runtime through the TS SDK; shares cwd resolution with
  subagent-acp via new dsh-subagent-subprocess cwd helpers.
- Keyless unit suites drive real subprocesses (scripted fake runtime peer);
  100% per-file coverage on all touched packages.
This commit is contained in:
Tianyi Cui
2026-07-27 03:21:06 +08:00
parent 46e70be34a
commit 4ad37344a8
33 changed files with 2738 additions and 122 deletions

View File

@@ -0,0 +1,193 @@
/**
* High-level turns API over {@link HarnessClient}: `DeepSeekHarness` owns one
* runtime subprocess across many sessions; `HarnessSession.run` sends a
* prompt and settles with the final response once `session.finished` arrives.
* Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair.
*
* @module @deepseek-ai/dsh-sdk-client/api
*/
import { randomUUID } from 'node:crypto'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import { HarnessClient } from './client.ts'
import type { ContentBlock, DeepSeekHarnessOptions, HarnessNotification, TurnResult } from './types.ts'
/**
* Reusable SDK for running DeepSeek Harness agent turns in a runtime
* subprocess. The subprocess starts lazily on first use and stays owned by
* this instance until {@link close}; always close (or `await using`) so the
* child is reaped.
*/
export class DeepSeekHarness implements AsyncDisposable {
/** The underlying JSON-RPC client (exposed for low-level access). */
readonly client: HarnessClient
private readonly cwd: string
private readonly provider: string
private readonly model: string
private initialized: Promise<void> | undefined
/** @param options - runtime launch spec plus the session route (cwd/provider/model). */
constructor(options: DeepSeekHarnessOptions) {
this.client = new HarnessClient(options.launch)
this.cwd = options.cwd ?? options.launch.cwd ?? process.cwd()
this.provider = options.provider ?? 'deepseek'
this.model = options.model ?? 'deepseek-v4-flash'
}
/**
* Start the subprocess and perform the `initialize` handshake once.
* @returns settlement of the (memoized) handshake.
*/
start(): Promise<void> {
this.initialized ??= (async () => {
try {
this.client.start()
await this.client.initialize({ cwd: this.cwd, provider: this.provider, model: this.model })
} catch (error) {
this.initialized = undefined
await this.client.close()
throw error
}
})()
return this.initialized
}
/**
* Open a session handle (no wire traffic; the runtime creates the session
* on its first prompt).
* @param sessionId - explicit id to reuse; omitted mints a fresh one.
* @returns the session handle.
*/
session(sessionId?: string): HarnessSession {
return new HarnessSession(this, sessionId ?? `session-${randomUUID().replaceAll('-', '')}`)
}
/**
* Run one prompt on a fresh (or named) session.
* @param input - prompt text, or content blocks sent verbatim.
* @param options - optional session id and per-notification observer.
* @returns the settled turn result.
*/
run(input: string | ContentBlock[], options?: RunOptions): Promise<TurnResult> {
return this.session(options?.sessionId).run(input, options)
}
/**
* Shut down and reap the runtime subprocess. Idempotent.
* @returns settlement of the complete teardown.
*/
close(): Promise<void> {
return this.client.close()
}
/**
* `await using` support: {@link close}.
* @returns settlement of the teardown.
*/
[Symbol.asyncDispose](): Promise<void> {
return this.close()
}
}
/** Per-run options: target session and streaming observer. */
export interface RunOptions {
/** Session id to run on; omitted mints a fresh session per call. */
sessionId?: string
/** Observer invoked with every notification for this session tree, in wire order. */
onNotification?: (notification: HarnessNotification) => void
}
/**
* One SDK session: a stable id plus the turn loop that pairs a
* `session/prompt` with its `session.finished`.
*/
export class HarnessSession {
/**
* @param harness - the owning harness (supplies the client and handshake).
* @param id - the wire session id this handle runs on.
*/
constructor(readonly harness: DeepSeekHarness, readonly id: string) {}
/**
* Run one prompt turn to settlement.
* @param input - prompt text, or content blocks sent verbatim.
* @param options - optional per-notification observer.
* @returns the settled turn result; rejects on transport loss, timeout, or
* a protocol error — never on a model-level failure (that is
* `status: 'error'` in the result).
*/
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<TurnResult> {
await this.harness.start()
const client = this.harness.client
const contentBlocks = normalizeInput(input)
const events: SessionEvent[] = []
const notifications: HarnessNotification[] = []
let status: TurnResult['status'] = 'error'
let reason: TurnEndReason | undefined
let finished = false
const subscription = client.subscribeSessionTree(this.id)
const collect = (notification: HarnessNotification): void => {
notifications.push(notification)
options?.onNotification?.(notification)
if (notification.method === 'session.event' && notification.params.sessionId === this.id) {
events.push(notification.params.event as SessionEvent)
}
if (notification.method === 'session.finished' && notification.params.sessionId === this.id) {
status = notification.params.status === 'ok' ? 'ok' : 'error'
reason = notification.params.reason as TurnEndReason | undefined
finished = true
}
}
const accepted = client.prompt(this.id, contentBlocks)
// Drain concurrently so observers see progress while the prompt request
// is still pending (its response arrives only after settlement).
const drain = (async () => {
while (!finished) collect(await subscription.next())
})()
try {
await Promise.all([accepted, drain])
} finally {
// On a prompt rejection the drain is still parked on next(); closing the
// subscription settles it, and the swallow keeps that secondary
// TransportClosedError from surfacing as an unhandled rejection.
subscription.close()
await drain.catch(() => {})
}
return {
sessionId: this.id,
status,
reason,
finalResponse: finalResponse(events),
events,
notifications,
}
}
}
/**
* Normalize run input: a string becomes one text block; blocks pass verbatim.
* @param input - prompt text or content blocks.
* @returns the content blocks to send.
*/
export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] {
return typeof input === 'string' ? [{ type: 'text', text: input }] : input
}
/**
* Extract the concatenated text of the last assistant message.
* @param events - the turn's `session.event` payloads in wire order.
* @returns the final response text, or `''` when no assistant message exists.
*/
export function finalResponse(events: SessionEvent[]): string {
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index]
if (event?.type !== 'assistant/message') continue
return event.data.content
.filter((block): block is ContentBlock & { type: 'text' } => block.type === 'text')
.map(block => block.text)
.join('')
}
return ''
}

View File

@@ -0,0 +1,425 @@
/**
* 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 the shared subprocess dispose ladder. The design twin is
* the Python SDK's `HarnessClient` (`python/sdk`); both drive the same
* runtime protocol.
*
* @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 { disposeChildProcess } from '@deepseek-ai/dsh-subagent-subprocess'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
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. Delivery order matches the wire;
* {@link close} detaches it from the client, after which {@link next} rejects.
*/
export class NotificationSubscription implements AsyncIterable<HarnessNotification> {
constructor(
private readonly state: SubscriptionState,
private readonly unsubscribe: () => void,
) {}
/**
* Await the next matching notification.
* @returns the notification; rejects once the runtime is closed or the
* subscription itself is closed.
*/
next(): Promise<HarnessNotification> {
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()
this.fail(new TransportClosedError('notification subscription closed'))
}
/** Reject pending and future waits with `error` (delivery stops). */
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. */
push(notification: HarnessNotification): void {
if (this.state.filter !== undefined && !this.state.filter(notification)) 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<HarnessNotification> {
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<string, NotificationSubscription>()
private readonly sessionParents = new Map<string, string>()
private subscriptionSerial = 0
private exitCode: number | null | undefined
private spawnError: Error | undefined
private streamsSettled: Promise<void> = Promise.resolve()
private closeTask: Promise<void> | 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<InitializeResult> {
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<void> {
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<unknown> {
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 pending = transport.request(method, params ?? {})
const timeout = timeoutMs ?? this.options.requestTimeoutMs
try {
if (timeout === undefined) return await pending
let timer: NodeJS.Timeout | undefined
try {
return await Promise.race([
pending,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
// The abandoned wire promise settles on close; keep it handled.
pending.catch(() => {})
reject(new RequestTimeoutError(`${method} timed out after ${timeout}ms waiting for the DeepSeek Harness runtime`))
}, timeout)
}),
])
} 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.
*/
subscribe(filter?: NotificationFilter): NotificationSubscription {
const id = String(this.subscriptionSerial++)
const state: SubscriptionState = { queue: [], waiters: [], filter, failure: undefined }
const subscription = new NotificationSubscription(state, () => { this.subscriptions.delete(id) })
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<void> {
this.closeTask ??= this.performClose()
return this.closeTask
}
private async performClose(): Promise<void> {
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 disposeChildProcess(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<string>()
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<void> {
return Promise.race([
this.streamsSettled,
new Promise<void>((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). */
export function isRecord(value: unknown): value is Record<string, unknown> {
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)
}

View File

@@ -0,0 +1,14 @@
/**
* TypeScript client SDK for the DeepSeek Harness runtime: spawn the
* `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over
* stdio JSON-RPC. `DeepSeekHarness` is the high-level turns API;
* `HarnessClient` is the lower-level protocol client. A pure library — it
* registers nothing on a Cordis context; the runtime process it spawns is a
* complete harness configured by its own `cordis.yml`.
*
* @module @deepseek-ai/dsh-sdk-client
*/
export * from './api.ts'
export * from './client.ts'
export type * from './types.ts'

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-client`.
* @module @deepseek-ai/dsh-sdk-client/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client'
/** Cordis companion plugin name. */
export const name = 'sdk-client-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this client library runs outside any harness context
* (its peer is a separate runtime process); the runtime's own packages own
* the event-stream relations.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,77 @@
/**
* Types for the TypeScript SDK client: launch options, notification shapes,
* and turn results.
*
* @module @deepseek-ai/dsh-sdk-client/types
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SdkRunStatus } from '@deepseek-ai/dsh-sdk-protocol'
/** One server-to-client notification as received off the wire. */
export interface HarnessNotification {
/** The JSON-RPC method name (`session.event`, `session.finished`, `subagent.started`, `subagent.finished`). */
method: string
/** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */
params: Record<string, unknown>
}
/** Predicate deciding whether a subscription receives a notification. */
export type NotificationFilter = (notification: HarnessNotification) => boolean
/** Launch and timeout options for {@link HarnessClient}. */
export interface HarnessClientOptions {
/** The runtime executable (the `dsh-jsonrpc-agent` bin, a packaged exe, or `node`). */
command: string
/** Arguments passed to {@link command}. */
args?: string[]
/** Working directory for the runtime process itself. */
cwd?: string
/**
* The complete child environment. `undefined` inherits the parent env
* verbatim; passing an object replaces it entirely, so callers own
* credential policy (see `buildChildEnv` in
* `@deepseek-ai/dsh-subagent-subprocess` for the scrub-then-inject helper).
*/
env?: NodeJS.ProcessEnv
/** Per-request timeout (ms); `undefined` waits indefinitely (a turn can legitimately run long). */
requestTimeoutMs?: number
/** Bound (ms) on the protocol `shutdown` exchange inside `close()` (default 1000). */
shutdownTimeoutMs?: number
/** Grace (ms) for the runtime's stdin-EOF quiesce during `close()` (default 6000). */
disposeEofGraceMs?: number
/** Termination confirmation window (ms) after SIGTERM/SIGKILL during `close()` (default 3000). */
disposeGraceMs?: number
}
/** Options for the high-level {@link DeepSeekHarness} wrapper. */
export interface DeepSeekHarnessOptions {
/** Launch spec for the runtime subprocess (command, args, cwd, env, timeouts). */
launch: HarnessClientOptions
/** Workspace cwd recorded on every SDK-created session (default: the launch cwd, else `process.cwd()`). */
cwd?: string
/** Provider route for SDK-created agents (default `deepseek`). */
provider?: string
/** Model for SDK-created agents (default `deepseek-v4-flash`). */
model?: string
}
/** The settled outcome of one {@link HarnessSession.run} turn. */
export interface TurnResult {
/** The session the turn ran on. */
sessionId: string
/** Deployment-mapped turn outcome from `session.finished`. */
status: SdkRunStatus
/** Why the last message-triggered turn ended; `undefined` when no turn ran. */
reason: TurnEndReason | undefined
/** Concatenated text of the session's last assistant message (empty when none). */
finalResponse: string
/** Every `session.event` payload for this session tree, in wire order. */
events: SessionEvent[]
/** Every notification observed during the turn, in wire order. */
notifications: HarnessNotification[]
}
/** Re-exported content-block alias so SDK callers need no extra import. */
export type { ContentBlock }