@agent-compose/sdk 0.5.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/agent/agent-context.d.ts +1 -1
  2. package/dist/agent/agent-loop.d.ts +14 -12
  3. package/dist/agent/pause-client.d.ts +50 -0
  4. package/dist/agent/pause-client.test.d.ts +1 -0
  5. package/dist/agent/steer-control.d.ts +22 -6
  6. package/dist/client.d.ts +12 -1
  7. package/dist/index.d.ts +7 -5
  8. package/dist/index.js +2379 -1463
  9. package/dist/pause/checkpoint.d.ts +27 -10
  10. package/dist/pause/manager.d.ts +1 -0
  11. package/dist/pause/pause-core.d.ts +23 -0
  12. package/dist/pause/state-dir.d.ts +1 -1
  13. package/dist/processors/builtins.d.ts +20 -1
  14. package/dist/processors/gate-pause.d.ts +46 -0
  15. package/dist/processors/gate-pause.test.d.ts +1 -0
  16. package/dist/processors/index.d.ts +3 -1
  17. package/dist/processors/processor.d.ts +13 -0
  18. package/dist/runtimes/_acp-client.d.ts +140 -0
  19. package/dist/runtimes/_cli-agent.d.ts +155 -3
  20. package/dist/runtimes/amp.d.ts +2 -2
  21. package/dist/runtimes/cli-agent-acp-live.test.d.ts +30 -0
  22. package/dist/runtimes/cli-agent.test.d.ts +22 -6
  23. package/dist/runtimes/codex.d.ts +7 -2
  24. package/dist/runtimes/openai-desktop.js +2365 -1463
  25. package/dist/runtimes/vercel.js +389 -2
  26. package/dist/sandbox.d.ts +113 -19
  27. package/dist/step-invocation/__tests__/background-invoker.test.d.ts +1 -0
  28. package/dist/step-invocation/index.d.ts +2 -1
  29. package/dist/step-invocation/invoker.d.ts +36 -0
  30. package/dist/types/__tests__/environment-build-flag.test.d.ts +1 -0
  31. package/dist/types/__tests__/workflow-metadata-provider.test.d.ts +1 -0
  32. package/dist/types/execution-context.d.ts +0 -8
  33. package/dist/types/protocol.d.ts +32 -1
  34. package/dist/types/runtime.d.ts +14 -0
  35. package/dist/types/sandbox-environment.d.ts +6 -1
  36. package/dist/types/sandbox.d.ts +86 -6
  37. package/dist/types/workflow-metadata.d.ts +40 -10
  38. package/dist/types/workflow.d.ts +22 -6
  39. package/dist/utils/bundler.d.ts +5 -1
  40. package/dist/workflow-steps/observability.d.ts +28 -2
  41. package/dist/workflow-steps/types.d.ts +11 -7
  42. package/dist/workflow-steps/workflow.d.ts +3 -2
  43. package/package.json +3 -2
  44. package/src/agent/agent-context.ts +14 -6
  45. package/src/agent/agent-loop.ts +32 -10
  46. package/src/agent/pause-client.ts +108 -0
  47. package/src/agent/run-agent.ts +9 -4
  48. package/src/agent/steer-control.ts +21 -7
  49. package/src/client.ts +35 -1
  50. package/src/index.ts +20 -2
  51. package/src/pause/checkpoint.ts +33 -14
  52. package/src/pause/manager.ts +2 -2
  53. package/src/pause/pause-core.ts +35 -0
  54. package/src/pause/state-dir.ts +2 -2
  55. package/src/processors/builtins.ts +44 -1
  56. package/src/processors/gate-pause.ts +94 -0
  57. package/src/processors/index.ts +7 -0
  58. package/src/processors/processor.ts +13 -0
  59. package/src/runtimes/_acp-client.ts +516 -0
  60. package/src/runtimes/_cli-agent.ts +416 -3
  61. package/src/runtimes/claude.ts +31 -3
  62. package/src/runtimes/codex.ts +21 -1
  63. package/src/runtimes/vercel.ts +4 -1
  64. package/src/sandbox.ts +426 -56
  65. package/src/step-invocation/index.ts +2 -1
  66. package/src/step-invocation/invoker.ts +195 -84
  67. package/src/types/execution-context.ts +0 -8
  68. package/src/types/protocol.ts +27 -1
  69. package/src/types/runtime.ts +14 -0
  70. package/src/types/sandbox-environment.ts +12 -1
  71. package/src/types/sandbox.ts +84 -6
  72. package/src/types/workflow-metadata.ts +42 -10
  73. package/src/types/workflow.ts +22 -7
  74. package/src/utils/bundler.ts +6 -1
  75. package/src/workflow-steps/observability.ts +51 -5
  76. package/src/workflow-steps/runner.ts +9 -5
  77. package/src/workflow-steps/types.ts +11 -7
  78. package/src/workflow-steps/workflow.ts +3 -2
