@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
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `ctx.checkpoint(name, fn)` — disk-backed memoise across pause-resume.
2
+ * Disk-backed memoise across pause-resume — the engine behind durable
3
+ * `ctx.step(name, fn)` (ADR-0012).
3
4
  *
4
5
  * First call: `fn()` runs, the result is atomically written to
5
6
  * `/tmp/wf/state/checkpoints/<name>.json`. On any subsequent invocation
@@ -16,13 +17,29 @@
16
17
  * memoisation is in-sandbox only and would silently retry on a sandbox
17
18
  * recreation that lost the checkpoint file.
18
19
  *
19
- * See ADR-0006 §"`ctx.checkpoint(name, fn)` — disk-backed memoisation".
20
+ * INTERNAL only — there is no public `ctx.checkpoint`. The durable
21
+ * `ctx.step` wires `scopedMemoize("step<idx>")` and reports a "restored"
22
+ * sub-step on a cache hit. See ADR-0012.
20
23
  */
21
- /** Read-or-run-and-write helper. `name` is validated by the state-dir
22
- * layer (rejects path-escape attempts); `fn` is awaited if it returns
23
- * a Promise so sync and async callers compose identically. */
24
- export declare function checkpoint<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
25
- /** Bind user checkpoint names to a runner-owned namespace. The user's
26
- * `name` is still validated independently so a scope prefix cannot turn
27
- * an empty or path-escaping name into a valid filename. */
28
- export declare function scopedCheckpoint(scope: string): typeof checkpoint;
24
+ /** Result of a memoise call — `restored` distinguishes a disk hit (fn was
25
+ * NOT run, value read from a prior subprocess) from a fresh run (fn ran,
26
+ * value just written). The durable `ctx.step` maps `restored` onto the
27
+ * duration-0 "restored" sub-step status. */
28
+ export interface MemoizeResult<T> {
29
+ value: T;
30
+ restored: boolean;
31
+ }
32
+ /** Read-or-run-and-write helper reporting whether the value was restored
33
+ * from disk. `name` is validated by the state-dir layer (rejects
34
+ * path-escape attempts); `fn` is awaited if it returns a Promise so sync
35
+ * and async callers compose identically.
36
+ *
37
+ * A pause inside `fn` (PauseSignal) propagates BEFORE any write — a
38
+ * partially-completed body is never memoised, so the resume re-runs it. */
39
+ export declare function memoize<T>(name: string, fn: () => Promise<T> | T): Promise<MemoizeResult<T>>;
40
+ /** A memoise function bound to a runner-owned scope. */
41
+ export type ScopedMemoize = <T>(name: string, fn: () => Promise<T> | T) => Promise<MemoizeResult<T>>;
42
+ /** Bind memoise names to a runner-owned namespace (e.g. `step<idx>`). The
43
+ * caller's `name` is still validated independently so a scope prefix
44
+ * cannot turn an empty or path-escaping name into a valid filename. */
45
+ export declare function scopedMemoize(scope: string): ScopedMemoize;
@@ -27,6 +27,7 @@ export interface AgentLoopProgressState {
27
27
  reason: string;
28
28
  correlationKey: string | null;
29
29
  at: number;
30
+ payload?: Record<string, unknown>;
30
31
  } | null;
31
32
  }
