Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue

# Conflicts:
#	docs/config-catalog.md
#	docs/event-producer-consumer.md
#	docs/rfc/implemented/feature/2026-07-06-approval-seam.md
#	packages/ui/acp/README.md
#	packages/ui/acp/src/index.ts
#	packages/ui/jsonrpc/README.md
#	packages/ui/jsonrpc/src/server.ts
This commit is contained in:
Tianyi Cui
2026-07-14 18:55:51 +08:00
508 changed files with 3487 additions and 9370 deletions
+18 -79
View File
@@ -1,30 +1,10 @@
/**
* The SDK-facing stdio JSON-RPC server plugin: mounting it wires a
* {@link JsonRpcLineTransport} over the process stdio and serves
* {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`,
* plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK
* client (e.g. the Python `deepseek_harness` package). The structured
* SDK-client analogue of the `acp` bridge: a client-driver plugin over
* `ctx.agents`, not a loop change and not a capability seam. Which process
* actually serves this protocol is a `cordis.yml` decision — the tree that
* loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such
* a tree for the single-exe distribution; see
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
*
* stdout is the protocol: this plugin must run in a tree that loads NO stdout
* logger (the console logger writes to stdout and would corrupt the JSON-RPC
* frames). The guarantee is config-only — see the package README.
*
* Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the
* `shutdown` request answers first, then the plugin disposes its own fiber and
* exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM,
* SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the
* whole root context.
*
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
* export — the cordis Loader's `unwrapExports` does `exports.default ??
* exports`, so a stray default would collapse the module to the bare `apply`
* and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001).
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
* whether to load it; see the single-executable RFC 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
*/
@@ -39,65 +19,29 @@ export * from './server.ts'
export * from './transport.ts'
export const name = 'jsonrpc'
// The server programs against the agent factory only: `agents` is read on
// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM
// seam is deliberately NOT injected — `initialize` reads it opportunistically
// via `ctx.get('llm')` (the topology-independent lookup for a non-injected
// service, per packages/AGENTS.md) to decide whether to lazily mount the
// DeepSeek adapter for the requested model.
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
export const inject = ['agents']
/**
* Plugin config. Every field is a runtime-only test seam — none is part of the
* schemastery {@link Config}, so nothing here is settable from a `cordis.yml`
* (production always serves the process stdio and exits via `process.exit`).
*/
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
export interface JsonRpcConfig {
/**
* Transport input override. Production omits this (the plugin reads
* `process.stdin`); tests inject an in-memory `Readable` to drive the server
* without a subprocess.
*/
/** Transport input override; production uses `process.stdin`. */
input?: Readable
/**
* Transport output override. Production omits this (the plugin writes
* `process.stdout` — the protocol channel); tests inject an in-memory
* `Writable` to capture frames.
*/
/** Transport output override; production uses `process.stdout`. */
output?: Writable
/**
* Process-exit override for the `shutdown` request path. Production omits
* this (`process.exit`); tests inject a recorder so a driven shutdown does
* not kill the test process.
*/
/** Process-exit override; production uses `process.exit`. */
exit?: (code: number) => void
}
export const Config: Schema<JsonRpcConfig> = Schema.object({})
/**
* Mount the SDK server on the process stdio: build the line transport and
* {@link HarnessSdkServer}, dispatch incoming requests, and start reading
* frames. Disposal is an effect: disposing this plugin's fiber runs
* `server.shutdown()` (disposes every SDK-created agent to quiescence and
* detaches the event subscriptions) and `transport.close()`.
*
* The `shutdown` request's process-exit semantics live HERE, because the
* plugin owns the server and transport: the request is answered first, an
* explicit output-write barrier confirms the response frame flushed, then the
* plugin disposes its
* OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the
* request's `server.shutdown()` already brought every SDK-created agent to
* quiescence (their session logs are flushed by the awaited agent-handle
* disposes), the fiber's effect disposer re-runs the idempotent shutdown and
* closes the transport, and the process exit that follows IS the teardown of
* the rest of the tree (the bin's EOF/signal handlers own root-context
* disposal for the process-level exits).
* 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 {
// Capture the fiber handle NOW, during apply(): the shutdown path runs LATER,
// from the transport's read loop, and must dispose exactly this plugin's
// fiber (cf. the injection-scope capture note in the acp bridge).
// 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
@@ -109,10 +53,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
const transport = new JsonRpcLineTransport(input, output)
const server = new HarnessSdkServer(ctx, transport)
// The shutdown-request exit path, exactly once (a second `shutdown` frame
// racing the dispose shares the same task). Flush and disposal failures are
// settled independently: once shutdown was answered, process exit is still
// the honest outcome and neither failure may prevent the next teardown step.
// Share one exit task and attempt flush and disposal independently before exiting.
let exitTask: Promise<void> | undefined
const disposeAndExit = (): Promise<void> => {
exitTask ??= (async () => {
@@ -126,9 +67,7 @@ export function apply(ctx: Context, config: JsonRpcConfig): void {
transport.onRequest(async (method, params) => {
const result = await server.handleRequest(method, params)
if (method === 'shutdown') {
// The transport writes the returned result after this handler resolves.
// Schedule the explicit flush barrier after that write, then dispose this
// plugin's fiber and exit 0 (see apply's doc).
// Run after the handler result is written; the task then flushes, disposes, and exits.
setImmediate(() => { void disposeAndExit() })
}
return result
+16 -31
View File
@@ -1,10 +1,7 @@
/**
* Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK
* server's stdio channel). One JSON frame per line; a frame with `id`+`method`
* is an incoming request, `id` alone matches a pending outgoing request, and
* `method` alone is a notification. Malformed lines are ignored (a resilient
* wire reader, not a validator); handler failures become JSON-RPC error
* responses, never a crashed transport.
* Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and
* `method` are requests, `id` alone is a response, and `method` alone is a
* notification. Malformed lines are ignored; handler failures become error frames.
*
* @module @deepseek-ai/dsh-jsonrpc/transport
*/
@@ -18,22 +15,18 @@ type RequestHandler = (method: string, params: Record<string, unknown>) => Promi
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
/**
* The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs
* to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s.
* Narrow on purpose so tests substitute a recording fake without a stream pair.
* Outbound request and notification surface used by {@link HarnessSdkServer}.
*/
export interface JsonRpcTransportPeer {
/**
* Send a request to the remote peer and await its response.
* Send a request and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
* @returns the remote peer's `result`; rejects on a JSON-RPC `error`
* response, a write failure, or transport/input closure.
* @returns the result; rejects on an error response, write failure, or closure.
*/
request(method: string, params: Record<string, unknown>): Promise<unknown>
/**
* Send a notification (no response expected). An omitted `params` sends no
* `params` member at all.
* Send a notification; omitted params produce no `params` member.
* @param method - the JSON-RPC method name.
* @param params - the optional notification parameters object.
*/
@@ -46,14 +39,10 @@ interface PendingRequest {
}
/**
* Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair.
* Inert until {@link start} attaches the input listeners; {@link close}
* detaches them and rejects every pending outgoing request (dispose-safe: the
* streams themselves are not destroyed — the caller owns them). Incoming
* requests are dispatched to the single {@link onRequest} handler (a missing
* handler answers `-32601 method not found`; a throwing handler answers
* `-32603` with the message); incoming notifications go to {@link
* onNotification} and are dropped without one.
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
* listeners; {@link close} detaches them and rejects pending requests without
* destroying the streams. Missing request handlers return `-32601`; handler
* failures return `-32603`. Notifications without a handler are dropped.
*/
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
private buffer = ''
@@ -78,8 +67,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Detach the input listeners and reject every pending outgoing request with
* "JSON-RPC transport closed". Safe to call without a prior {@link start}.
* Detach listeners and reject pending requests. Safe before {@link start}.
*/
close(): void {
this.input.off('data', this.onData)
@@ -89,7 +77,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Install THE handler for incoming requests (a later call replaces it).
* Install the request handler, replacing any prior handler.
* @param handler - resolves to the response `result`; a rejection becomes a
* `-32603` error response carrying the message.
*/
@@ -98,7 +86,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Install THE handler for incoming notifications (a later call replaces it).
* Install the notification handler, replacing any prior handler.
* @param handler - invoked per notification with the method and normalized
* params object.
*/
@@ -125,9 +113,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
/**
* Wait until every frame written before this call has reached the output's
* write callback. The empty queued write is a barrier and emits no protocol
* bytes.
* Wait for prior frame write callbacks. The empty barrier emits no bytes.
* @returns a promise that settles with the output write callback.
*/
flush(): Promise<void> {
@@ -170,8 +156,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
try {
message = JSON.parse(line)
} catch {
// Swallows ONLY JSON.parse syntax errors: a malformed wire line is a
// peer bug this resilient reader skips; nothing else runs in the try.
// Only JSON syntax errors reach this catch; malformed peer lines are ignored.
return
}
if (!message || typeof message !== 'object') return