@@ -0,0 +1,516 @@
1
+ /**
2
+ * ACP client peer — the JSON-RPC half of a CLI-agent runtime (WS-C / ADR-0020).
3
+ *
4
+ * `CliAgentRunner` spawns an agentic CLI in **ACP agent** mode and delegates the
5
+ * whole wire protocol to this class. We are the ACP *Client*; the CLI is the
6
+ * *Agent*. `AcpClientPeer` owns:
7
+ *
8
+ * - the `ClientSideConnection` (from `@agentclientprotocol/sdk`) over an
9
+ * `ndJsonStream`-framed duplex wired to the subprocess stdio;
10
+ * - the SINGLE `session/update` → `AgentMessage` normaliser (replacing every
11
+ * per-CLI `mapEvent`) — one place, defensive, never throws on an unknown
12
+ * `sessionUpdate` variant (returns `[]`);
13
+ * - the `session/request_permission` handler — builds a `ToolCall`, runs it
14
+ * through the runner's `gateToolCall` (the shared processor chain), and maps
15
+ * the verdict back to a `PermissionOption.optionId`. A `PauseSignal` thrown
16
+ * by a processor MUST propagate (recognised via `isPauseSignal`) — never
17
+ * swallowed by a JSON-RPC try/catch, or the `run_pauses` row is silently
18
+ * lost (ADR-0020 Q4 / ADR-0006);
19
+ * - `session/cancel` driven by the `sendMessage` `AbortSignal`.
20
+ *
21
+ * We advertise NO `fs` / `terminal` client capabilities: the CLI runs in-sandbox
22
+ * with direct access to the factory drive, so we never serve file or terminal
23
+ * ops. We register no handlers for them — the `Client` interface methods are
24
+ * simply absent, so a (conformant) agent won't request them.
25
+ *
26
+ * The wire `protocolVersion` is pinned at integer 1 at `initialize`; the
27
+ * package version is pinned separately. Version negotiation drives the
28
+ * capability fallback that lives in `CliAgentRunner` (a non-1 response → the
29
+ * runner closes this peer and falls back to the legacy JSONL path).
30
+ */
31
+
32
+ import {
33
+ ClientSideConnection,
34
+ RequestError,
35
+ type Agent,
36
+ type Client,
37
+ type Stream,
38
+ } from "@agentclientprotocol/sdk";
39
+ import type * as acp from "@agentclientprotocol/sdk";
40
+ import type { AgentMessage } from "../types/protocol.js";
41
+ import type { ProcessorContext, ToolCall } from "../processors/processor.js";
42
+ import type { ToolCallGateResult } from "../types/runtime.js";
43
+ import { AsyncQueue } from "../agent/async-queue.js";
44
+ import { isPauseSignal } from "../pause/pause-core.js";
45
+ import { formatError } from "../utils/errors.js";
46
+
47
+ function now(): string { return new Date().toISOString(); }
48
+
49
+ /** The wire protocol version we pin at `initialize`. Bumped only on a breaking
50
+ * ACP wire change — never silently. The package version is pinned separately
51
+ * in package.json; this integer is the real compatibility gate (ADR-0020). */
52
+ export const ACP_PROTOCOL_VERSION = 1;
53
+
54
+ /** Permission option ids we answer `session/request_permission` with. The agent
55
+ * presents its own `options`; we pick the one whose `kind` matches our verdict.
56
+ * When no matching option is offered we fall back to these (a conformant agent
57
+ * always offers an allow + a reject option). */
58
+ const OPTION_KIND_FOR_VERDICT = {
59
+ allow: ["allow_once", "allow_always"],
60
+ deny: ["reject_once", "reject_always"],
61
+ } as const;
62
+
63
+ /** Collaborators `AcpClientPeer` needs from `CliAgentRunner`, kept as a small
64
+ * injected surface so the peer stays transport-agnostic and unit-testable. */
65
+ export interface AcpClientPeerDeps {
66
+ /** Duplex ACP stream (typically `ndJsonStream(stdinWritable, stdoutReadable)`
67
+ * wired to the subprocess). The peer does not own the subprocess — only the
68
+ * protocol over this stream. */
69
+ stream: Stream;
70
+ /** Run a proposed tool call through the runner's processor chain. Mirrors
71
+ * `ClaudeRunner.gateToolCall`. May throw `PauseSignal` (human-approval
72
+ * processor) — the peer re-raises it, never swallows it. */
73
+ gateToolCall(call: ToolCall, ctx: ProcessorContext): Promise<ToolCallGateResult>;
74
+ /** PROVIDER for the current turn's processor context (requestContext, abort
75
+ * signal, agentId, iteration). A provider — not a frozen value — because the
76
+ * peer is long-lived (persistent session) and reused across turns; it must
77
+ * read the LIVE turn's context so a permission->pause binds to the right
78
+ * iteration. The runner refreshes the backing value before each turn. */
79
+ procCtx: () => ProcessorContext;
80
+ /** Turn-scoped abort signal from `sendMessage`. On abort the peer sends
81
+ * `session/cancel` for the live session. */
82
+ signal?: AbortSignal;
83
+ }
84
+
85
+ // ── The single session/update → AgentMessage normaliser ───────────────────────
86
+
87
+ /** Pull readable text out of an ACP `ContentBlock` (text blocks only; image /
88
+ * audio / resource produce no text). */
89
+ function contentBlockText(block: acp.ContentBlock | undefined): string {
90
+ if (!block) return "";
91
+ return block.type === "text" ? block.text : "";
92
+ }
93
+
94
+ /** Render a tool call's `content[]` (content / diff / terminal blocks) to a
95
+ * readable string for the MVP renderer, and collect structured `diffs`.
96
+ * `terminal` blocks become a reference line (we never resolve `terminalId` —
97
+ * no terminal capability advertised). */
98
+ function renderToolContent(
99
+ content: acp.ToolCallContent[] | null | undefined,
100
+ ): { output: string; diffs: { path: string; oldText: string | null; newText: string }[] } {
101
+ const parts: string[] = [];
102
+ const diffs: { path: string; oldText: string | null; newText: string }[] = [];
103
+ for (const block of content ?? []) {
104
+ if (block.type === "content") {
105
+ parts.push(contentBlockText(block.content));
106
+ } else if (block.type === "diff") {
107
+ diffs.push({ path: block.path, oldText: block.oldText ?? null, newText: block.newText });
108
+ const header = block.oldText == null ? `--- (new file)\n+++ ${block.path}` : `--- ${block.path}\n+++ ${block.path}`;
109
+ parts.push(`${header}\n${block.newText}`);
110
+ } else if (block.type === "terminal") {
111
+ parts.push(`[terminal ${block.terminalId}]`);
112
+ }
113
+ }
114
+ return { output: parts.filter((p) => p !== "").join("\n"), diffs };
115
+ }
116
+
117
+ function mapLocations(
118
+ locations: acp.ToolCallLocation[] | null | undefined,
119
+ ): { path: string; line?: number }[] | undefined {
120
+ if (!locations || locations.length === 0) return undefined;
121
+ return locations.map((l) => (l.line != null ? { path: l.path, line: l.line } : { path: l.path }));
122
+ }
123
+
124
+ /** Build the `tool_result` for a terminal (`completed` / `failed`) tool call.
125
+ * Shared by the `tool_call_update` branch (the streaming, two-message shape)
126
+ * AND the `tool_call` start branch (a fast/sync tool that arrives already
127
+ * terminal with content/locations and no follow-up `tool_call_update`) — both
128
+ * must render diffs/locations/output identically (ADR-0020 Q3). */
129
+ function terminalToolResult(args: {
130
+ toolCallId: string;
131
+ status: "completed" | "failed";
132
+ content: acp.ToolCallContent[] | null | undefined;
133
+ locations: acp.ToolCallLocation[] | null | undefined;
134
+ timestamp: string;
135
+ }): AgentMessage {
136
+ const { output, diffs } = renderToolContent(args.content);
137
+ const locations = mapLocations(args.locations);
138
+ return {
139
+ type: "tool_result",
140
+ toolUseId: args.toolCallId,
141
+ output,
142
+ isError: args.status === "failed",
143
+ timestamp: args.timestamp,
144
+ ...(diffs.length > 0 ? { diffs } : {}),
145
+ ...(locations ? { locations } : {}),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Map ONE ACP `session/update` to zero-or-more `AgentMessage`s. The single
151
+ * normaliser (ADR-0020) — defensive: any variant a given CLI never emits is
152
+ * simply absent → `[]`, and an unknown `sessionUpdate` discriminator returns
153
+ * `[]` rather than throwing (mirrors the legacy `mapEvent` `default: return []`).
154
+ *
155
+ * Lifecycle (`init` / `done`) and turn-level errors are synthesised by the
156
+ * runner / `prompt()` result, NOT here. Per-turn token usage rides the
157
+ * `session/prompt` result (`PromptResponse.usage`), not `usage_update` — the
158
+ * `usage_update` session update is a context-window/cost gauge with no per-token
159
+ * fields, so it maps to nothing (ADR-0020 Q1 verified against the live zod types).
160
+ */
161
+ export function normalizeSessionUpdate(update: acp.SessionUpdate): AgentMessage[] {
162
+ const ts = now();
163
+ switch (update.sessionUpdate) {
164
+ case "agent_message_chunk":
165
+ return [{ type: "text", text: contentBlockText(update.content), timestamp: ts }];
166
+
167
+ case "agent_thought_chunk":
168
+ return [{ type: "thinking", text: contentBlockText(update.content), timestamp: ts }];
169
+
170
+ case "tool_call": {
171
+ // Always emit the tool_use. A `tool_call` start is the symmetric partner
172
+ // of the legacy codex `item.started` → tool_use mapping.
173
+ const messages: AgentMessage[] = [{
174
+ type: "tool_use",
175
+ toolName: update.title || update.kind || "tool",
176
+ toolInput: (update.rawInput ?? {}) as Record<string, unknown>,
177
+ toolUseId: update.toolCallId,
178
+ timestamp: ts,
179
+ }];
180
+ // ACP permits a `tool_call` to arrive ALREADY terminal (a fast/sync tool:
181
+ // status completed|failed carrying content/locations/rawOutput, with NO
182
+ // follow-up `tool_call_update`). The legacy codex path always produces a
183
+ // tool_result, so the ACP path must too — emit the same tool_result the
184
+ // `tool_call_update` terminal branch produces (ADR-0020 Q3).
185
+ if (update.status === "completed" || update.status === "failed") {
186
+ messages.push(terminalToolResult({
187
+ toolCallId: update.toolCallId,
188
+ status: update.status,
189
+ content: update.content,
190
+ locations: update.locations,
191
+ timestamp: ts,
192
+ }));
193
+ }
194
+ return messages;
195
+ }
196
+
197
+ case "tool_call_update": {
198
+ // Only terminal statuses produce a tool_result. pending / in_progress are
199
+ // progress-only deltas merged by toolCallId downstream → no message.
200
+ if (update.status !== "completed" && update.status !== "failed") return [];
201
+ return [terminalToolResult({
202
+ toolCallId: update.toolCallId,
203
+ status: update.status,
204
+ content: update.content,
205
+ locations: update.locations,
206
+ timestamp: ts,
207
+ })];
208
+ }
209
+
210
+ case "plan":
211
+ return [{
212
+ type: "plan",
213
+ // Each `plan` notification carries the ENTIRE entries array (the agent
214
+ // replaces the whole plan each time); pass it through verbatim. ACP
215
+ // PlanEntryStatus is exactly pending | in_progress | completed.
216
+ entries: update.entries.map((e) => ({ content: e.content, priority: e.priority, status: e.status })),
217
+ timestamp: ts,
218
+ }];
219
+
220
+ // Echoed user input (only during session/load replay — not our stream),
221
+ // slash-command palette, mode switches, context-window gauge, and the
222
+ // experimental plan_update / plan_removed / config / session-info updates
223
+ // are not part of our contract → no message.
224
+ case "user_message_chunk":
225
+ case "available_commands_update":
226
+ case "current_mode_update":
227
+ case "usage_update":
228
+ case "plan_update":
229
+ case "plan_removed":
230
+ case "config_option_update":
231
+ case "session_info_update":
232
+ return [];
233
+
234
+ default:
235
+ // Unknown / future variant — never throw; the contract is defensive.
236
+ return [];
237
+ }
238
+ }
239
+
240
+ // ── The peer ──────────────────────────────────────────────────────────────────
241
+
242
+ export class AcpClientPeer {
243
+ private readonly conn: ClientSideConnection;
244
+ /** Messages mapped from inbound `session/update` notifications, drained by the
245
+ * active `prompt()` call. REPLACED with a fresh queue at the start of every
246
+ * turn: an `AsyncQueue` is single-use (once `close()`d it stays drained), so a
247
+ * long-lived peer reused across turns must hand each turn its own queue or the
248
+ * second turn's updates would be silently dropped. The inbound `sessionUpdate`
249
+ * handler always pushes onto whatever the current queue is. */
250
+ private updates = new AsyncQueue<AgentMessage>();
251
+ /** Live session id (from `session/new` / `session/resume`), needed by
252
+ * `session/cancel`. */
253
+ private sessionId: string | undefined;
254
+ /** Captured `PauseSignal` thrown out of the permission handler. The JSON-RPC
255
+ * layer would otherwise convert the throw into an error response; we instead
256
+ * stash it and re-raise it from `prompt()` so it unwinds to `serveStep`. */
257
+ private pendingPause: unknown = null;
258
+ private cancelled = false;
259
+ /** Trips the in-flight `session/prompt` turn when `cancel()` / `close()` fires.
260
+ * The agent is supposed to answer `cancelled` after `session/cancel`, but a
261
+ * fully-silent / dead CLI may never resolve the prompt request — so the abort
262
+ * latch unblocks `prompt()` deterministically instead of hanging on the
263
+ * outstanding JSON-RPC request. `null` between turns. */
264
+ private abortPrompt: (() => void) | null = null;
265
+
266
+ constructor(private readonly deps: AcpClientPeerDeps) {
267
+ // The client handler. Methods we DON'T define (writeTextFile / readTextFile)
268
+ // and the terminal methods are simply absent — we advertise no fs/terminal
269
+ // capability, so a conformant agent never calls them; if one does the SDK
270
+ // answers method-not-found.
271
+ const handler: Client = {
272
+ sessionUpdate: (params: acp.SessionNotification): void => {
273
+ // Push mapped messages onto the queue. Tolerate updates that race in
274
+ // after cancel (the agent may flush a few before unwinding) — the
275
+ // normaliser handles them the same way.
276
+ for (const msg of normalizeSessionUpdate(params.update)) this.updates.push(msg);
277
+ },
278
+ requestPermission: (params: acp.RequestPermissionRequest) => this.onRequestPermission(params),
279
+ };
280
+ this.conn = new ClientSideConnection((_agent: Agent) => handler, this.deps.stream);
281
+
282
+ // Wire cancel: on abort, send session/cancel for the live session.
283
+ if (this.deps.signal) {
284
+ if (this.deps.signal.aborted) this.cancel();
285
+ else this.deps.signal.addEventListener("abort", () => this.cancel(), { once: true });
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Negotiate the wire protocol at `initialize`. Pins `protocolVersion` to
291
+ * integer 1 and advertises NO fs / terminal capabilities. Returns the
292
+ * version the agent negotiated — `CliAgentRunner` compares it to
293
+ * `ACP_PROTOCOL_VERSION` to decide ACP-path vs. legacy-JSONL fallback.
294
+ */
295
+ async initialize(): Promise<{ protocolVersion: number }> {
296
+ const res = await this.conn.initialize({
297
+ protocolVersion: ACP_PROTOCOL_VERSION,
298
+ clientCapabilities: {
299
+ fs: { readTextFile: false, writeTextFile: false },
300
+ terminal: false,
301
+ },
302
+ });
303
+ return { protocolVersion: res.protocolVersion };
304
+ }
305
+
306
+ /**
307
+ * Start a session and return its id (threaded back onto the synthesised
308
+ * `init` / `done`). Resume is via the CLI's own persisted thread/rollout on
309
+ * the sandbox FS keyed by this id — not an ACP `session/resume` round-trip;
310
+ * the re-issued turn on resume continues that thread (ADR-0020 Q4, validated
311
+ * in the manual live step).
312
+ */
313
+ async startSession(cwd: string): Promise<string> {
314
+ const res = await this.conn.newSession({ cwd, mcpServers: [] });
315
+ this.sessionId = res.sessionId;
316
+ return res.sessionId;
317
+ }
318
+
319
+ /**
320
+ * Drive one `session/prompt` turn, yielding `AgentMessage`s as the agent
321
+ * streams `session/update` notifications. Resolves the generator when the
322
+ * prompt result lands (clean `stopReason`), or yields `{type:"error"}` on a
323
+ * `refusal` stop reason or a JSON-RPC error. A `cancelled` stop reason ends
324
+ * the turn with no error. Re-raises any `PauseSignal` captured by the
325
+ * permission handler so it unwinds past `sendMessage` to `serveStep`.
326
+ */
327
+ async *prompt(text: string): AsyncGenerator<AgentMessage> {
328
+ if (!this.sessionId) throw new Error("AcpClientPeer.prompt called before startSession");
329
+
330
+ // Fresh updates queue for THIS turn. The peer is long-lived and reused
331
+ // across turns (one persistent ACP process / session), but an `AsyncQueue`
332
+ // is single-use — once closed it stays drained — so the previous turn's
333
+ // queue can't carry this turn's `session/update`s. Swap in a new one and
334
+ // bind a local alias the closures below capture, so closing "the turn's
335
+ // queue" always means this turn's, never a later turn's.
336
+ const updates = new AsyncQueue<AgentMessage>();
337
+ this.updates = updates;
338
+
339
+ // Abort latch: `cancel()` / `close()` trips this to unblock the turn even if
340
+ // the agent never resolves the outstanding `session/prompt` request (a dead
341
+ // or fully-silent CLI). Resolving it closes the updates queue (ending the
342
+ // for-await) and races the prompt result so the await below can't hang.
343
+ const aborted = new Promise<void>((resolve) => {
344
+ this.abortPrompt = () => { resolve(); updates.close(); };
345
+ });
346
+ // If cancel already fired before the prompt started (abort-before-prompt
347
+ // race), trip immediately.
348
+ if (this.cancelled) this.abortPrompt!();
349
+
350
+ // Kick off the prompt; do NOT await yet — drain updates while it runs, then
351
+ // close the queue when the turn resolves so the for-await below terminates.
352
+ const promptPromise = this.conn
353
+ .prompt({ sessionId: this.sessionId, prompt: [{ type: "text", text }] })
354
+ .then(
355
+ (res) => { updates.close(); return { ok: true as const, res }; },
356
+ (err) => { updates.close(); return { ok: false as const, err }; },
357
+ );
358
+
359
+ for await (const msg of updates) {
360
+ yield msg;
361
+ }
362
+
363
+ // A processor inside the permission handler asked for a human decision:
364
+ // re-raise so the pause unwinds to serveStep (ADR-0020 Q4). The outstanding
365
+ // session/prompt is abandoned as the subprocess exits; on resume the loop
366
+ // re-issues the turn against the persisted session.
367
+ if (this.pendingPause !== null) {
368
+ const pause = this.pendingPause;
369
+ this.pendingPause = null;
370
+ this.abortPrompt = null;
371
+ throw pause;
372
+ }
373
+
374
+ // The turn ended either because the prompt resolved (its `.then` closed the
375
+ // queue) or because the abort latch tripped. Race them so an aborted turn
376
+ // returns cleanly instead of awaiting a JSON-RPC reply the agent may never
377
+ // send. `cancelled` → no error, like a clean cancel stop reason.
378
+ const settled = await Promise.race([
379
+ promptPromise.then((o) => ({ kind: "prompt" as const, o })),
380
+ aborted.then(() => ({ kind: "aborted" as const })),
381
+ ]);
382
+ this.abortPrompt = null;
383
+ if (settled.kind === "aborted") return;
384
+
385
+ const outcome = settled.o;
386
+ if (!outcome.ok) {
387
+ yield { type: "error", text: formatError(outcome.err), timestamp: now() };
388
+ return;
389
+ }
390
+ const stopReason = outcome.res.stopReason;
391
+ if (stopReason === "refusal") {
392
+ yield { type: "error", text: "agent refused the request", timestamp: now() };
393
+ return;
394
+ }
395
+ // end_turn / max_tokens / max_turn_requests → clean end (the runner
396
+ // synthesises `done`). cancelled → end the turn with no error.
397
+
398
+ // Per-turn token usage rides the prompt result, not usage_update (Q1).
399
+ const usage = outcome.res.usage;
400
+ if (usage) {
401
+ yield {
402
+ type: "usage",
403
+ inputTokens: usage.inputTokens,
404
+ outputTokens: usage.outputTokens,
405
+ cacheReadTokens: usage.cachedReadTokens ?? 0,
406
+ cacheCreationTokens: usage.cachedWriteTokens ?? 0,
407
+ durationMs: 0,
408
+ numTurns: 1,
409
+ timestamp: now(),
410
+ };
411
+ }
412
+ }
413
+
414
+ /** The live session id, or undefined before `startSession`. Threaded back
415
+ * onto the synthesised `init` / `done` by the runner. */
416
+ get currentSessionId(): string | undefined {
417
+ return this.sessionId;
418
+ }
419
+
420
+ /** Send `session/cancel` for the live session (notification, fire-and-forget).
421
+ * Idempotent. The pending `session/prompt` is expected to resolve
422
+ * `cancelled`; any outstanding `session/request_permission` resolves with
423
+ * `outcome:"cancelled"` via the handler's signal check. */
424
+ cancel(): void {
425
+ if (this.cancelled) { this.abortPrompt?.(); return; }
426
+ this.cancelled = true;
427
+ // Send session/cancel when we have a live session; with or without one, trip
428
+ // the abort latch so an in-flight `prompt()` unblocks deterministically
429
+ // rather than waiting on a reply a cancelled/dead agent may never send.
430
+ if (this.sessionId) {
431
+ void this.conn.cancel({ sessionId: this.sessionId }).catch(() => { /* connection may already be closing */ });
432
+ }
433
+ this.abortPrompt?.();
434
+ }
435
+
436
+ /** Tear down the connection. Trips the abort latch so any in-flight `prompt()`
437
+ * turn unblocks, and closes the updates queue. Used both on clean turn end and
438
+ * when the runner falls back to the legacy JSONL path after a version
439
+ * mismatch. */
440
+ close(): void {
441
+ // Trip the in-flight turn (closes the updates queue too) so `prompt()`'s
442
+ // `await` can't hang on an outstanding `session/prompt` request.
443
+ this.abortPrompt?.();
444
+ this.updates.close();
445
+ // ClientSideConnection has no explicit close(); ending the stream closes it.
446
+ // Closing the writable is the caller's job (it owns the subprocess), so we
447
+ // only drain our queue here. The connection's `signal` aborts when the
448
+ // stream ends.
449
+ }
450
+
451
+ // ── Permission → gate → PermissionOption ────────────────────────────────────
452
+
453
+ // Permission → pause, wired end-to-end (WS-C increment 2b). An ACP
454
+ // `session/request_permission` runs the shared processor chain via
455
+ // `gateToolCall`; a human-approval processor (`humanApproval` / any processor
456
+ // calling `ctx.pause`) raises a `PauseSignal` here. This handler propagates it
457
+ // unswallowed — stashed in `pendingPause` and re-raised from `prompt()` so it
458
+ // unwinds to serveStep and the `run_pauses` row is written (ADR-0020 Q4 /
459
+ // ADR-0006). `ProcessorContext.pause` (processors/processor.ts), wired by the
460
+ // runtime's `buildProcCtx`, is the source. A gate can now allow / deny / abort
461
+ // / PAUSE.
462
+ private async onRequestPermission(
463
+ params: acp.RequestPermissionRequest,
464
+ ): Promise<acp.RequestPermissionResponse> {
465
+ // If the turn was cancelled, the protocol REQUIRES we answer cancelled.
466
+ if (this.cancelled || this.deps.signal?.aborted) {
467
+ return { outcome: { outcome: "cancelled" } };
468
+ }
469
+
470
+ const tc = params.toolCall;
471
+ const call: ToolCall = {
472
+ toolName: tc.title || tc.kind || "tool",
473
+ toolInput: (tc.rawInput ?? {}) as Record<string, unknown>,
474
+ toolUseId: tc.toolCallId,
475
+ };
476
+
477
+ let verdict: ToolCallGateResult;
478
+ try {
479
+ // CRITICAL: do NOT wrap this in a catch that swallows everything. A
480
+ // processor requesting human approval throws `PauseSignal`, which is
481
+ // control flow, not an error — it must escape to `prompt()` and unwind to
482
+ // serveStep so the run_pauses row is written (ADR-0020 Q4 / ADR-0006).
483
+ verdict = await this.deps.gateToolCall(call, this.deps.procCtx());
484
+ } catch (err) {
485
+ if (isPauseSignal(err)) {
486
+ // Stash the signal for `prompt()` to re-raise, and answer the still-
487
+ // pending request as cancelled so the agent unwinds its turn while the
488
+ // subprocess is torn down by the pause.
489
+ this.pendingPause = err;
490
+ this.cancel();
491
+ return { outcome: { outcome: "cancelled" } };
492
+ }
493
+ throw err; // genuine error → JSON-RPC error response
494
+ }
495
+
496
+ if (verdict.kind === "abort") {
497
+ this.cancel();
498
+ return { outcome: { outcome: "cancelled" } };
499
+ }
500
+
501
+ const wantedKinds =
502
+ verdict.kind === "allow" ? OPTION_KIND_FOR_VERDICT.allow : OPTION_KIND_FOR_VERDICT.deny;
503
+ const option =
504
+ params.options.find((o) => (wantedKinds as readonly string[]).includes(o.kind)) ?? params.options[0];
505
+ if (!option) {
506
+ // No options offered at all — a non-conformant agent. Cancel rather than
507
+ // guess.
508
+ return { outcome: { outcome: "cancelled" } };
509
+ }
510
+ return { outcome: { outcome: "selected", optionId: option.optionId } };
511
+ }
512
+ }
513
+
514
+ // Re-export RequestError so callers building/handling JSON-RPC errors don't
515
+ // reach into the SDK directly.
516
+ export { RequestError };