# Agent Note: TypeScript SDK client and the SDK subagent backend

Status: implemented

English | [中文](2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md)

## Problem

The stdio JSON-RPC serving surface (`@deepseek-ai/dsh-sdk-jsonrpc-server`, the [single-exe Agent Note](../architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)) had exactly one client: the Python SDK. TypeScript consumers wanting the same drive-a-harness-as-a-subprocess capability — repo tests, automation, and above all a subagent backend whose child is a *complete harness runtime* rather than a generic ACP agent — had nothing to import: the request/notification payload shapes existed only as anonymous object literals inside the server, and the transport class lived inside the server plugin package.

## Decision

Three packages, layered exactly like the existing Python stack, plus one Service Provider registration:

- **`@deepseek-ai/dsh-sdk-protocol`** (`packages/sdk/protocol/`) — the wire made shared and nominal. `JsonRpcLineTransport` lives here, and `types.ts` names every payload the server speaks: `InitializeParams/Result`, `SessionPromptParams/Result`, the four notification payloads, and the `HarnessSdkRequestMap`/`HarnessSdkNotificationMap` indexes. `InitializeParams` carries provider, model, optional adapter-owned reasoning effort, and optional output cap. The package root explicitly exports that complete interface and provides no source-module deep imports. The server's `notify()` call sites are typed against these named payloads, so server drift breaks compilation, not clients. Error responses reject with `JsonRpcResponseError` carrying the wire `code`/`data`, matching the Python client.
- **`@deepseek-ai/dsh-sdk-client`** (`packages/sdk/client/`) — the TypeScript twin of `python/sdk`: `HarnessClient` (spawn, frame, fan out notifications, typed error surfaces, close-to-quiescence via the shared dispose ladder) under `DeepSeekHarness`/`HarnessSession` (lazy start, memoized `initialize`, and `run()` pairing one `session/prompt` with its owned activity). Its package-root consumer interface explicitly exports both client layers, caller-facing types, and the protocol-owned `JsonRpcResponseError`; source modules, normalization helpers, and the notification producer stay internal. `RunResult.events` contains only the root session's typed events, while `notifications` retains session ids across the root and descendants discovered from `subagent.started`; session-tree scoping is client-side, mirroring `client.py`. The result carries the final root-session assistant text but no prompt-level status or turn reason. The launch interface resolves the same-version `@deepseek-ai/dsh` dependency and selects a named profile, with optional `dshBin`, ordered patches, an explicit Harness home, process cwd, environment, and timeouts; arbitrary command/argv launch remains an internal fake-runtime adapter. `initialize` carries provider, model, optional reasoning effort, and optional output cap. A clean checkout without `lib/bin.js` uses that package's source entry through an absolute `tsx/esm` loader and an internal patch that omits build-generated Typert contribution loading, which the SDK protocol does not consume. `env` replaces rather than merges and is read when `start()` spawns, so callers own credential policy and can finish preparing it before first use. A failed handshake installs a fresh client after successful cleanup so a later call retries with a new process; if initialization and SDK-owned cleanup both fail, `start()` rejects with an ordered `AggregateError` and retains the failed client rather than spawning beside a process whose exit was not proved. Teardown walks a private stdin-EOF → SIGTERM → SIGKILL ladder to actual exit because the client runs outside any harness context.
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling but advertising `agentOptions: true`: each run merges provider/model/reasoning/maxTokens over instance defaults and sends only those fields through the child `initialize`. Other start capabilities remain false, and `inheritsParentContext: false`. The provider retains the publish-after-handshake ownership transaction, result-never-rejects flattening through an `onError` sink, and parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason`: `completed`, `max-tokens`, and ordinary `aborted` pass through; `blocked` becomes `refusal`; other non-completed values become `error`. Reachable child failures and SDK errors add the bounded safe diagnostic defined by the [out-of-process diagnostics decision](2026-08-21-out-of-process-subagent-minimal-diagnostics.md), using one category plus the current provider stage. Its `dshBin`/profile/patch/home config selects an isolated SDK application, while `env` supplies explicit child-only values such as its API key.
- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.

`dsh-sdk-jsonrpc-server` validates the exact provider/model/effort route during `initialize`, stores only explicitly supplied effort and token values, and creates every SDK root Agent from that fixed process-wide route. Because JSON-RPC requests can dispatch concurrently, it rejects `session/prompt` until one initialization has completed successfully, preventing pending or invalid routes from falling back to constructor defaults. TypeScript and Python clients both expose the same initialization fields through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree.

## Testing

Four tiers, per [testing policy](../../../../docs/testing.md):

- **Keyless unit** — `sdk-client` drives a scripted fake runtime (`tests/fake-runtime.ts`, env-scripted, protocol-only — the Python `test_client.py` pattern) over real stdio; `subagent-dsh-sdk` drives the same fake through the real provider, covering per-run route overrides, reachable child reasons, typed errors, and initialize/session-run/shutdown diagnostics. 100% per-file coverage on all three packages.
- **Keyless Loader composition** — `subagent-dsh-sdk/tests/loader-composition.e2e.ts` boots its package-owned test composition (`packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/`), where the child is a real second `dsh --profile sdk` runtime with its own isolated home and ordered patch; the tool result and persisted request header prove provider, model, reasoning effort, maxTokens, and parent-session cwd, while the failure case pins a model-visible child-error diagnostic separately from partial output.
- **Keyless snapshot** — `snapshots/sdk/sdk.snapshot.ts` drives the real `dsh --profile sdk` runtime through the real `dsh-sdk-client`, replaying recorded fixtures through ordered `llm-replay` patches. One DSH SDK scenario pins a model-selected route through the delegation tool, a second SDK runtime, and the child's persisted request header; another pins the normalized notification stream, SDK result, persisted log, and foreground/background failure text for safe diagnostics.
- **With-key e2e** — the snapshot suite's `DSH_SNAPSHOT=record` mode is the live-API path (it produced the committed fixtures); the composition e2e needs no key by design.

## Alternatives considered

**Import wire types from `dsh-sdk-jsonrpc-server` instead of extracting a protocol package.** Makes every SDK consumer (including `subagent-dsh-sdk`, which must not serve JSON-RPC) depend on the server plugin and its `dsh-agent`/`dsh-llm-deepseek` peer set, and leaves the notification payloads anonymous. The capability-seam rule (Service Definition / Service Provider / Consumer as separate packages) already names this shape; the transport is genuinely two-sided.

**Have `subagent-dsh-sdk` speak raw JSON-RPC without the client SDK.** Duplicates the request/notification pairing, subscription fan-out, timeout, and teardown logic the SDK exists to own; the user's ask was explicitly a backend that *uses* the SDK, and the layering earns its keep by making the backend ~200 lines of policy over a reusable client.

**Fold the SDK backend into `subagent-acp` with a transport switch.** The two backends share the subprocess lifecycle but nothing about the wire (ACP SDK connection vs harness JSON-RPC), the child contract (any ACP agent vs a harness runtime), or the result extraction (`agent_message_chunk` accumulation vs session-event reading). A config discriminant would bury two protocols in one package; the genuinely shared provider-side parts moved into the subagent seam's `out-of-process.ts`, and the process mechanics live in the `dsh-subprocess` seam.

**Resolve `dsh` only from `PATH`.** Rejected: a Node consumer does not reliably inherit a project-local `.bin` directory. The same-version package dependency supplies the built CLI for installed consumers and the source entry for a clean checkout.

**Export source modules, normalization helpers, and subscription producer operations.** These are implementation details with no caller need; exposing them would make callers learn how the client validates and distributes wire input. The package roots instead enumerate the supported client and protocol interfaces, and the client re-exports the one protocol error callers must distinguish.

**Reuse `dsh-session-snapshot`'s ACP `runScenario` for the SDK snapshots.** That adapter speaks ACP (`ClientSideConnection`, `InputStep` scripts). The SDK suite's whole point is to drive the *SDK client* as the entry surface; it reuses the normalize/refresh library layer (`normalizeSessionLog`, `refreshFixtureReplacements`, …) and leaves the ACP adapter alone.

## Consequences

**Bought**: the SDK runtime protocol has named, compiler-checked types shared by its server and both client SDKs; TypeScript consumers get the same subprocess-driving capability Python has, with typed errors, structured turn reasons, and package roots that expose only caller-owned operations; the subagent seam has a harness-native out-of-process backend whose children are full peers (own config, persistence, tools), and parent agents receive minimal safe child/SDK failure facts through the same SDK path; the SDK profile pins both successful and failed delegation behavior through the SDK path itself.

**Paid**: a third package in the `sdk/` group and a fourth subagent backend to keep current; the SDK backend boots a complete plugin tree per child (heavier per-run than an ACP child; pooling remains future work, same as ACP); the wire still has no cancel method, so both the SDK's `RequestTimeoutError` and the backend's dispose settle locally while the server-side turn runs on until process teardown; fixtures for the snapshot suite were recorded against `deepseek-v4-flash` and re-record on model-behavior drift like every other recorded corpus.