32
33
  export interface SettledAgentLoopResult<TResponse = unknown> {
@@ -88,6 +88,29 @@ export declare class PauseSignal {
88
88
  * `err instanceof PauseSignal` anywhere a signal may have been thrown by a
89
89
  * different SDK instance (the runner catching a bundled workflow's signal). */
90
90
  export declare function isPauseSignal(err: unknown): err is PauseSignal;
91
+ /**
92
+ * The run's pause boundary. The runner (`run-agent` `steerPause`) closes a
93
+ * `corePause` over the ambient `runId`/`stepIndex`; the agent loop / runtime
94
+ * supplies the per-iteration `agentScope` so the pauseId stays stable across
95
+ * the loop's iteration-skipping on resume. Absent outside a workflow step
96
+ * (local / non-sandbox callers) — see `boundProcessorPause`.
97
+ */
98
+ export type BoundaryPauseFn = <T = unknown>(req: PauseRequest<T>, agentScope: {
99
+ agentId: string;
100
+ iteration: number;
101
+ }) => Promise<T>;
102
+ /**
103
+ * Build the `ProcessorContext.pause` callable for a given hook scope: binds the
104
+ * boundary to this hook's `{ agentId, iteration }` so a processor calling
105
+ * `ctx.pause(req)` gets a deterministic, resume-stable pauseId. When no boundary
106
+ * is wired (local tests, non-sandbox callers), the returned fn rejects loudly
107
+ * rather than silently no-op'ing — pause genuinely cannot work without the
108
+ * runner's snapshot/Temporal machinery.
109
+ */
110
+ export declare function boundProcessorPause(boundary: BoundaryPauseFn | undefined, scope: {
111
+ agentId: string;
112
+ iteration: number;
113
+ }): <T = unknown>(req: PauseRequest<T>) => Promise<T>;
91
114
  /** Wipe the ordinal counters. Called by the runner at first-boot alongside
92
115
  * `resetStateDir()`; a fresh module load already starts empty, so this is
93
116
  * belt-and-braces for any in-process reuse. */
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * agent-<agentInstanceId>.json ← agent loop state (iteration, messages, processor cursor)
8
8
  * runtime-<agentInstanceId>.json ← runtime-private blob (opaque to the loop)
9
- * checkpoints/<name>.json ← user `ctx.checkpoint(name, fn)` memoised values
9
+ * checkpoints/<name>.json ← durable `ctx.step(name, fn)` memoised values (keyed `step<idx>.<name>`)
10
10
  * pauses/<pauseId>.json ← pause records (request + resume payload once available)
11
11
  *
12
12
  * Atomic writes (write-tmp → fsync → rename) so a mid-write `sandbox.snapshot()`
@@ -3,7 +3,7 @@
3
3
  * covers the most common gating needs. More can be authored by users; these
4
4
  * exist as load-bearing examples and as defaults for common policies.
5
5
  */
6
- import type { Processor } from "./processor.js";
6
+ import type { Processor, ToolCall } from "./processor.js";
7
7
  /**
8
8
  * Reject any tool call whose name appears in `names`. The reason is returned
9
9
  * to the model as the tool result, so the model can react and try a
@@ -14,6 +14,25 @@ import type { Processor } from "./processor.js";
14
14
  * (candidate #1).
15
15
  */
16
16
  export declare function denyTools(names: readonly string[]): Processor;
17
+ /**
18
+ * Pause the run for HUMAN APPROVAL before a matching tool executes — the
19
+ * human-in-the-loop pre-tool gate (ADR-0006 `ctx.pause`). Over an ACP runtime
20
+ * this is exactly the `session/request_permission` path: the CLI asks to run a
21
+ * tool, the gate runs here, and `ctx.pause` snapshots the workflow and waits
22
+ * durably until a reviewer resolves the pause with `{ approved, reason? }`.
23
+ * Approve → the tool runs; deny → the reason is returned to the model as the
24
+ * tool result and the loop continues.
25
+ *
26
+ * tools? — only these tool names require approval (default: EVERY tool call).
27
+ * reason? — build the human-facing prompt from the call (default names the tool).
28
+ *
29
+ * Dormant on runtimes without pre-tool gating; active on those that wire
30
+ * `processToolCall` (the ACP CLI runtimes, the Claude pre-tool hook, Vercel).
31
+ */
32
+ export declare function humanApproval(opts?: {
33
+ tools?: readonly string[];
34
+ reason?: (call: ToolCall) => string;
35
+ }): Processor;
17
36
  /**
18
37
  * Require the calling API key to carry every scope in `required`. Aborts the
19
38
  * agent loop with a clear reason if any are missing — defence-in-depth on
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Gate-pause processor (ADR-0028).
3
+ *
4
+ * A pre-tool gate that, when its `policy` flags a tool call as needing human
5
+ * approval, pauses the run as a SERVER operation (`requestPauseAndAwait`) and
6
+ * blocks until a human answers — then allows or denies the tool.
7
+ *
8
+ * It pauses through the server pause API (`requestPauseAndAwait`), so the pause
9
+ * is a server operation no `try/catch` can swallow. It does NOT use today's
10
+ * `ctx.pause`, which still throws `PauseSignal` — exactly the swallowable path
11
+ * ADR-0028 supersedes for agents. (Routing `ctx.pause` itself through this same
12
+ * server pause is the natural unification — ADR-0028 open question — at which
13
+ * point "the pause API" and "ctx.pause" become one thing.) Because it lives in
14
+ * the shared `gateToolCall` chain, one implementation covers every runtime — the
15
+ * Claude Agent SDK `PreToolUse` hook and the ACP `session/request_permission`
16
+ * path both route through it.
17
+ *
18
+ * Opt-in: the workflow/agent supplies the `policy` (which may be a heuristic or
19
+ * an async classifier agent). The connection defaults to the run credential in
20
+ * the sandbox env; absent it (local / non-sandbox), the gate is a no-op and
21
+ * tool calls pass through untouched.
22
+ */
23
+ import type { Processor, ProcessorContext, ToolCall } from "./processor.js";
24
+ export interface GatePauseApproval {
25
+ /** Human-readable question shown on the approval UI. */
26
+ reason: string;
27
+ /** Decision options the human picks from. Defaults to Approve / Deny. */
28
+ options?: Array<{
29
+ id: string;
30
+ label: string;
31
+ }>;
32
+ }
33
+ /** Decide whether a tool call needs human approval. Return `false` to let it
34
+ * through untouched, or an approval request to pause the run until a human
35
+ * answers. May be async (e.g. a small classifier agent). */
36
+ export type GatePausePolicy = (call: ToolCall, ctx: ProcessorContext) => (false | GatePauseApproval) | Promise<false | GatePauseApproval>;
37
+ export interface GatePauseConnection {
38
+ baseUrl: string;
39
+ token: string;
40
+ runId: string;
41
+ }
42
+ export declare function createGatePauseProcessor(opts: {
43
+ policy: GatePausePolicy;
44
+ /** Override the server connection (defaults to the sandbox run-credential env). */
45
+ connection?: GatePauseConnection;
46
+ }): Processor;
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,6 @@
1
1
  export type { Processor, ProcessorContext, ProcessorVerdict, ToolCall, } from "./processor.js";
2
2
  export { Verdict } from "./processor.js";
3
3
  export { runProcessorChain } from "./runner.js";
4
- export { denyTools, requireScope, redactPattern, } from "./builtins.js";
4
+ export { denyTools, humanApproval, requireScope, redactPattern, } from "./builtins.js";
5
+ export { createGatePauseProcessor } from "./gate-pause.js";
6
+ export type { GatePausePolicy, GatePauseApproval, GatePauseConnection } from "./gate-pause.js";
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import type { AgentMessage } from "../types/protocol.js";
31
31
  import type { RequestContext } from "../request-context/request-context.js";
32
+ import type { PauseRequest } from "../pause/pause-core.js";
32
33
  /** Proposed tool call. Mirrors the relevant fields of AgentMessageToolUse but
33
34
  * lives as its own type so runtime adapters (candidate #1) can populate it
34
35
  * from their internal call shape without coupling to the agent message protocol. */
@@ -50,6 +51,18 @@ export interface ProcessorContext {
50
51
  agentId: string;
51
52
  /** Iteration of the agent loop (1-based). */
52
53
  iteration: number;
54
+ /**
55
+ * Pause the run from inside a processor — the human-approval primitive for a
56
+ * pre-tool-use gate (ADR-0006 §"reachable from every user code path";
57
+ * ADR-0020 the ACP `session/request_permission` path). On the fresh pass it
58
+ * throws `PauseSignal` (internal control flow) so the workflow snapshots and
59
+ * waits durably; on step re-entry after resolution it returns the resume
60
+ * payload (validated against `req.schema` if given). The agent loop / runtime
61
+ * wires it to the run's pause boundary with a deterministic, resume-stable
62
+ * pauseId (keyed on this hook's agentId + iteration). Calling it outside a
63
+ * sandboxed workflow step throws — pause requires the runner's pause boundary.
64
+ */
65
+ pause<T = unknown>(req: PauseRequest<T>): Promise<T>;
53
66
  }
54
67
  /**
55
68
  * Verdict returned by every processor method.
@@ -0,0 +1,140 @@
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
+ import { RequestError, type Stream } from "@agentclientprotocol/sdk";
32
+ import type * as acp from "@agentclientprotocol/sdk";
33
+ import type { AgentMessage } from "../types/protocol.js";
34
+ import type { ProcessorContext, ToolCall } from "../processors/processor.js";
35
+ import type { ToolCallGateResult } from "../types/runtime.js";
36
+ /** The wire protocol version we pin at `initialize`. Bumped only on a breaking
37
+ * ACP wire change — never silently. The package version is pinned separately
38
+ * in package.json; this integer is the real compatibility gate (ADR-0020). */
39
+ export declare const ACP_PROTOCOL_VERSION = 1;
40
+ /** Collaborators `AcpClientPeer` needs from `CliAgentRunner`, kept as a small
41
+ * injected surface so the peer stays transport-agnostic and unit-testable. */
42
+ export interface AcpClientPeerDeps {
43
+ /** Duplex ACP stream (typically `ndJsonStream(stdinWritable, stdoutReadable)`
44
+ * wired to the subprocess). The peer does not own the subprocess — only the
45
+ * protocol over this stream. */
46
+ stream: Stream;
47
+ /** Run a proposed tool call through the runner's processor chain. Mirrors
48
+ * `ClaudeRunner.gateToolCall`. May throw `PauseSignal` (human-approval
49
+ * processor) — the peer re-raises it, never swallows it. */
50
+ gateToolCall(call: ToolCall, ctx: ProcessorContext): Promise<ToolCallGateResult>;
51
+ /** PROVIDER for the current turn's processor context (requestContext, abort
52
+ * signal, agentId, iteration). A provider — not a frozen value — because the
53
+ * peer is long-lived (persistent session) and reused across turns; it must
54
+ * read the LIVE turn's context so a permission->pause binds to the right
55
+ * iteration. The runner refreshes the backing value before each turn. */
56
+ procCtx: () => ProcessorContext;
57
+ /** Turn-scoped abort signal from `sendMessage`. On abort the peer sends
58
+ * `session/cancel` for the live session. */
59
+ signal?: AbortSignal;
60
+ }
61
+ /**
62
+ * Map ONE ACP `session/update` to zero-or-more `AgentMessage`s. The single
63
+ * normaliser (ADR-0020) — defensive: any variant a given CLI never emits is
64
+ * simply absent → `[]`, and an unknown `sessionUpdate` discriminator returns
65
+ * `[]` rather than throwing (mirrors the legacy `mapEvent` `default: return []`).
66
+ *
67
+ * Lifecycle (`init` / `done`) and turn-level errors are synthesised by the
68
+ * runner / `prompt()` result, NOT here. Per-turn token usage rides the
69
+ * `session/prompt` result (`PromptResponse.usage`), not `usage_update` — the
70
+ * `usage_update` session update is a context-window/cost gauge with no per-token
71
+ * fields, so it maps to nothing (ADR-0020 Q1 verified against the live zod types).
72
+ */
73
+ export declare function normalizeSessionUpdate(update: acp.SessionUpdate): AgentMessage[];
74
+ export declare class AcpClientPeer {
75
+ private readonly deps;
76
+ private readonly conn;
77
+ /** Messages mapped from inbound `session/update` notifications, drained by the
78
+ * active `prompt()` call. REPLACED with a fresh queue at the start of every
79
+ * turn: an `AsyncQueue` is single-use (once `close()`d it stays drained), so a
80
+ * long-lived peer reused across turns must hand each turn its own queue or the
81
+ * second turn's updates would be silently dropped. The inbound `sessionUpdate`
82
+ * handler always pushes onto whatever the current queue is. */
83
+ private updates;
84
+ /** Live session id (from `session/new` / `session/resume`), needed by
85
+ * `session/cancel`. */
86
+ private sessionId;
87
+ /** Captured `PauseSignal` thrown out of the permission handler. The JSON-RPC
88
+ * layer would otherwise convert the throw into an error response; we instead
89
+ * stash it and re-raise it from `prompt()` so it unwinds to `serveStep`. */
90
+ private pendingPause;
91
+ private cancelled;
92
+ /** Trips the in-flight `session/prompt` turn when `cancel()` / `close()` fires.
93
+ * The agent is supposed to answer `cancelled` after `session/cancel`, but a
94
+ * fully-silent / dead CLI may never resolve the prompt request — so the abort
95
+ * latch unblocks `prompt()` deterministically instead of hanging on the
96
+ * outstanding JSON-RPC request. `null` between turns. */
97
+ private abortPrompt;
98
+ constructor(deps: AcpClientPeerDeps);
99
+ /**
100
+ * Negotiate the wire protocol at `initialize`. Pins `protocolVersion` to
101
+ * integer 1 and advertises NO fs / terminal capabilities. Returns the
102
+ * version the agent negotiated — `CliAgentRunner` compares it to
103
+ * `ACP_PROTOCOL_VERSION` to decide ACP-path vs. legacy-JSONL fallback.
104
+ */
105
+ initialize(): Promise<{
106
+ protocolVersion: number;
107
+ }>;
108
+ /**
109
+ * Start a session and return its id (threaded back onto the synthesised
110
+ * `init` / `done`). Resume is via the CLI's own persisted thread/rollout on
111
+ * the sandbox FS keyed by this id — not an ACP `session/resume` round-trip;
112
+ * the re-issued turn on resume continues that thread (ADR-0020 Q4, validated
113
+ * in the manual live step).
114
+ */
115
+ startSession(cwd: string): Promise<string>;
116
+ /**
117
+ * Drive one `session/prompt` turn, yielding `AgentMessage`s as the agent
118
+ * streams `session/update` notifications. Resolves the generator when the
119
+ * prompt result lands (clean `stopReason`), or yields `{type:"error"}` on a
120
+ * `refusal` stop reason or a JSON-RPC error. A `cancelled` stop reason ends
121
+ * the turn with no error. Re-raises any `PauseSignal` captured by the
122
+ * permission handler so it unwinds past `sendMessage` to `serveStep`.
123
+ */
124
+ prompt(text: string): AsyncGenerator<AgentMessage>;
125
+ /** The live session id, or undefined before `startSession`. Threaded back
126
+ * onto the synthesised `init` / `done` by the runner. */
127
+ get currentSessionId(): string | undefined;
128
+ /** Send `session/cancel` for the live session (notification, fire-and-forget).
129
+ * Idempotent. The pending `session/prompt` is expected to resolve
130
+ * `cancelled`; any outstanding `session/request_permission` resolves with
131
+ * `outcome:"cancelled"` via the handler's signal check. */
132
+ cancel(): void;
133
+ /** Tear down the connection. Trips the abort latch so any in-flight `prompt()`
134
+ * turn unblocks, and closes the updates queue. Used both on clean turn end and
135
+ * when the runner falls back to the legacy JSONL path after a version
136
+ * mismatch. */
137
+ close(): void;
138
+ private onRequestPermission;
139
+ }
140
+ export { RequestError };
@@ -23,9 +23,28 @@
23
23
  * (see each spec's `authEnv`); `commands.run` inherits the sandbox env, so
24
24
  * values set via `agentc secrets set` are visible to the CLI.
25
25
  */
26
- import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider } from "../index.js";
26
+ import type { AgentMessage, ModelExecutionContract, RuntimeOptions, SandboxProvider, ToolCallGateResult } from "../index.js";
27
+ import type { ProcessorContext, ToolCall } from "../processors/processor.js";
27
28
  /** Single-quote a value for safe interpolation into a `sh -c` command line. */
28
29
  export declare function shellQuote(value: string): string;
30
+ /** Hard deadline (ms) for the ACP `initialize` handshake AND the prompt turn.
31
+ * `peer.initialize()` resolves only when the agent answers; a real CLI that
32
+ * never speaks ACP (or blocks on stdin) would otherwise hang forever and the
33
+ * JSONL fallback would never be reached. On timeout we tear the peer down and
34
+ * route to JSONL exactly like a handshake error. Overridable via the env for
35
+ * ops tuning; defaults sane. */
36
+ export declare const ACP_HANDSHAKE_TIMEOUT_MS: number;
37
+ /** Readiness gate for the LIVE ACP attempt. The duplex-stdin transport in
38
+ * `spawnAcpProcess` is now real (`commands.spawnDuplex` on the local provider),
39
+ * so the agent's `initialize` request bytes are delivered and the handshake can
40
+ * actually negotiate. The transport is live; this is `true`.
41
+ *
42
+ * Readiness alone is not sufficient — the runner still requires the live
43
+ * provider to expose `commands.spawnDuplex`. A provider without it (the
44
+ * server→sandbox vercel/e2b view never spawns the in-VM CLI, so neither
45
+ * implements duplex) falls back to JSONL cleanly via the capability check in
46
+ * `sendMessage`. */
47
+ export declare const ACP_TRANSPORT_READY = true;
29
48
  /** Per-CLI behaviour. The base owns the lifecycle (init/done/error) and the
30
49
  * transport (spawn + JSONL parse); a spec owns the CLI-specific bits. */
31
50
  export interface CliAgentSpec {
@@ -59,11 +78,27 @@ export interface CliAgentSpec {
59
78
  }): string;
60
79
  /** Map one parsed JSONL stdout event to `AgentMessage`s. The base emits
61
80
  * `init`/`done`/`error` lifecycle itself, so a spec maps only content +
62
- * usage (text / thinking / tool_use / tool_result / usage). */
81
+ * usage (text / thinking / tool_use / tool_result / usage).
82
+ *
83
+ * LEGACY FALLBACK PATH. Retained for a pinned-old CLI that predates ACP
84
+ * support (the `initialize` handshake negotiates a non-1 version → the
85
+ * runner closes the ACP peer and drives this JSONL path instead). Removed
86
+ * only after the last subprocess CLI is on ACP (ADR-0020 increment 5). */
63
87
  mapEvent(parsed: Record<string, unknown>): AgentMessage[];
64
88
  /** Pull a session/thread id out of a parsed event so the next turn can
65
- * resume it (codex `thread.started.thread_id`, amp `session_id`). */
89
+ * resume it (codex `thread.started.thread_id`, amp `session_id`).
90
+ * LEGACY FALLBACK PATH (see `mapEvent`). */
66
91
  extractSessionId(parsed: Record<string, unknown>): string | undefined;
92
+ /** ACP-mode invocation (the Zed `agent_servers` shape). When present, the
93
+ * runner spawns the CLI in ACP agent mode and delegates the whole wire
94
+ * protocol to `AcpClientPeer`; the legacy `promptPayload`/`buildCommand`/
95
+ * `extractSessionId`/`mapEvent` members are used only as the version-mismatch
96
+ * fallback. Absent → the spec is JSONL-only (legacy path always). */
97
+ acp?: {
98
+ command: string;
99
+ args: string[];
100
+ env?: Record<string, string>;
101
+ };
67
102
  }
68
103
  export declare class CliAgentRunner implements ModelExecutionContract {
69
104
  private readonly sandbox;
@@ -71,8 +106,60 @@ export declare class CliAgentRunner implements ModelExecutionContract {
71
106
  private readonly spec;
72
107
  private readonly configModel?;
73
108
  readonly kind: string;
109
+ /** Whether the live ACP path will actually run for this runner: the spec
110
+ * declares an `acp` invocation, the transport is ready, AND the live provider
111
+ * exposes the duplex-stdin primitive ACP needs. A provider without
112
+ * `commands.spawnDuplex` (the server→sandbox vercel/e2b view) can't carry the
113
+ * JSON-RPC duplex, so the runner falls back to JSONL — and in that state the
114
+ * CLI owns its own tool loop with no pre-tool seam. */
115
+ private get acpCapable();
116
+ /** ACP-capable specs gate tools through the shared processor chain (the CLI
117
+ * no longer owns its own unsupervised tool loop). True ONLY when the ACP path
118
+ * will actually run — a JSONL-only spec, an ACP spec while the transport is
119
+ * dormant (`acpTransportReady` false), OR a provider that can't spawn a duplex
120
+ * leaves the CLI in charge with NO pre-tool seam, so this must report false in
121
+ * those states. Otherwise the agent loop would suppress its "processToolCall
122
+ * gating is dormant" warning and a registered deny/gate processor would be
123
+ * silently un-enforced for the agent. */
124
+ get supportsToolCallProcessor(): boolean;
125
+ /** Once the handshake has decided ACP vs. legacy for this runner instance,
126
+ * cache it so every later turn skips re-probing. `undefined` = not yet
127
+ * decided. */
128
+ private acpDecision;
129
+ /** The persistent ACP session, established lazily on the first ACP turn and
130
+ * reused across every later turn (a real ACP agent is a LONG-LIVED JSON-RPC
131
+ * server — one process, one initialize, one session — driven turn-by-turn via
132
+ * `session/prompt`, NOT respawned per turn). `undefined` until the first
133
+ * successful handshake; never re-set after a fallback (we switch to JSONL
134
+ * for the whole runner instead). */
135
+ private acpSession;
136
+ /** The CURRENT turn's processor context. The long-lived ACP peer is built
137
+ * once but reused across turns, so it reads the gate context through a
138
+ * provider (`procCtx: () => this.currentAcpProcCtx`) rather than a frozen
139
+ * value — `sendMessageAcp` refreshes this before each prompt so a
140
+ * permission->pause binds to THIS turn's iteration (stable pauseId on
141
+ * resume), not the first turn's. */
142
+ private currentAcpProcCtx;
143
+ /** Per-instance copy of the live-ACP readiness gate (defaults to the module
144
+ * const `ACP_TRANSPORT_READY`). Tests that drive the ACP lifecycle/fallback/
145
+ * pause/parity paths flip it on via `enableAcpForTesting()` so they keep
146
+ * exercising the real `sendMessageAcp` even while the production default is
147
+ * dormant. Not a public API. */
148
+ private acpTransportReady;
74
149
  constructor(sandbox: SandboxProvider, options: RuntimeOptions, spec: CliAgentSpec, configModel?: string | undefined);
150
+ /** TEST-ONLY: flip the live-ACP readiness gate on for this runner instance so
151
+ * the ACP lifecycle / fallback tests can exercise `sendMessageAcp` while the
152
+ * production default (`ACP_TRANSPORT_READY`) stays dormant. Not part of the
153
+ * public runtime surface. */
154
+ enableAcpForTesting(): void;
75
155
  get model(): string | undefined;
156
+ /** Runtime-owned pre-tool gate. Runs the shared processor chain and maps its
157
+ * verdict to a `ToolCallGateResult` — identical to `ClaudeRunner.gateToolCall`.
158
+ * Invoked by `AcpClientPeer`'s `session/request_permission` handler. A
159
+ * processor that throws `PauseSignal` propagates through unchanged (the
160
+ * handler re-raises it; never swallowed). */
161
+ gateToolCall(call: ToolCall, ctx: ProcessorContext): Promise<ToolCallGateResult>;
162
+ private buildProcCtx;
76
163
  private installed;
77
164
  /** Provision the CLI on demand. A no-op once `bin` is on PATH — the steady
78
165
  * state under `snapshots: { bootFrom: "reuse" }`, where the first run's
@@ -85,5 +172,70 @@ export declare class CliAgentRunner implements ModelExecutionContract {
85
172
  iteration?: number;
86
173
  signal?: AbortSignal;
87
174
  }): AsyncGenerator<AgentMessage>;
175
+ /**
176
+ * Tear down the failed/probed ACP process on a fallback path: group-kill the
177
+ * subprocess (so the npx-spawned grandchild dies too, not just `sh`) and
178
+ * `.catch()` its `exited` promise so the abandoned process can't surface an
179
+ * unhandled rejection once we've stopped awaiting it. A best-effort stdin
180
+ * close is attempted first — a conformant CLI exits cleanly on EOF — but we
181
+ * rely on the group-kill for force-termination of one that won't.
182
+ */
183
+ private teardownAcp;
184
+ /**
185
+ * Establish the persistent ACP session for this runner instance, ONCE,
186
+ * lazily on the first ACP turn: spawn the long-lived ACP agent process, run
187
+ * `initialize`, and open the single `session/new`. The result is cached on the
188
+ * instance and reused by every later turn — a real ACP agent
189
+ * (`npx @agentclientprotocol/codex-acp`) is a long-lived JSON-RPC server that
190
+ * does NOT exit after one turn, so respawning per turn would both hang on
191
+ * `proc.exited` and break multi-turn continuity (each turn would open a fresh
192
+ * session and lose the thread).
193
+ *
194
+ * Returns the cached session, or `null` to signal the version-mismatch /
195
+ * handshake-error / stall fallback — in which case the failed process has
196
+ * already been torn down (group-killed + exited-catch) by `teardownAcp`, and
197
+ * the caller yields `ACP_FALLBACK`. The persistent process is otherwise killed
198
+ * only on the abort signal (wired in `spawnAcpProcess`) and is reclaimed when
199
+ * the sandbox VM is torn down at run-end — we never block on its exit.
200
+ */
201
+ private ensureAcpSession;
202
+ /**
203
+ * ACP transport, ONE turn. On the FIRST turn it lazily establishes the
204
+ * persistent session (`ensureAcpSession`); every later turn REUSES that same
205
+ * process + peer + session id — `session/prompt` against the live session, no
206
+ * respawn, no new session. On a handshake error / version mismatch / session
207
+ * open failure (first turn only) it yields `ACP_FALLBACK` so `sendMessage`
208
+ * switches to the JSONL path. Never yields a duplicate `init` (the caller
209
+ * already did). CRITICALLY: never `await proc.exited` — the ACP agent is a
210
+ * long-lived server that does not exit after a turn; blocking on its exit
211
+ * would hang the loop forever.
212
+ */
213
+ private sendMessageAcp;
214
+ /**
215
+ * Spawn the CLI in ACP agent mode and wrap its stdio as an `ndJsonStream`
216
+ * duplex.
217
+ *
218
+ * The real stdin/stdout duplex comes from `commands.spawnDuplex` — the in-VM
219
+ * `child_process` pipe (`makeLocalSandboxProvider`). The runner runs INSIDE
220
+ * the sandbox VM and spawns the CLI via the local provider, so a duplex stdin
221
+ * is a normal pipe and works identically on Vercel/E2B/local (the
222
+ * @vercel/sandbox no-stdin limit is the server→sandbox boundary, not this
223
+ * one). `ndJsonStream(stdin, stdout)` then carries JSON-RPC both ways:
224
+ * outbound `initialize`/`session/*` request frames down stdin, inbound
225
+ * `session/update` notifications + results up stdout.
226
+ *
227
+ * Caller contract: only invoked when `commands.spawnDuplex` is present (the
228
+ * capability check in `sendMessage` gates the whole ACP attempt on it), so a
229
+ * provider without duplex never reaches here — it falls back to JSONL.
230
+ */
231
+ private spawnAcpProcess;
232
+ /**
233
+ * Legacy JSONL transport (the version-mismatch fallback, ADR-0020 increment
234
+ * 5 retires it). Unchanged behaviour: write the prompt to a file, run the
235
+ * one-shot command, line-buffer stdout, `mapEvent` each parsed line, and cap
236
+ * with the runner-synthesised `done`. Does NOT re-yield `init` (the caller
237
+ * already did).
238
+ */
239
+ private sendMessageJsonl;
88
240
  }
89
241
  export declare function createCliAgentRuntime(spec: CliAgentSpec, configModel?: string): import("../index.js").AgentRuntime<SandboxProvider>;
@@ -18,6 +18,6 @@ export interface AmpRuntimeConfig {
18
18
  /** Amp uses its configured model; reserved for forward-compatibility. */
19
19
  model?: string;
20
20
  }
21
- export declare function createAmpRuntime(config?: AmpRuntimeConfig): import("../index.js").AgentRuntime<import("../sandbox.js").SandboxProvider>;
22
- declare const _default: import("../index.js").AgentRuntime<import("../sandbox.js").SandboxProvider>;
21
+ export declare function createAmpRuntime(config?: AmpRuntimeConfig): import("../index.js").AgentRuntime<import("../index.js").SandboxProvider>;
22
+ declare const _default: import("../index.js").AgentRuntime<import("../index.js").SandboxProvider>;
23
23
  export default _default;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * WS-C / ADR-0020 Increment 2 — END-TO-END transport tests for the live ACP
3
+ * path. Unlike `cli-agent.test.ts` (which drives `AcpClientPeer` over an
4
+ * in-memory cross-piped `TransformStream` against a fake `AgentSideConnection`
5
+ * in the SAME process), these spawn a REAL child process — the committed
6
+ * `__fixtures__/fake-acp-agent.mjs` — through
7
+ * `makeLocalSandboxProvider().commands.spawnDuplex` and drive a full
8
+ * `CliAgentRunner.sendMessage` turn against it.
9
+ *
10
+ * Why this matters: the in-memory fixtures never exercise stdin *delivery*. The
11
+ * historical no-duplex-stdin reality (the @vercel/sandbox server→sandbox
12
+ * boundary) meant a spawned "ACP" process never received the client's
13
+ * `initialize` frame, so the handshake hung and the runner fell back to JSONL.
14
+ * Increment 2's `commands.spawnDuplex` on the local provider is a genuine
15
+ * `child_process` pipe; these tests prove the JSON-RPC duplex round-trips over
16
+ * real OS pipes — `initialize` / `session/new` / `session/prompt` frames go
17
+ * DOWN the child's stdin and `session/update` notifications + results come back
18
+ * UP its stdout — end to end, with no in-memory shortcut.
19
+ *
20
+ * Three scenarios, one fake agent (mode selected via `FAKE_ACP_MODE`):
21
+ * 1. full turn — init → thinking/text/tool_use/tool_result/plan/usage → done
22
+ * 2. cancel mid-turn — AbortSignal fires; session/cancel is sent; turn unwinds
23
+ * 3. deny-gate — a denyTools-style processor rejects a tool over ACP
24
+ * WITHOUT ctx.pause (gateToolCall deny → reject option)
25
+ *
26
+ * The in-memory normaliser/lifecycle/fallback/pause suites in
27
+ * `cli-agent.test.ts` stay green — they validate the mapping; these validate
28
+ * the wire.
29
+ */
30
+ export {};
@@ -1,9 +1,25 @@
1
1
  /**
2
- * CliAgentRunner self-provisioning — the runtime installs its CLI on demand
3
- * (`command -v <bin>` → install if missing), runs the install at most once per
4
- * runner, and surfaces an install failure as an `error` message rather than
5
- * blowing up. This is what lets `createCodexRuntime()` work on a bare sandbox
6
- * with no image baking, and lets `snapshots: { bootFrom: "reuse" }` collapse
7
- * the install to a one-time cost.
2
+ * WS-C / ADR-0020 — fixture-based parity + lifecycle tests for the ACP path.
3
+ *
4
+ * There is NO live Codex/Amp/Gemini CLI in the test sandbox, so parity is
5
+ * proven against fixtures rather than a running agent (design doc §Parity-test
6
+ * strategy). Two equivalent fixture sets live under `__fixtures__/`:
7
+ *
8
+ * - `acp/codex/<case>.ndjson` — ACP `session/update` notifications (one per
9
+ * line), plus, for the usage/error cases, a single `PromptResponse`.
10
+ * - `jsonl/codex/<case>.jsonl` — the EQUIVALENT legacy codex JSONL events the
11
+ * current `codexSpec.mapEvent` consumes.
12
+ *
13
+ * The parity gate feeds each ACP fixture through the single `normalizeSessionUpdate`
14
+ * normaliser and the JSONL fixture through `codexSpec.mapEvent`, then asserts the
15
+ * two `AgentMessage` arrays are EQUAL MODULO `timestamp` (timestamps are
16
+ * now()-derived; we strip them before comparing). New-kind cases (`plan`,
17
+ * structured `diffs`) have no legacy counterpart — they assert the normaliser
18
+ * output against a golden directly.
19
+ *
20
+ * The lifecycle / fallback / pause suites drive the REAL `AcpClientPeer` over an
21
+ * in-memory `ndJsonStream` duplex against a fake `AgentSideConnection`, so the
22
+ * synthesised init/done ordering, the version-1 handshake fallback, and the
23
+ * PauseSignal-propagation invariant are exercised end-to-end with no subprocess.
8
24
  */
9
25
  export {};
@@ -12,10 +12,15 @@
12
12
  * Verified against codex-cli 0.124.0: `codex exec --json` + resume-by-thread,
13
13
  * with the command_execution / reasoning / agent_message item shapes below.
14
14
  */
15
+ import { type CliAgentSpec } from "./_cli-agent.js";
16
+ /** Exported for the fixture-based parity tests (ADR-0020): the legacy JSONL
17
+ * `mapEvent` is the golden the ACP normaliser is asserted equal to. Not part
18
+ * of the public runtime surface — `createCodexRuntime` stays the entry point. */
19
+ export declare const codexSpec: CliAgentSpec;
15
20
  export interface CodexRuntimeConfig {
16
21
  /** Codex model id (`-m`). Omit to use the codex CLI's configured default. */
17
22
  model?: string;
18
23
  }
19
- export declare function createCodexRuntime(config?: CodexRuntimeConfig): import("../index.js").AgentRuntime<import("../sandbox.js").SandboxProvider>;
20
- declare const _default: import("../index.js").AgentRuntime<import("../sandbox.js").SandboxProvider>;
24
+ export declare function createCodexRuntime(config?: CodexRuntimeConfig): import("../index.js").AgentRuntime<import("../index.js").SandboxProvider>;
25
+ declare const _default: import("../index.js").AgentRuntime<import("../index.js").SandboxProvider>;
21
26
  export default _default;