Merge branch 'codex/simp-ui-identity-residue' into codex/simp-hide-concrete-agent-loop

# Conflicts:
#	packages/ui/acp/src/index.ts
This commit is contained in:
Tianyi Cui
2026-07-19 02:21:21 +08:00
7 changed files with 86 additions and 300 deletions

View File

@@ -20,20 +20,14 @@ export interface AcpConfig {
provider?: string
/** Model name for created agents (must have a registered adapter). */
model?: string
/**
* Transport stream override. Production omits this (the plugin wires
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
* in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive
* the bridge without a subprocess. Not part of the schemastery `Config` —
* it is a runtime-only seam, never set from a `cordis.yml`.
*/
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
```
Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts)
Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-demo`

View File

@@ -49,39 +49,39 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin
#### The seam: mechanism and policy split
After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` `allowed-once` / `rejected` / `cancelled` / `unavailable`. `ApprovalRequest` is a readonly same-process contract, so the service borrows its routing identity and cancellation signal instead of copying the record or capturing a parallel callback bundle. It dispatches the `approval/request` waterfall, races the request signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the request agent's session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design.
After validation and a successful `approval/asked` append, the service resolves the `approval/request` waterfall to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. It borrows the readonly request identity and signal, treats abort as `cancelled`, contains answerer failures and invalid returns as `unavailable`, discards late answers, and appends the paired `approval/decided` event. Pre-commit audit failures reject; post-append observer failures cannot undo an authoritative event. `allowed-once` authorizes only the asked action, and `request()` rejects outside an open turn so the audit pair remains inside the durable commit boundary.
Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates.
Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates.
`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller retains ownership and honors the readonly contract for the duration of `request()`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call.
`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Tool arguments stay on the already-streamed call that a UI references by `callId`.
#### Ask routing in dsh-tools
`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to guards and dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: with no ApprovalService, or after one unmounts, the next ask fails closed without gating the registry's fiber. An agent-less execution also fails closed — without an agent there is no session to audit to and no UI to route to.
`ToolRegistry.execute()` resolves `ask` before dispatch: `allowed-once` proceeds, while rejection, cancellation, and channel absence produce distinct deny reasons. Opportunistic `ctx.get('approval')` consumption lets an absent or unmounted service fail closed without gating the registry fiber. Agent-less execution also fails closed because it has neither an audit session nor a UI owner.
#### The per-session policy tier
The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged and falls through to fail-closed `'unavailable'` when nobody answers. Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections.
The seam also owns the session-scoped `'ask' | 'never'` policy described by [the sandbox RFC](2026-07-06-sandbox.md). Effective policy is folded from logged switches over the deployment default. `'never'` resolves to `rejected` inside `request()` before any answerer can run; `'ask'` dispatches and otherwise falls through to `unavailable`. The prompt states only deterministic `'never'`, switch narration is coalesced, and every request still records the audit pair.
#### The ACP answerer
The bridge registers the first real answerer: it resolves the owning session through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once``allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled``cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment.
The ACP bridge answers only for an exact agent object owned by its forward session map. It attaches `session/request_permission` to the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all.
The answerer routes through the bridge's exact-agent ownership check described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md).
#### Audit, and what the model sees
`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`.
`approval/asked` and `approval/decided` are durable log-only events; the model sees only the ordinary tool result derived from the outcome. Successful completion commits one `decided` per `asked`, including cancellation and contained answerer failure. Idle requests append neither event; a pre-commit failure rejects, while failure of the second append can leave an already-committed `asked` unmatched.
#### Entities and dependencies
One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (event-map merge + append), `dsh-agent` (the `Agent` type), `dsh-llm` (`CallId`, via `dsh-brand`). `dsh-tools` and `dsh-acp` each peer on it; the escalation phase's asker lives in `dsh-tool-bash` (see [the sandbox RFC](2026-07-06-sandbox.md) § Escalation), so the sandbox family keeps its ZERO-edge relation (the executor contributes the per-call override mechanism, and transport seams never ask humans questions). The seam is one package, not the capability-seam three: the service body (dispatch + audit) has no replaceable implementation — the replaceable part is the answerer listeners, and those live with their owners (the bridge; future terminal UIs; test scripts). `@cordisjs/plugin-capability` stays orthogonal (a static grant registry answers "is this already authorized", not "ask the user now"), and `subagent-acp`'s child-side `permission` auto-answer is untouched — routing a child's approvals to the parent session is deferred (§ Deferred).
`dsh-user-approval` depends on Cordis plus the session, agent, and branded-call contracts; `dsh-tools` and `dsh-acp` consume it. The sandbox executor stays independent because `dsh-tool-bash` owns escalation requests. The fixed dispatch-and-audit service remains one package; replaceable answerers live with their channel owners. Static capability grants and `subagent-acp` child-side permission answers remain separate concerns.
### Testing
Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), scoped routing, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`.
Unit tests pin outcomes, first-wins delegation, containment, cancellation, scoped routing, audit pairing, the unbypassable `'never'` policy, tool deny reasons, and ACP ownership/outcome mapping through a real scripted bridge.
Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing).
Snapshots record allowed and rejected sandbox escalation through `session/request_permission`, plus the `'never'` prompt and policy-switch notice. Unscripted permission prompts cancel and fail closed.
## Deferred
@@ -102,12 +102,10 @@ Snapshot tier: the harness accepts scripted permission answers (`permissionAnswe
The implemented contract is pinned by the suites in Testing:
- With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason.
- A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)).
- Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection.
- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair.
- Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor.
- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request.
- `allowed-once` dispatches one action; every other outcome denies with a distinct reason, and `'never'` rejects before prompting.
- Missing, foreign, agent-less, throwing, invalid, and disconnected answer paths fail closed.
- Successful requests route by exact agent ownership and append one replayable, model-invisible audit pair; idle and pre-commit failures reject.
- ACP ownership keeps prompts inside their session, while a deployment without the service emits no prompt or audit events.
Costs and accepted limits:
@@ -117,8 +115,6 @@ Costs and accepted limits:
## FAQ
Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that.
- **What happens in a deployment with no answerer at all (headless, CI)?** Every ask falls through the empty waterfall to `unavailable` and the tool call denies with the "no approval channel is available" reason. Fail-closed is the zero-listener default, not a configuration.
- **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred).
- **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel.

