@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
@@ -24,10 +24,16 @@
24
24
  * values set via `agentc secrets set` are visible to the CLI.
25
25
  */
26
26
 
27
- import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider } from "../index.js";
27
+ import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider, ToolCallGateResult } from "../index.js";
28
28
  import { defineRuntime } from "../types/runtime.js";
29
29
  import { AsyncQueue } from "../agent/async-queue.js";
30
30
  import { formatError } from "../utils/errors.js";
31
+ import { ndJsonStream, type Stream } from "@agentclientprotocol/sdk";
32
+ import { runProcessorChain } from "../processors/runner.js";
33
+ import type { ProcessorContext, ToolCall } from "../processors/processor.js";
34
+ import { RequestContext } from "../request-context/request-context.js";
35
+ import { AcpClientPeer, ACP_PROTOCOL_VERSION } from "./_acp-client.js";
36
+ import { isPauseSignal, boundProcessorPause } from "../pause/pause-core.js";
31
37
 
32
38
  function now(): string { return new Date().toISOString(); }
33
39
 
@@ -36,6 +42,47 @@ export function shellQuote(value: string): string {
36
42
  return `'${value.replace(/'/g, `'\\''`)}'`;
37
43
  }
38
44
 
45
+ /** Internal sentinel yielded by `sendMessageAcp` to signal the version-check
46
+ * fallback: the ACP handshake didn't negotiate version 1, so `sendMessage`
47
+ * must drive the legacy JSONL path for this (and every later) turn. Not an
48
+ * `AgentMessage` — filtered out before anything reaches the loop. */
49
+ const ACP_FALLBACK = Symbol("acp-fallback");
50
+
51
+ /** Hard deadline (ms) for the ACP `initialize` handshake AND the prompt turn.
52
+ * `peer.initialize()` resolves only when the agent answers; a real CLI that
53
+ * never speaks ACP (or blocks on stdin) would otherwise hang forever and the
54
+ * JSONL fallback would never be reached. On timeout we tear the peer down and
55
+ * route to JSONL exactly like a handshake error. Overridable via the env for
56
+ * ops tuning; defaults sane. */
57
+ export const ACP_HANDSHAKE_TIMEOUT_MS = Number(process.env.AC_ACP_HANDSHAKE_TIMEOUT_MS) || 10_000;
58
+
59
+ /** Readiness gate for the LIVE ACP attempt. The duplex-stdin transport in
60
+ * `spawnAcpProcess` is now real (`commands.spawnDuplex` on the local provider),
61
+ * so the agent's `initialize` request bytes are delivered and the handshake can
62
+ * actually negotiate. The transport is live; this is `true`.
63
+ *
64
+ * Readiness alone is not sufficient — the runner still requires the live
65
+ * provider to expose `commands.spawnDuplex`. A provider without it (the
66
+ * server→sandbox vercel/e2b view never spawns the in-VM CLI, so neither
67
+ * implements duplex) falls back to JSONL cleanly via the capability check in
68
+ * `sendMessage`. */
69
+ export const ACP_TRANSPORT_READY = true;
70
+
71
+ /** Race a promise against the handshake deadline. On timeout, rejects with a
72
+ * marker error so `sendMessageAcp` falls back to JSONL (same as a handshake
73
+ * error). The timer is cleared on settle so it never keeps the process alive. */
74
+ async function withHandshakeTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
75
+ let timer: ReturnType<typeof setTimeout> | undefined;
76
+ const timeout = new Promise<never>((_resolve, reject) => {
77
+ timer = setTimeout(() => reject(new Error(`ACP handshake timed out after ${ms}ms`)), ms);
78
+ });
79
+ try {
80
+ return await Promise.race([p, timeout]);
81
+ } finally {
82
+ if (timer) clearTimeout(timer);
83
+ }
84
+ }
85
+
39
86
  /** Per-CLI behaviour. The base owns the lifecycle (init/done/error) and the
40
87
  * transport (spawn + JSONL parse); a spec owns the CLI-specific bits. */
41
88
  export interface CliAgentSpec {
@@ -64,15 +111,87 @@ export interface CliAgentSpec {
64
111
  buildCommand(args: { promptPath: string; sessionId?: string; model?: string; cwd?: string }): string;
65
112
  /** Map one parsed JSONL stdout event to `AgentMessage`s. The base emits
66
113
  * `init`/`done`/`error` lifecycle itself, so a spec maps only content +
67
- * usage (text / thinking / tool_use / tool_result / usage). */
114
+ * usage (text / thinking / tool_use / tool_result / usage).
115
+ *
116
+ * LEGACY FALLBACK PATH. Retained for a pinned-old CLI that predates ACP
117
+ * support (the `initialize` handshake negotiates a non-1 version → the
118
+ * runner closes the ACP peer and drives this JSONL path instead). Removed
119
+ * only after the last subprocess CLI is on ACP (ADR-0020 increment 5). */
68
120
  mapEvent(parsed: Record<string, unknown>): AgentMessage[];
69
121
  /** Pull a session/thread id out of a parsed event so the next turn can
70
- * resume it (codex `thread.started.thread_id`, amp `session_id`). */
122
+ * resume it (codex `thread.started.thread_id`, amp `session_id`).
123
+ * LEGACY FALLBACK PATH (see `mapEvent`). */
71
124
  extractSessionId(parsed: Record<string, unknown>): string | undefined;
125
+ /** ACP-mode invocation (the Zed `agent_servers` shape). When present, the
126
+ * runner spawns the CLI in ACP agent mode and delegates the whole wire
127
+ * protocol to `AcpClientPeer`; the legacy `promptPayload`/`buildCommand`/
128
+ * `extractSessionId`/`mapEvent` members are used only as the version-mismatch
129
+ * fallback. Absent → the spec is JSONL-only (legacy path always). */
130
+ acp?: { command: string; args: string[]; env?: Record<string, string> };
131
+ }
132
+
133
+ /** A spawned ACP-mode CLI: the read side of its stdout and a delivery for its
134
+ * stdin, wrapped as byte web-streams for `ndJsonStream`. Producing this is a
135
+ * below-ACP transport concern (the duplex command primitive) — see
136
+ * `spawnAcpProcess`. */
137
+ interface AcpProcess {
138
+ /** Duplex byte stream: `readable` = subprocess stdout, `writable` = stdin. */
139
+ stream: Stream;
140
+ /** Resolves when the subprocess exits (with its exit code). */
141
+ exited: Promise<{ exitCode: number; stderr: string }>;
142
+ /** Force-terminate the subprocess and release the stream. */
143
+ kill(): void;
72
144
  }
73
145
 
74
146
  export class CliAgentRunner implements ModelExecutionContract {
75
147
  readonly kind: string;
148
+ /** Whether the live ACP path will actually run for this runner: the spec
149
+ * declares an `acp` invocation, the transport is ready, AND the live provider
150
+ * exposes the duplex-stdin primitive ACP needs. A provider without
151
+ * `commands.spawnDuplex` (the server→sandbox vercel/e2b view) can't carry the
152
+ * JSON-RPC duplex, so the runner falls back to JSONL — and in that state the
153
+ * CLI owns its own tool loop with no pre-tool seam. */
154
+ private get acpCapable(): boolean {
155
+ return this.spec.acp !== undefined
156
+ && this.acpTransportReady
157
+ && typeof this.sandbox.commands.spawnDuplex === "function";
158
+ }
159
+
160
+ /** ACP-capable specs gate tools through the shared processor chain (the CLI
161
+ * no longer owns its own unsupervised tool loop). True ONLY when the ACP path
162
+ * will actually run — a JSONL-only spec, an ACP spec while the transport is
163
+ * dormant (`acpTransportReady` false), OR a provider that can't spawn a duplex
164
+ * leaves the CLI in charge with NO pre-tool seam, so this must report false in
165
+ * those states. Otherwise the agent loop would suppress its "processToolCall
166
+ * gating is dormant" warning and a registered deny/gate processor would be
167
+ * silently un-enforced for the agent. */
168
+ get supportsToolCallProcessor(): boolean {
169
+ return this.acpCapable;
170
+ }
171
+ /** Once the handshake has decided ACP vs. legacy for this runner instance,
172
+ * cache it so every later turn skips re-probing. `undefined` = not yet
173
+ * decided. */
174
+ private acpDecision: "acp" | "jsonl" | undefined;
175
+ /** The persistent ACP session, established lazily on the first ACP turn and
176
+ * reused across every later turn (a real ACP agent is a LONG-LIVED JSON-RPC
177
+ * server — one process, one initialize, one session — driven turn-by-turn via
178
+ * `session/prompt`, NOT respawned per turn). `undefined` until the first
179
+ * successful handshake; never re-set after a fallback (we switch to JSONL
180
+ * for the whole runner instead). */
181
+ private acpSession: { proc: AcpProcess; peer: AcpClientPeer; sessionId: string } | undefined;
182
+ /** The CURRENT turn's processor context. The long-lived ACP peer is built
183
+ * once but reused across turns, so it reads the gate context through a
184
+ * provider (`procCtx: () => this.currentAcpProcCtx`) rather than a frozen
185
+ * value — `sendMessageAcp` refreshes this before each prompt so a
186
+ * permission->pause binds to THIS turn's iteration (stable pauseId on
187
+ * resume), not the first turn's. */
188
+ private currentAcpProcCtx: ProcessorContext | undefined;
189
+ /** Per-instance copy of the live-ACP readiness gate (defaults to the module
190
+ * const `ACP_TRANSPORT_READY`). Tests that drive the ACP lifecycle/fallback/
191
+ * pause/parity paths flip it on via `enableAcpForTesting()` so they keep
192
+ * exercising the real `sendMessageAcp` even while the production default is
193
+ * dormant. Not a public API. */
194
+ private acpTransportReady = ACP_TRANSPORT_READY;
76
195
 
77
196
  constructor(
78
197
  private readonly sandbox: SandboxProvider,
@@ -83,10 +202,49 @@ export class CliAgentRunner implements ModelExecutionContract {
83
202
  this.kind = spec.kind;
84
203
  }
85
204
 
205
+ /** TEST-ONLY: flip the live-ACP readiness gate on for this runner instance so
206
+ * the ACP lifecycle / fallback tests can exercise `sendMessageAcp` while the
207
+ * production default (`ACP_TRANSPORT_READY`) stays dormant. Not part of the
208
+ * public runtime surface. */
209
+ enableAcpForTesting(): void {
210
+ this.acpTransportReady = true;
211
+ }
212
+
86
213
  get model(): string | undefined {
87
214
  return this.configModel ?? this.options.model ?? this.spec.defaultModel;
88
215
  }
89
216
 
217
+ /** Runtime-owned pre-tool gate. Runs the shared processor chain and maps its
218
+ * verdict to a `ToolCallGateResult` — identical to `ClaudeRunner.gateToolCall`.
219
+ * Invoked by `AcpClientPeer`'s `session/request_permission` handler. A
220
+ * processor that throws `PauseSignal` propagates through unchanged (the
221
+ * handler re-raises it; never swallowed). */
222
+ async gateToolCall(call: ToolCall, ctx: ProcessorContext): Promise<ToolCallGateResult> {
223
+ const verdict = await runProcessorChain(this.options.processors ?? [], (p) => p.processToolCall, call, ctx);
224
+ if (verdict.kind === "continue") return { kind: "allow", call: verdict.value };
225
+ return verdict;
226
+ }
227
+
228
+ private buildProcCtx(signal: AbortSignal | undefined, iteration: number | undefined): ProcessorContext {
229
+ const agentId = this.options.agentId ?? "agent";
230
+ const iter = iteration ?? 1;
231
+ return {
232
+ requestContext: this.options.requestContext ?? RequestContext.fromReserved({
233
+ teamId: "", runId: "", workflowId: "",
234
+ factoryId: null, apiKeyScopes: [], parentRunId: null,
235
+ }),
236
+ abortSignal: signal ?? new AbortController().signal,
237
+ retryCount: 0,
238
+ agentId,
239
+ iteration: iter,
240
+ // Wire the pause boundary so a permission-gate processor can raise a
241
+ // human-approval `ctx.pause` from inside `gateToolCall` (the ACP
242
+ // `session/request_permission` path). pauseId is keyed on agentId+iteration
243
+ // so it stays stable across the mid-turn pause/resume.
244
+ pause: boundProcessorPause(this.options.pause, { agentId, iteration: iter }),
245
+ };
246
+ }
247
+
90
248
  private installed = false;
91
249
 
92
250
  /** Provision the CLI on demand. A no-op once `bin` is on PATH — the steady
@@ -115,8 +273,263 @@ export class CliAgentRunner implements ModelExecutionContract {
115
273
  iteration?: number;
116
274
  signal?: AbortSignal;
117
275
  }): AsyncGenerator<AgentMessage> {
276
+ // Synthesised init stays runner-side (unchanged loop contract): yield before
277
+ // any spawn/handshake so `lastSessionId` is set even on an early failure.
118
278
  yield { type: "init", sessionId: opts.sessionId ?? "", timestamp: now() };
119
279
 
280
+ // ACP path when the spec declares an `acp` invocation, the live transport is
281
+ // ready, the provider exposes the duplex-stdin primitive (`acpCapable`), AND
282
+ // we haven't already fallen back to JSONL for this runner. A provider without
283
+ // `commands.spawnDuplex` (the server→sandbox vercel/e2b view) goes straight
284
+ // to JSONL — no wasted handshake. The handshake inside `sendMessageAcp` then
285
+ // decides per-process: a non-1 negotiated version, a handshake error, OR the
286
+ // handshake watchdog firing closes the ACP peer and yields `ACP_FALLBACK`
287
+ // so we drive the legacy JSONL path instead — a pinned-old CLI is never
288
+ // stranded, and a non-responding CLI can't hang the run.
289
+ if (this.acpCapable && this.acpDecision !== "jsonl") {
290
+ let fellBack = false;
291
+ try {
292
+ for await (const ev of this.sendMessageAcp(opts)) {
293
+ if (ev === ACP_FALLBACK) { fellBack = true; break; }
294
+ yield ev;
295
+ }
296
+ } catch (err) {
297
+ // A PauseSignal re-raised from the permission handler is control flow,
298
+ // NOT an error — let it propagate past sendMessage so it unwinds to
299
+ // serveStep and the run_pauses row is written (ADR-0020 Q4 / ADR-0006).
300
+ // Swallowing it here as `{type:"error"}` is the exact bug the design
301
+ // warns against.
302
+ if (isPauseSignal(err)) throw err;
303
+ yield { type: "error", text: formatError(err), timestamp: now() };
304
+ return;
305
+ }
306
+ if (!fellBack) { this.acpDecision = "acp"; return; }
307
+ this.acpDecision = "jsonl";
308
+ // fall through to the legacy path below for THIS turn
309
+ }
310
+
311
+ yield* this.sendMessageJsonl(opts);
312
+ }
313
+
314
+ /**
315
+ * Tear down the failed/probed ACP process on a fallback path: group-kill the
316
+ * subprocess (so the npx-spawned grandchild dies too, not just `sh`) and
317
+ * `.catch()` its `exited` promise so the abandoned process can't surface an
318
+ * unhandled rejection once we've stopped awaiting it. A best-effort stdin
319
+ * close is attempted first — a conformant CLI exits cleanly on EOF — but we
320
+ * rely on the group-kill for force-termination of one that won't.
321
+ */
322
+ private async teardownAcp(proc: AcpProcess, peer: AcpClientPeer): Promise<void> {
323
+ peer.close();
324
+ // EOF on stdin lets a conformant CLI exit on its own; group-kill is the
325
+ // backstop for one that ignores it. Both are best-effort.
326
+ try { await proc.stream.writable.close(); } catch { /* already closing/closed */ }
327
+ proc.kill();
328
+ proc.exited.catch(() => { /* abandoned process — don't leak an unhandled rejection */ });
329
+ }
330
+
331
+ /**
332
+ * Establish the persistent ACP session for this runner instance, ONCE,
333
+ * lazily on the first ACP turn: spawn the long-lived ACP agent process, run
334
+ * `initialize`, and open the single `session/new`. The result is cached on the
335
+ * instance and reused by every later turn — a real ACP agent
336
+ * (`npx @agentclientprotocol/codex-acp`) is a long-lived JSON-RPC server that
337
+ * does NOT exit after one turn, so respawning per turn would both hang on
338
+ * `proc.exited` and break multi-turn continuity (each turn would open a fresh
339
+ * session and lose the thread).
340
+ *
341
+ * Returns the cached session, or `null` to signal the version-mismatch /
342
+ * handshake-error / stall fallback — in which case the failed process has
343
+ * already been torn down (group-killed + exited-catch) by `teardownAcp`, and
344
+ * the caller yields `ACP_FALLBACK`. The persistent process is otherwise killed
345
+ * only on the abort signal (wired in `spawnAcpProcess`) and is reclaimed when
346
+ * the sandbox VM is torn down at run-end — we never block on its exit.
347
+ */
348
+ private async ensureAcpSession(opts: {
349
+ iteration?: number;
350
+ signal?: AbortSignal;
351
+ }): Promise<{ proc: AcpProcess; peer: AcpClientPeer; sessionId: string } | null> {
352
+ if (this.acpSession) return this.acpSession;
353
+
354
+ await this.ensureInstalled();
355
+
356
+ const acp = this.spec.acp!;
357
+ const proc = this.spawnAcpProcess(acp, opts.signal);
358
+ const peer = new AcpClientPeer({
359
+ stream: proc.stream,
360
+ gateToolCall: (call, ctx) => this.gateToolCall(call, ctx),
361
+ // Provider, NOT a frozen value: the peer outlives a single turn, so it
362
+ // must read the CURRENT turn's gate context (refreshed by sendMessageAcp)
363
+ // — otherwise a turn>=2 permission->pause computes its pauseId with the
364
+ // first turn's iteration and the resume can't recompute the same id.
365
+ procCtx: () => this.currentAcpProcCtx ?? this.buildProcCtx(opts.signal, opts.iteration),
366
+ ...(opts.signal ? { signal: opts.signal } : {}),
367
+ });
368
+
369
+ let negotiated: number;
370
+ try {
371
+ // Watchdog the handshake: `peer.initialize()` resolves ONLY when the agent
372
+ // answers. A CLI that never speaks ACP (or blocks on stdin) would hang
373
+ // here forever and the JSONL fallback would never be reached, so race a
374
+ // hard deadline. A timeout is treated identically to a handshake error.
375
+ ({ protocolVersion: negotiated } = await withHandshakeTimeout(peer.initialize(), ACP_HANDSHAKE_TIMEOUT_MS));
376
+ } catch {
377
+ // The process doesn't speak ACP (no valid JSON-RPC response, or it never
378
+ // answered within the deadline) — tear down and fall back.
379
+ await this.teardownAcp(proc, peer);
380
+ return null;
381
+ }
382
+ if (negotiated !== ACP_PROTOCOL_VERSION) {
383
+ // A pinned-old CLI negotiating a different wire version: fall back rather
384
+ // than strand it.
385
+ await this.teardownAcp(proc, peer);
386
+ return null;
387
+ }
388
+
389
+ // `session/new` is the second handshake round-trip — watchdog it too, so a
390
+ // CLI that negotiates a version then stalls before opening a session still
391
+ // falls back rather than hanging. This is the ONLY `session/new` for the
392
+ // runner's lifetime; every later turn reuses this session id.
393
+ let sessionId: string;
394
+ try {
395
+ sessionId = await withHandshakeTimeout(peer.startSession(this.options.cwd ?? "/"), ACP_HANDSHAKE_TIMEOUT_MS);
396
+ } catch {
397
+ await this.teardownAcp(proc, peer);
398
+ return null;
399
+ }
400
+
401
+ this.acpSession = { proc, peer, sessionId };
402
+ return this.acpSession;
403
+ }
404
+
405
+ /**
406
+ * ACP transport, ONE turn. On the FIRST turn it lazily establishes the
407
+ * persistent session (`ensureAcpSession`); every later turn REUSES that same
408
+ * process + peer + session id — `session/prompt` against the live session, no
409
+ * respawn, no new session. On a handshake error / version mismatch / session
410
+ * open failure (first turn only) it yields `ACP_FALLBACK` so `sendMessage`
411
+ * switches to the JSONL path. Never yields a duplicate `init` (the caller
412
+ * already did). CRITICALLY: never `await proc.exited` — the ACP agent is a
413
+ * long-lived server that does not exit after a turn; blocking on its exit
414
+ * would hang the loop forever.
415
+ */
416
+ private async *sendMessageAcp(opts: {
417
+ prompt: string;
418
+ sessionId?: string;
419
+ iteration?: number;
420
+ signal?: AbortSignal;
421
+ }): AsyncGenerator<AgentMessage | typeof ACP_FALLBACK> {
422
+ // Refresh the per-turn gate context BEFORE prompting. The persistent peer
423
+ // reads it via its `procCtx` provider, so a permission->pause on THIS turn
424
+ // binds to THIS turn's iteration → the pauseId recomputes identically on
425
+ // resume. (Freezing it at peer construction mis-keyed every turn>=2 pause.)
426
+ this.currentAcpProcCtx = this.buildProcCtx(opts.signal, opts.iteration);
427
+ const session = await this.ensureAcpSession(opts);
428
+ if (!session) { yield ACP_FALLBACK; return; }
429
+ const { proc, peer, sessionId } = session;
430
+
431
+ // Prompt-turn watchdog: a turn must make progress within the deadline or we
432
+ // tear down. Unlike the handshake watchdog this does NOT fall back to JSONL
433
+ // (we've already committed to ACP for this runner) — it cancels the stalled
434
+ // turn and surfaces an error. The deadline is reset on every yielded message
435
+ // so a legitimately long, *streaming* turn is never killed; only a turn that
436
+ // goes fully silent (the documented Gemini-style hang, ADR-0020 risks) trips
437
+ // it. `peer.cancel()` sends `session/cancel` AND trips the peer's abort latch
438
+ // so the in-flight `session/prompt` unblocks deterministically rather than
439
+ // hanging on a reply a stalled CLI never sends.
440
+ //
441
+ // We do NOT close the peer here on a clean turn end: the peer wraps the
442
+ // persistent connection, reused by the next turn's `session/prompt`. The
443
+ // persistent process is killed only on the abort signal (wired in
444
+ // `spawnAcpProcess`) or group-killed below on a stall, and otherwise
445
+ // reclaimed when the sandbox VM is torn down at run-end.
446
+ let sawError = false;
447
+ let stalled = false;
448
+ let watchdog: ReturnType<typeof setTimeout> | undefined;
449
+ const armWatchdog = () => {
450
+ if (watchdog) clearTimeout(watchdog);
451
+ watchdog = setTimeout(() => { stalled = true; peer.cancel(); }, ACP_HANDSHAKE_TIMEOUT_MS);
452
+ };
453
+ try {
454
+ armWatchdog();
455
+ for await (const msg of peer.prompt(opts.prompt)) {
456
+ armWatchdog();
457
+ if (msg.type === "error") sawError = true;
458
+ yield msg;
459
+ }
460
+ } finally {
461
+ if (watchdog) clearTimeout(watchdog);
462
+ }
463
+ if (stalled && !sawError) {
464
+ // A wedged CLI can't continue — group-kill the persistent process, drop
465
+ // the cached session (a later turn would only re-stall on the dead peer),
466
+ // and surface the stall as an error.
467
+ await this.teardownAcp(proc, peer);
468
+ this.acpSession = undefined;
469
+ yield { type: "error", text: `${this.spec.kind} prompt turn stalled (no activity for ${ACP_HANDSHAKE_TIMEOUT_MS}ms)`, timestamp: now() };
470
+ return;
471
+ }
472
+
473
+ if (!sawError) yield { type: "done", sessionId: peer.currentSessionId ?? sessionId, timestamp: now() };
474
+ }
475
+
476
+ /**
477
+ * Spawn the CLI in ACP agent mode and wrap its stdio as an `ndJsonStream`
478
+ * duplex.
479
+ *
480
+ * The real stdin/stdout duplex comes from `commands.spawnDuplex` — the in-VM
481
+ * `child_process` pipe (`makeLocalSandboxProvider`). The runner runs INSIDE
482
+ * the sandbox VM and spawns the CLI via the local provider, so a duplex stdin
483
+ * is a normal pipe and works identically on Vercel/E2B/local (the
484
+ * @vercel/sandbox no-stdin limit is the server→sandbox boundary, not this
485
+ * one). `ndJsonStream(stdin, stdout)` then carries JSON-RPC both ways:
486
+ * outbound `initialize`/`session/*` request frames down stdin, inbound
487
+ * `session/update` notifications + results up stdout.
488
+ *
489
+ * Caller contract: only invoked when `commands.spawnDuplex` is present (the
490
+ * capability check in `sendMessage` gates the whole ACP attempt on it), so a
491
+ * provider without duplex never reaches here — it falls back to JSONL.
492
+ */
493
+ private spawnAcpProcess(
494
+ acp: { command: string; args: string[]; env?: Record<string, string> },
495
+ signal: AbortSignal | undefined,
496
+ ): AcpProcess {
497
+ const spawnDuplex = this.sandbox.commands.spawnDuplex;
498
+ if (!spawnDuplex) {
499
+ // Defensive: the capability check in sendMessage must keep this path off a
500
+ // provider without duplex. Reaching here is a programming error.
501
+ throw new Error("spawnAcpProcess called on a provider without commands.spawnDuplex");
502
+ }
503
+
504
+ const cmd = `${acp.command} ${acp.args.map(shellQuote).join(" ")}`.trim();
505
+ const agentEnv = { ...acp.env };
506
+ const proc = spawnDuplex.call(this.sandbox.commands, cmd, {
507
+ ...(this.options.cwd ? { cwd: this.options.cwd } : {}),
508
+ ...(Object.keys(agentEnv).length > 0 ? { envs: agentEnv } : {}),
509
+ });
510
+
511
+ const kill = () => proc.kill();
512
+ if (signal) {
513
+ if (signal.aborted) kill();
514
+ else signal.addEventListener("abort", kill, { once: true });
515
+ }
516
+
517
+ return { stream: ndJsonStream(proc.stdin, proc.stdout), exited: proc.exited, kill };
518
+ }
519
+
520
+ /**
521
+ * Legacy JSONL transport (the version-mismatch fallback, ADR-0020 increment
522
+ * 5 retires it). Unchanged behaviour: write the prompt to a file, run the
523
+ * one-shot command, line-buffer stdout, `mapEvent` each parsed line, and cap
524
+ * with the runner-synthesised `done`. Does NOT re-yield `init` (the caller
525
+ * already did).
526
+ */
527
+ private async *sendMessageJsonl(opts: {
528
+ prompt: string;
529
+ sessionId?: string;
530
+ iteration?: number;
531
+ signal?: AbortSignal;
532
+ }): AsyncGenerator<AgentMessage> {
120
533
  let sessionId = opts.sessionId;
121
534
  let sawError = false;
122
535
  try {
@@ -8,6 +8,7 @@ import { AsyncQueue } from "../agent/async-queue.js";
8
8
  import { runProcessorChain } from "../processors/runner.js";
9
9
  import type { ProcessorContext, ToolCall } from "../processors/processor.js";
10
10
  import { RequestContext } from "../request-context/request-context.js";
11
+ import { boundProcessorPause } from "../pause/pause-core.js";
11
12
  import { formatError } from "../utils/errors.js";
12
13
 
13
14
  function now(): string { return new Date().toISOString(); }
@@ -199,12 +200,15 @@ export class ClaudeRunner implements ModelExecutionContract {
199
200
  toolInput: (pre.tool_input ?? {}) as Record<string, unknown>,
200
201
  toolUseId: toolUseId ?? `${pre.tool_name}-${Date.now()}`,
201
202
  };
203
+ const gateAgentId = this.options.agentId ?? "agent";
204
+ const gateIteration = opts.iteration ?? 1;
202
205
  const gate = await this.gateToolCall(call, {
203
206
  requestContext,
204
207
  abortSignal: signal ?? opts.signal ?? new AbortController().signal,
205
208
  retryCount: 0,
206
- agentId: this.options.agentId ?? "agent",
207
- iteration: opts.iteration ?? 1,
209
+ agentId: gateAgentId,
210
+ iteration: gateIteration,
211
+ pause: boundProcessorPause(this.options.pause, { agentId: gateAgentId, iteration: gateIteration }),
208
212
  });
209
213
  if (gate.kind === "allow") return { hookSpecificOutput: { hookEventName: pre.hook_event_name, permissionDecision: "allow", updatedInput: gate.call.toolInput } };
210
214
  if (gate.kind === "deny") return { hookSpecificOutput: { hookEventName: pre.hook_event_name, permissionDecision: "deny", permissionDecisionReason: gate.reason } };
@@ -249,6 +253,26 @@ export class ClaudeRunner implements ModelExecutionContract {
249
253
  }
250
254
  const promptForQuery: string | AsyncIterable<SDKUserMessage> = inboxQueue ?? opts.prompt;
251
255
 
256
+ // Bridge the loop's abort signal into the Agent SDK's cancel knob
257
+ // (`query({ options: { abortController } })`). Without this the `signal` was
258
+ // accepted but never wired into query(), so a loop abort (cancellation, or a
259
+ // sibling agent's pause tearing the loop down) let the in-flight query run to
260
+ // completion — exactly what the loop's abort is supposed to prevent.
261
+ const queryAbort = new AbortController();
262
+ const onLoopAbort = () => queryAbort.abort();
263
+ if (opts.signal) {
264
+ if (opts.signal.aborted) queryAbort.abort();
265
+ else opts.signal.addEventListener("abort", onLoopAbort, { once: true });
266
+ }
267
+
268
+ // System-prompt append: the platform manual (agentManual, threaded from
269
+ // agent()) + any caller-supplied claudeMdContent. The Agent SDK does NOT
270
+ // auto-load CLAUDE.md from cwd (no settingSources), so without this the
271
+ // agent never reliably sees the manual — including how to pause.
272
+ const systemPromptAppend = [this.config.claudeMdContent, this.options.agentManual]
273
+ .filter((s): s is string => Boolean(s && s.trim()))
274
+ .join("\n\n");
275
+
252
276
  try {
253
277
  let emittedAssistantText = false;
254
278
  for await (const message of query({
@@ -262,11 +286,12 @@ export class ClaudeRunner implements ModelExecutionContract {
262
286
  thinking: resolveThinking(this.model, this.config.thinking),
263
287
  effort: this.config.effort,
264
288
  cwd: this.options.cwd,
289
+ abortController: queryAbort,
265
290
  env: { ...process.env, ...(this.config.env ?? {}) },
266
291
  pathToClaudeCodeExecutable: this.config.pathToClaudeCodeExecutable
267
292
  ?? process.env.CLAUDE_CODE_EXECUTABLE
268
293
  ?? DEFAULT_CLAUDE_PATH,
269
- ...(this.config.claudeMdContent ? { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: this.config.claudeMdContent } } : {}),
294
+ ...(systemPromptAppend ? { systemPrompt: { type: "preset" as const, preset: "claude_code" as const, append: systemPromptAppend } } : {}),
270
295
  // Turn skills ON (and auto-add the `Skill` tool). The agent-env
271
296
  // bakes the `/ac:*` skills; without this the Agent SDK leaves
272
297
  // them un-enabled and the agent can't invoke them.
@@ -314,6 +339,9 @@ export class ClaudeRunner implements ModelExecutionContract {
314
339
  // Belt-and-braces — close on error/abort too so the dangling
315
340
  // iterable doesn't leak the inboxStream consumer.
316
341
  inboxQueue?.close();
342
+ // Detach the abort bridge (no-op if it already fired, {once:true}) so a
343
+ // completed turn doesn't leak a listener on the long-lived loop signal.
344
+ opts.signal?.removeEventListener("abort", onLoopAbort);
317
345
  }
318
346
  }
319
347
  }
@@ -19,10 +19,30 @@ import { formatError } from "../utils/errors.js";
19
19
 
20
20
  function now(): string { return new Date().toISOString(); }
21
21
 
22
- const codexSpec: CliAgentSpec = {
22
+ /** Pinned codex-acp adapter version. The README warns "breaking changes
23
+ * likely", so we pin exact (not a range) and bump deliberately. `CODEX_PATH`
24
+ * overrides the bundled `@openai/codex` the adapter drives underneath. */
25
+ const CODEX_ACP_ADAPTER = "@agentclientprotocol/codex-acp@0.1.0";
26
+
27
+ /** Exported for the fixture-based parity tests (ADR-0020): the legacy JSONL
28
+ * `mapEvent` is the golden the ACP normaliser is asserted equal to. Not part
29
+ * of the public runtime surface — `createCodexRuntime` stays the entry point. */
30
+ export const codexSpec: CliAgentSpec = {
23
31
  kind: "codex",
24
32
  authEnv: "CODEX_API_KEY",
25
33
  bin: "codex",
34
+ // ACP-mode invocation (ADR-0020 increment 1). `codex` has no native `--acp`
35
+ // flag; the adapter (a Rust binary shipped via npm) speaks ACP and drives a
36
+ // compatible bundled `@openai/codex`. The runner attempts this first and
37
+ // falls back to the legacy `codex exec --json` JSONL spec below if the
38
+ // version-1 handshake fails.
39
+ acp: {
40
+ command: "npx",
41
+ args: ["-y", CODEX_ACP_ADAPTER],
42
+ // CODEX_PATH lets an operator point the adapter at a specific codex binary
43
+ // (e.g. a sandbox-baked one) instead of the adapter's bundled copy.
44
+ ...(process.env.CODEX_PATH ? { env: { CODEX_PATH: process.env.CODEX_PATH } } : {}),
45
+ },
26
46
  // Global npm install; symlink onto PATH only if the global bin dir isn't
27
47
  // already there (so a non-login `sh -c` can find it).
28
48
  install: 'sudo npm install -g @openai/codex && (command -v codex >/dev/null 2>&1 || sudo ln -sf "$(npm prefix -g)/bin/codex" /usr/local/bin/codex)',
@@ -10,6 +10,7 @@ import { codingTools, type CodingTool } from "../tools/index.js";
10
10
  import { runProcessorChain } from "../processors/runner.js";
11
11
  import type { ProcessorContext, ToolCall } from "../processors/processor.js";
12
12
  import { RequestContext } from "../request-context/request-context.js";
13
+ import { boundProcessorPause } from "../pause/pause-core.js";
13
14
  import { formatError } from "../utils/errors.js";
14
15
 
15
16
  type AiToolSet = Record<string, ReturnType<typeof tool<Record<string, unknown>, string>>>;
@@ -153,12 +154,14 @@ export class VercelRunner implements ModelExecutionContract {
153
154
  teamId: "", runId: "", workflowId: "",
154
155
  factoryId: null, apiKeyScopes: [], parentRunId: null,
155
156
  });
157
+ const gateAgentId = this.options.agentId ?? "agent";
156
158
  const gate = await this.gateToolCall(call, {
157
159
  requestContext,
158
160
  abortSignal: execOpts.abortSignal ?? signal ?? new AbortController().signal,
159
161
  retryCount: 0,
160
- agentId: this.options.agentId ?? "agent",
162
+ agentId: gateAgentId,
161
163
  iteration,
164
+ pause: boundProcessorPause(this.options.pause, { agentId: gateAgentId, iteration }),
162
165
  });
163
166
  if (gate.kind === "deny") return gate.reason;
164
167
  if (gate.kind === "abort") throw new Error(gate.reason);