View File

@@ -28,7 +28,7 @@ The config-driven path keeps `agents[].id` as a stable configuration label, not
- Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place.
- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state.
- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC caches only local disposable-child parent lineage while leaving remote runs outside its local-session notification pair.
- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC forwards only lifecycle events whose service-snapshotted `local` flag is true, obtains the delegating parent from the scoped event carrier, and keeps no child identity or lineage cache.
- The config-driven resume-or-create policy is explicit and covered across a durable restart.
- A production listener search kept `agent/created`/`agent/disposed` and their publication semantics.
- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass.

View File

@@ -121,7 +121,7 @@ export interface SubagentRunInfo {
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Whether the provider exposed an exact published in-process child. */
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
}
@@ -133,7 +133,7 @@ export interface SubagentRunEndInfo {
readonly provider: string
/** The child agent's id. */
readonly id: SessionId
/** Whether the provider exposed an exact published in-process child. */
/** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */
readonly local: boolean
/** The terminal stop reason. */
readonly stopReason: SubagentResult['stopReason']

View File

@@ -1,36 +1,7 @@
/**
* The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that
* exposes the harness agent as an ACP server over JSON-RPC stdio, so editors
* (Zed and other ACP clients) can drive it. The structured analogue of the
* readline `stdio-chat` plugin.
*
* This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes
* the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory,
* and `dsh-session-persistence` (for `session/load`). It maps:
*
* - `initialize` → protocol-version negotiation, text-only capabilities
* - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })`
* - `session/load` → `ctx.agents.resume(...)` then replay the event log
* - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn
* that ends in `error` rejects the RPC)
* - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a
* running step, clears queued + steering work, and drops a
* turn about to start) + settle the in-flight prompt
*
* Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
* its own concrete `Agent`. Sessions are keyed by their shared agent/session id;
* every `session/event` and `agent/*` event is routed strictly to its owning
* session record, and each `session/update` carries that id. Concurrent updates
* may alternate on the shared connection without crossing session attribution.
* Permission prompts use the same identity: the bridge answers
* `approval/request` for its own agents over `session/request_permission` (see
* the approval answerer below) — whether a call ASKS is policy (a hook or
* plugin returning `ask`), not the bridge's.
*
* stdout is the protocol: this plugin must run in an example 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 and
* RFC 010 § Risks.
* Multi-session ACP bridge over JSON-RPC stdio. Creates or resumes agents,
* routes session-scoped events and approvals, and settles prompts by turn.
* Stdout is reserved for protocol frames.
*
* @module @deepseek-ai/dsh-acp
*/
@@ -104,27 +75,15 @@ import {
} from './codec.ts'
export const name = 'acp'
// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction.
// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`.
// Interface services back loading, presentation, interaction, and prompt assembly.
export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
/**
* Build an ACP "invalid params" error whose human detail rides in the message.
* `RequestError.invalidParams(data, additionalMessage)` keeps the standard
* "Invalid params" message and appends `additionalMessage`, so we pass the
* detail as `additionalMessage` (and no structured `data`).
*/
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/**
* Build an ACP "internal error" whose human detail rides in the message. Used
* to reject a `session/prompt` whose turn ended in failure: a plain `Error`
* thrown from a method handler is flattened to a generic "Internal error" on
* the wire, so we wrap the detail in the SDK's `RequestError.internalError`
* (which appends `additionalMessage`) to surface *why* the turn failed.
*/
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
@@ -249,13 +208,7 @@ export interface AcpConfig {
provider?: string
/** Model name for created agents (must have a registered adapter). */
model?: string
/**
* Transport stream override. Production omits this (the plugin wires
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
* in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive
* the bridge without a subprocess. Not part of the schemastery `Config` —
* it is a runtime-only seam, never set from a `cordis.yml`.
*/
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
@@ -292,30 +245,11 @@ interface ModelCatalogEntry {
/** Per-session bridge state keyed by ACP session id. */
interface SessionRecord {
agent: Agent
/**
* The owned-agent disposer (from the {@link AgentHandle} the factory returned).
* Teardown calls it to unregister this ONE agent, stop its loop, await
* quiescence, and remove its session — instead of leaving it for the bridge
* fiber to reclaim.
*/
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
dispose: () => Promise<void>
/**
* Resolves tool-owned presentation for THIS session's tool calls and remembers
* each in-flight call's `(name, args)` so the matching `tool/result` can find
* its tool. Per-session so two concurrent sessions never cross their in-flight
* tool state.
*/
/** Per-session tool presentation and call/result correlation. */
presenter: ToolPresenter
/**
* Whether THIS session renders shell tools as terminal cards — snapshotted
* from the client's `_meta.terminal_output` capability at session creation
* (`session/new`/`session/load`), NOT re-read live. A capability snapshot per
* session means the `tool_call` (which registers the terminal) and the matching
* `tool_call_update` (which streams its output) ALWAYS agree, even if a later
* `initialize` mutates the connection-level capability between them — otherwise
* a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal,
* result terminal) or clobber the card (call terminal, result non-terminal).
*/
/** Terminal capability snapshot shared by matching call and result updates. */
terminalEnabled: boolean
/** Session-local provider/model selection and the current step snapshot. */
target: LlmTargetRef
@@ -325,18 +259,7 @@ interface SessionRecord {
reject: (error: Error) => void
turn: number | undefined
} | undefined
/**
* Config switches accepted while the session was IDLE, not yet anchored in
* its log. The turn-enclosure contract makes a bare between-turns append
* invalid (the JSONL backend treats a post-`turn/end` tail as crash
* garbage, and dev invariants throw), so an idle switch waits here and is
* anchored at the next turn's prompt-submit — before anything in that
* turn assembles a prompt or runs a call, and last write
* per knob wins (an idle flip-flop anchors as one event). Until anchored,
* the switch lives only in bridge memory: the set/new/load responses
* overlay it truthfully, and a restart before the next turn reverts it —
* which `session/load` then reports honestly from the log's fold.
*/
/** Last idle switch per knob, anchored before the next prompt assembles. */
pendingSwitches: { preset?: string }
}
@@ -347,21 +270,15 @@ interface SessionRecord {
* correlation in a `finally` so presentation failure cannot starve settlement.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// Capture the injected services NOW, during apply(), while we are inside this
// plugin's fiber (where `inject` grants access). The ACP method handlers run
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
// NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` /
// `ctx.sessionPersistence` lazily inside a handler throws "cannot get property
// … without inject". Resolving the references here and closing over them keeps
// the handlers working regardless of which fiber later invokes them.
// ACP handlers execute outside this plugin's injection scope, so capture
// injected services during apply(); lazy service reads in a handler fail.
const agents = ctx.agents
const llm = ctx.llm
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
const tools = ctx.tools
const userInteraction = ctx.userInteraction
// A new ToolPresenter per session (and a throwaway per load replay), each given
// this warn sink so a throwing tool presenter is logged, not propagated.
// Presenter callbacks are contained so display failures cannot break protocol handling.
const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent)
/** Resolve a complete target only; partial config remains available to other request listeners. */
@@ -459,23 +376,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
}
}
// TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map.
// Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together.
// Dropping the forward record lets the weak reverse entry expire.
const sessions = new Map<SessionId, SessionRecord>()
// Session ids whose `session/load` is mid-`resume()` (the slot is reserved
// before the async resume so a pipelined load/new for the SAME id can't create
// two agents). Distinct ids load concurrently; a given id loads once at a time.
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
const loadingIds = new Set<SessionId>()
// Set once the bridge has torn down (disposal or client disconnect). An async
// `session/load` mid-`resume()` when teardown ran must observe this after its
// await and NOT install a record (which would resurrect a live agent/listeners
// after the bridge closed). Checked after every load await.
// Async creation checks this after awaits to avoid publishing after teardown.
let closed = false
// Connection-level terminal capability from the latest `initialize`; false
// before initialization. Each `session/new` or `session/load` snapshots it in
// `SessionRecord.terminalEnabled`, so later initialization affects only future
// sessions.
// Each new or loaded session snapshots the latest connection capability.
let terminalOutputCap = false
// Assigned at the bottom, before any agent event can fire (a session only
@@ -674,22 +580,13 @@ export function apply(ctx: Context, config: AcpConfig): void {
currentValue,
options: [
...presets.names.map((name: string) => presets.optionOf(name)),
// The derived not-a-preset state: visible exactly while it IS the
// current value (a knob state outside the table), switchable FROM,
// never a target — set() below rejects it like any unknown name.
// `custom` echoes the current derived state but is never a target.
...currentValue === 'custom' ? [presets.optionOf('custom')] : [],
],
}]
}
/**
* Whether the session's log currently has an open turn — the last boundary
* event is a `turn/start`. Decides whether a config switch may append NOW
* (enclosed) or must wait for the next turn (see
* {@link SessionRecord.pendingSwitches}). Read from the LOG, not
* `agent.status`: status stays `running` across the gap between two queued
* turns, where a bare append would still land outside any turn.
*/
/** Whether the log has an open turn in which a config switch can be enclosed. */
const isTurnOpen = (agent: Agent): boolean => {
const events = agent.session.events
for (let index = events.length - 1; index >= 0; index -= 1) {
@@ -700,12 +597,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
return false
}
/**
* Anchor a record's pending switches into its (just-opened) turn, last
* write per knob — skipping a value the session already effectively has,
* so a net-zero idle flip-flop anchors NOTHING (the log records switches,
* not select clicks).
*/
/** Anchor last-write-wins idle switches into a just-opened turn. */
const flushPendingSwitches = (rec: SessionRecord): void => {
const pending = rec.pendingSwitches
rec.pendingSwitches = {}
@@ -717,15 +609,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
presets.set(rec.agent.session, pending.preset)
}
// Idle-accepted switches anchor at the next turn's prompt-submit: the turn
// is open (the seam fires inside it, per drained message — the first flush
// empties the slot, later ones no-op), the loop has not yet assembled
// anything for it, and — unlike appending from inside a `session/event`
// listener — this seam fires OUTSIDE any log emit, so peer listeners
// (the dev invariants, persistence) observe the anchored events in strict
// log order. A turn with no prompt (an idle inject's one-shot injection
// turn) leaves the switch pending — it runs no step, so nothing executes
// or assembles under a stale value.
// Prompt-submit is inside the new turn but before prompt assembly. Promptless
// injection turns leave the switch pending because they execute no request.
ctx.on('agent/prompt-submit', (agent, _content, _source, next) => {
const rec = ownedRecord(agent)
if (rec !== undefined) flushPendingSwitches(rec)
@@ -780,10 +665,8 @@ export function apply(ctx: Context, config: AcpConfig): void {
agentOptions: agentOptions(config),
setup: (agentCtx) => { installTarget(agentCtx, target) },
})
// Creation is now asynchronous because it awaits the unpublished setup
// transaction. A client disconnect can therefore close this bridge
// after the entry check but before the handle resolves; never install a
// post-close record that quiesce() could not have seen.
// Agent creation may resolve after the bridge closes; dispose the handle
// instead of publishing a record that teardown could not observe.
/* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC
immediately on close; real stdio may let the handler resume */
if (closed) {
@@ -991,10 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
if (presets === undefined) {
throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`)
}
// A no-op switch (the value the session already shows — pending,
// else derived) is acknowledged FIRST and records nothing:
// clients re-push current selections on session start, and the
// derived 'custom' current is only ever valid as such an echo.
// A current-value echo is acknowledged without recording a switch.
const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events)
if (params.value === current) break
if (!presets.names.includes(params.value)) {
@@ -1236,27 +1116,16 @@ export function streamSessionEventUpdate(
}
/**
* Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires
* `content` + `priority` + `status`, but a {@link TodoItem} carries no priority,
* so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the
* harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole
* plan on each `plan` update, matching the harness's whole-list-replace
* semantics, so no per-entry diffing is needed.
* @param todos - the harness todo list (the whole list, not a diff).
* @returns the ACP plan body, one entry per todo.
* Map a whole harness todo list to an ACP replacement plan, using medium
* priority because harness todos do not carry one.
* @param todos - complete harness todo list.
* @returns one ACP plan entry per todo.
*/
export function todosToPlan(todos: TodoItem[]): Plan {
return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) }
}
/**
* Per-session terminal-rendering context threaded into
* {@link streamSessionEventUpdate}: whether the client advertised the
* `_meta.terminal_output` capability, and the session's workspace cwd (the
* default terminal-card header when a tool doesn't supply its own). Kept out of
* the pure translator's required params so the no-capability / no-presenter
* tests stay terse.
*/
/** Per-session terminal capability and workspace used while translating updates. */
export interface TerminalRendering {
enabled: boolean
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
@@ -1267,47 +1136,21 @@ export interface TerminalRendering {
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
/**
* Resolves tool-owned presentation for a session's tool-call events. A tool
* declares `presentCall`/`presentResult` (see `dsh-tools`) returning a
* `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up
* by name in the registry and applies a generic fallback when a tool defines
* neither. The returned view is what {@link streamSessionEventUpdate} switches on.
*
* The `tool/result` session event does NOT carry the tool name or args — so to
* call a tool's `presentResult` (which needs both), the presenter remembers each
* `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the
* matching result. The map is bridge-LOCAL (not a change to the event schema or a
* core service): one presenter per live session
* (and a throwaway per `session/load` replay), and each entry is removed when its
* result arrives. In the normal loop a `tool/call` is always followed by a
* `tool/result` (the registry turns even a thrown tool into an isError result),
* so the map holds only currently-in-flight calls. The one exception is a step
* torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
* leave a single stale entry per such call; this is bounded by the session
* lifetime (the whole presenter is dropped on teardown) and never affects
* correctness — a later result for a different callId is unaffected, and the
* stale entry's only cost is one map slot until the session ends.
* Resolve tool-owned call/result views with a generic fallback. Per-session
* state correlates results with call arguments; interrupted calls may retain an
* entry only until that session's presenter is discarded.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
/**
* @param tools the registry to resolve tool definitions by name.
* @param onError invoked when a tool's `presentCall`/`presentResult` THROWS;
* the presenter swallows the error and falls back to the generic
* presentation so a buggy display callback can never fail a live turn or a
* `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the
* boundary"). Defaults to a no-op for callers that don't supply a logger.
* @param tools - registry used to resolve executing definitions.
* @param onError - contained presenter-error sink before generic fallback.
* @param agent - optional scoped registry view for the executing agent.
*/
constructor(
private readonly tools: Pick<ToolRegistry, 'get'>,
private readonly onError: (message: string) => void = () => {},
/**
* The agent whose view resolves tool presentations: a scoped/shadowed
* tool presents with ITS OWN presentCall/presentResult — the same
* definition that executed — not a same-named global's. Absent (a replay
* with no live agent) the global view presents.
*/
private readonly agent?: Agent,
) {}
@@ -1316,10 +1159,8 @@ export class ToolPresenter {
* for the matching result.
* @param callId - the call id the matching `tool/result` will look up.
* @param name - the tool name, resolved against the registry for `presentCall`.
* @param argsJson - the raw arguments JSON from the event; parsed for the view
* (a non-JSON string is surfaced raw).
* @returns the tool-owned view, or the generic fallback (title = tool name,
* kind `other`, parsed args as raw input) when the tool defines none or threw.
* @param argsJson - raw event arguments parsed for presentation.
* @returns the tool-owned view or generic fallback.
*/
call(callId: CallId, name: string, argsJson: string): ToolCallView {
const args = parseToolArguments(argsJson)
@@ -1331,10 +1172,7 @@ export class ToolPresenter {
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
present = undefined
}
// No tool-owned presentation: fall back to the tool name as the title, the
// full parsed args as the raw input, and kind `other` (the generic card).
// The kind is never sniffed from the name — the bridge does not special-case
// tool names; a tool that wants a richer kind declares `presentCall`.
// Tool names never imply presentation kind; richer cards are tool-owned.
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
this.pending.set(callId, { name, args, card: view.card })
return view
@@ -1343,14 +1181,11 @@ export class ToolPresenter {
/**
* Completed-state render intent for a `tool/result`; consumes the remembered
* `(name, args, card)`.
* @param callId - the id of the matching `tool/call`; an unknown or late id
* falls back to the raw content.
* @param content - the result's content blocks (the fallback and fill-in body).
* @param callId - matching call id; unknown or late ids use raw content.
* @param content - result content used by the fallback and fill-in body.
* @param isError - whether the result is an error, forwarded to `presentResult`.
* @param meta - the result's machine-readable meta, forwarded when present.
* @returns the tool-owned view — an orphaned `terminal` result (no terminal
* call side) and a content-less `generic` are normalized — or the raw-content
* generic card when the tool defines no `presentResult` or threw.
* @returns a normalized tool-owned view or raw-content fallback.
*/
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
@@ -1420,25 +1255,11 @@ type AcpToolCallContent =
| { type: 'diff'; path: string; oldText: string | null; newText: string }
| { type: 'terminal'; terminalId: string }
/**
* Relativize a file card's TITLE path against the session workspace cwd, so a
* card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the
* reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the
* card's `locations`/`diff` paths stay RAW (the editor opens the real path). The
* pure tool presenter can't see the session cwd, so this happens here where the
* bridge knows it. The rewrite is an exact substring replace of the known raw
* path (a card carries the same path in `locations[0]`/`diffs[0]`), never a
* heuristic. A path outside the workspace, or an absent/relative session cwd, is
* left unchanged.
*/
/** Relativize only in-workspace title text; location and diff paths stay raw. */
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
const rel = relativePath(sessionCwd, rawPath)
// Only relativize a target that stays INSIDE the workspace. `relative` prefixes
// a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone
// or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x`
// (a real in-workspace name) still relativizes. Never relativize to the empty
// string (rawPath === cwd — a non-file target).
// Test the `..` segment, not a character prefix: `..cache/x` is in-workspace.
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
return title.split(rawPath).join(rel)
}

View File

@@ -1,26 +1,26 @@
# @deepseek-ai/dsh-jsonrpc
The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC server that lets an out-of-process SDK client (e.g. the Python `deepseek_harness` package) drive DeepSeek Harness agents without touching Cordis. The client speaks newline-delimited JSON-RPC on the process stdin/stdout ([`HarnessSdkServer`](src/server.ts): `initialize``session/prompt``shutdown`, with `session.event` / `session.finished` / `subagent.*` notifications over [`JsonRpcLineTransport`](src/transport.ts)). The SDK-client analogue of the [`acp`](../acp/README.md) bridge, split the same way: this package is the protocol plugin, while [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) is the app bin that boots a `cordis.yml` around it — which process serves this protocol is a config decision, not a hardcoded bin. This plugin is the serving face of the [single-executable distribution plan](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application.
## Wiring
`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It forwards subagent completions only when the lifecycle payload's `local` flag was snapshotted from the provider's exact in-process child; reusable provider names, child ids, and durable lineage never establish locality. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`.
## Config
No `cordis.yml`-settable keys. The `JsonRpcConfig` fields (`input`, `output`, `exit`) are runtime-only test seams so a spec can drive the server over in-memory streams without a subprocess or a killed test process; production always serves the process stdio and exits via `process.exit`.
There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`.
## stdout is the protocol
The process stdout this plugin runs in carries only JSON-RPC frames. The tree that loads it must load NO stdout logger (a console logger corrupts the frames) — the guarantee is config-only, same as the ACP bridge. Diagnostics go to stderr.
Stdout carries only JSON-RPC frames. The deployment must not compose a stdout logger; diagnostics belong on stderr.
## Shutdown and exit semantics
The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first (the response frame flushes), then the plugin disposes its own fiber — running the effect disposer: an idempotent `server.shutdown()` (every SDK-created agent disposed to quiescence, event subscriptions detached) plus `transport.close()` — and exits the process with code 0. Own-fiber disposal is deliberate: the request's `server.shutdown()` already flushed all SDK-owned session state, and the process exit that follows is the teardown of the rest of the tree. Process-level exits (stdin EOF → 0, SIGTERM → 0, SIGINT → 130) belong to the app bin, which disposes the whole root context. Fiber disposal WITHOUT a `shutdown` request (HMR-style unload) just stops serving — it never exits the process.
The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process.
## Wire notes
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). A session accepts at most one in-flight `session/prompt`; an overlapping prompt for the same `sessionId` fails immediately through the standard handler-error response, while other sessions remain independent and the same session can be reused after the active prompt settles. Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies.
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and persona come from `cordis.yml`.
## Model Experience

View File

@@ -1,13 +1,6 @@
/**
* `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin
* serves to out-of-process SDK clients (e.g. the Python `deepseek_harness`
* package). Requests: `initialize` → `session/prompt`* → `shutdown`.
* Notifications pushed to the host: `session.event` (every durable session
* event, verbatim), `session.finished` (per prompt turn settle),
* `subagent.started` / `subagent.finished` (child-session lineage and run
* outcomes). The server owns only the SDK-facing session map — the harness
* itself is the context the plugin mounts in; plugins, persistence, and
* the LLM adapter set all come from the external `cordis.yml`.
* JSON-RPC method and notification surface for out-of-process harness SDKs.
* The surrounding context owns plugins, persistence, and configured adapters.
*
* @module @deepseek-ai/dsh-jsonrpc/server
*/
@@ -23,7 +16,7 @@ import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { JsonRpcTransportPeer } from './transport.ts'
/** Parameters of the `initialize` request (once per process, before any prompt). */
/** Parameters for the process-wide SDK handshake. */
export interface InitializeParams {
/** Working directory recorded on every SDK-created session's header. */
cwd: string
@@ -33,16 +26,13 @@ export interface InitializeParams {
model: string
}
/** Result of the `initialize` request: the server's identity for the SDK handshake. */
/** Wire-stable server identity returned by initialization. */
export interface InitializeResult {
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
serverInfo: { name: string; version: string }
}
/**
* Parameters of a `session/prompt` request: one user turn on one SDK session,
* with at most one in flight per session.
*/
/** One user turn on one SDK session. */
export interface SessionPromptParams {
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
sessionId: string
@@ -50,7 +40,7 @@ export interface SessionPromptParams {
contentBlocks: ContentBlock[]
}
/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */
/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */
export interface SessionPromptResult {
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
accepted: true
@@ -62,20 +52,12 @@ interface SessionRecord {
activePrompt: boolean
}
/** Recover the delegating parent carried by every service-owned subagent lifecycle event. */
/** Recover the delegating parent from the service-owned scoped carrier. */
function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
// SubagentService emits this lifecycle pair only through scopeTarget(this, parent).
return carrierKeyOf(carrier) as Agent
}
/**
* The SDK server over a booted harness context. Constructing it subscribes to
* session and subagent lifecycle events, forwarding durable session
* events and SDK-facing completion notifications while retaining local-run
* identity across child disposal. The subscriptions live until
* {@link shutdown}. One instance serves one transport peer for the process
* lifetime — there is no re-`initialize`.
*/
/** SDK server whose subscriptions and created agents live until {@link shutdown}. */
export class HarnessSdkServer {
private cwd = process.cwd()
private provider = 'deepseek'
@@ -125,10 +107,9 @@ export class HarnessSdkServer {
}
/**
* Record cwd and provider/model, mounting the DeepSeek adapter only when the
* `deepseek` provider route has no configured owner.
* @param params - the SDK handshake parameters.
* @returns the server identity for the handshake.
* Configure the SDK route, mounting the DeepSeek fallback only when unowned.
* @param params - SDK handshake parameters.
* @returns server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise<InitializeResult> {
this.cwd = resolve(params.cwd)
@@ -142,13 +123,9 @@ export class HarnessSdkServer {
}
/**
* Handle `session/prompt`: get-or-create the session's agent, send the
* content as the user message, await turn settle (quiescence), then notify
* `session.finished` with the settled turn's outcome. A session accepts at
* most one prompt at a time; an overlapping request fails immediately while
* other sessions remain independent.
* @param params - the target session id and prompt content.
* @returns `{ accepted: true }` after the turn settled.
* Run one prompt to settlement; overlap on the same session fails.
* @param params - target session and user content.
* @returns acceptance after the turn settled.
*/
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
const rec = await this.getOrCreateSession(params.sessionId)
@@ -171,11 +148,9 @@ export class HarnessSdkServer {
}
/**
* Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop
* quiescence), unmount the adapter fiber this server mounted (if any), and
* detach the event subscriptions. The CONTEXT stays up — the bin disposes it
* as part of process exit.
* @returns an empty object (the JSON-RPC result).
* Dispose server-owned agents, adapter, and subscriptions to quiescence.
* The surrounding context remains running.
* @returns empty JSON-RPC result.
*/
shutdown(): Promise<Record<string, never>> {
this.shutdownTask ??= this.performShutdown()