@agent-compose/sdk 0.5.8 → 0.5.9

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 (70) hide show
  1. package/dist/agent/agent-context.d.ts +1 -1
  2. package/dist/agent/agent-loop.d.ts +22 -12
  3. package/dist/agent/local-pause-request.d.ts +49 -0
  4. package/dist/agent/local-pause-request.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 +4 -2
  8. package/dist/index.js +1275 -527
  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/index.d.ts +1 -1
  15. package/dist/processors/processor.d.ts +13 -0
  16. package/dist/runtimes/_acp-client.d.ts +140 -0
  17. package/dist/runtimes/_cli-agent.d.ts +155 -3
  18. package/dist/runtimes/amp.d.ts +2 -2
  19. package/dist/runtimes/cli-agent-acp-live.test.d.ts +30 -0
  20. package/dist/runtimes/cli-agent.test.d.ts +22 -6
  21. package/dist/runtimes/codex.d.ts +7 -2
  22. package/dist/runtimes/openai-desktop.js +1263 -527
  23. package/dist/runtimes/vercel.js +389 -2
  24. package/dist/sandbox.d.ts +113 -19
  25. package/dist/types/__tests__/environment-build-flag.test.d.ts +1 -0
  26. package/dist/types/__tests__/workflow-metadata-provider.test.d.ts +1 -0
  27. package/dist/types/execution-context.d.ts +0 -8
  28. package/dist/types/protocol.d.ts +32 -1
  29. package/dist/types/runtime.d.ts +7 -0
  30. package/dist/types/sandbox-environment.d.ts +6 -1
  31. package/dist/types/sandbox.d.ts +41 -6
  32. package/dist/types/workflow-metadata.d.ts +40 -10
  33. package/dist/types/workflow.d.ts +22 -6
  34. package/dist/utils/bundler.d.ts +5 -1
  35. package/dist/workflow-steps/observability.d.ts +28 -2
  36. package/dist/workflow-steps/types.d.ts +11 -7
  37. package/dist/workflow-steps/workflow.d.ts +3 -2
  38. package/package.json +3 -2
  39. package/src/agent/agent-context.ts +14 -6
  40. package/src/agent/agent-loop.ts +59 -10
  41. package/src/agent/local-pause-request.ts +90 -0
  42. package/src/agent/run-agent.ts +6 -2
  43. package/src/agent/steer-control.ts +21 -7
  44. package/src/client.ts +35 -1
  45. package/src/index.ts +10 -2
  46. package/src/pause/checkpoint.ts +33 -14
  47. package/src/pause/manager.ts +2 -2
  48. package/src/pause/pause-core.ts +35 -0
  49. package/src/pause/state-dir.ts +2 -2
  50. package/src/processors/builtins.ts +44 -1
  51. package/src/processors/index.ts +1 -0
  52. package/src/processors/processor.ts +13 -0
  53. package/src/runtimes/_acp-client.ts +516 -0
  54. package/src/runtimes/_cli-agent.ts +418 -3
  55. package/src/runtimes/claude.ts +27 -3
  56. package/src/runtimes/codex.ts +21 -1
  57. package/src/runtimes/vercel.ts +4 -1
  58. package/src/sandbox.ts +361 -54
  59. package/src/types/execution-context.ts +0 -8
  60. package/src/types/protocol.ts +27 -1
  61. package/src/types/runtime.ts +7 -0
  62. package/src/types/sandbox-environment.ts +12 -1
  63. package/src/types/sandbox.ts +40 -6
  64. package/src/types/workflow-metadata.ts +42 -10
  65. package/src/types/workflow.ts +22 -7
  66. package/src/utils/bundler.ts +6 -1
  67. package/src/workflow-steps/observability.ts +51 -5
  68. package/src/workflow-steps/runner.ts +9 -5
  69. package/src/workflow-steps/types.ts +11 -7
  70. package/src/workflow-steps/workflow.ts +3 -2
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Local pause-request marker — the in-sandbox bridge from `agentc pause` to
3
+ * the agent loop's snapshot-release pause boundary.
4
+ *
5
+ * `agentc pause` runs as a grandchild subprocess of the runner (the agent CLI
6
+ * shells out to it). It cannot throw a `PauseSignal` into the loop and the
7
+ * in-memory steer flag (`signalSteerPending`) lives in a different process, so
8
+ * the only reliable channel is the shared sandbox filesystem. The CLI writes a
9
+ * durable marker here; the agent loop consumes it at the end of the turn
10
+ * (alongside the `needs_input` self-pause) and stages a real `ctx.pause` the
11
+ * next boundary takes — which snapshots the sandbox, releases the activity
12
+ * (compute stops), and parks the workflow. On resume the human's answer is
13
+ * delivered as the agent's next user turn.
14
+ *
15
+ * This is the ONLY place the marker path + shape are defined — both the CLI
16
+ * (writer) and the SDK loop (reader) import it, so the two halves can never
17
+ * drift. Like the rest of the state-dir, the layout is a wire protocol between
18
+ * the runner and the next subprocess invocation (ADR-0006). The marker is
19
+ * consumed BEFORE the snapshot, so it never needs to survive a pause.
20
+ *
21
+ * Scoped by `agentId` so concurrent `agent()` calls sharing one sandbox each
22
+ * see only their own request. When the id is unavailable (older agent env that
23
+ * doesn't inject `AGENT_COMPOSE_AGENT_ID`) both sides fall back to a single
24
+ * unscoped slot — correct for the common single-agent step, and the only case
25
+ * where an unscoped marker can be ambiguous (two anonymous agents) is one the
26
+ * old block-poll CLI couldn't handle either.
27
+ */
28
+
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { randomBytes } from "node:crypto";
32
+
33
+ import { getStateDir } from "../pause/state-dir.js";
34
+
35
+ /** A choice offered to the human. A bare string is shorthand for
36
+ * `{ label, value }` with both equal — exactly what the dashboard's
37
+ * `readOptions` accepts. */
38
+ export type PauseOption = string | { label: string; value: string };
39
+
40
+ /** What `agentc pause` records for the loop to turn into a `ctx.pause`. */
41
+ export interface LocalPauseRequest {
42
+ /** The question shown to the human (becomes the pause `reason`). */
43
+ reason: string;
44
+ /** Optional offered choices, rendered as buttons in the dashboard. */
45
+ options?: PauseOption[];
46
+ }
47
+
48
+ const UNSCOPED = "_unscoped";
49
+
50
+ const requestsDir = () => join(getStateDir(), "pause-requests");
51
+ const markerPath = (agentId: string | undefined | null) =>
52
+ join(requestsDir(), `${slug(agentId) || UNSCOPED}.json`);
53
+
54
+ /** Keep the agentId filename-safe. Agent ids are `step<idx>-agent-<n>` shaped,
55
+ * but defend against anything exotic so the marker can never escape the dir. */
56
+ function slug(agentId: string | undefined | null): string {
57
+ return (agentId ?? "").replace(/[^a-zA-Z0-9_.-]/g, "_");
58
+ }
59
+
60
+ /** Write the marker atomically (tmp → rename) so the loop never reads a
61
+ * partial file mid-write. Called by `agentc pause`. */
62
+ export function writeLocalPauseRequest(agentId: string | undefined | null, req: LocalPauseRequest): void {
63
+ mkdirSync(requestsDir(), { recursive: true });
64
+ const final = markerPath(agentId);
65
+ const tmp = `${final}.${randomBytes(6).toString("hex")}.tmp`;
66
+ writeFileSync(tmp, JSON.stringify(req), "utf8");
67
+ renameSync(tmp, final);
68
+ }
69
+
70
+ /** Take-once read: returns + deletes this agent's pending pause request, else
71
+ * null. Checks the agent-scoped slot first, then the unscoped fallback. The
72
+ * loop calls this once per turn; a malformed marker is dropped (deleted +
73
+ * null) rather than wedging the loop. */
74
+ export function consumeLocalPauseRequest(agentId: string | undefined | null): LocalPauseRequest | null {
75
+ const paths = [...new Set([markerPath(agentId), markerPath(null)])]; // dedupe when agentId is absent
76
+ for (const path of paths) {
77
+ if (!existsSync(path)) continue;
78
+ try {
79
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
80
+ rmSync(path, { force: true });
81
+ if (raw && typeof raw === "object" && typeof (raw as LocalPauseRequest).reason === "string" && (raw as LocalPauseRequest).reason.trim().length > 0) {
82
+ const r = raw as LocalPauseRequest;
83
+ return { reason: r.reason.trim(), ...(Array.isArray(r.options) && r.options.length > 0 ? { options: r.options } : {}) };
84
+ }
85
+ } catch {
86
+ rmSync(path, { force: true }); // unreadable / partial → drop it
87
+ }
88
+ }
89
+ return null;
90
+ }
@@ -13,9 +13,10 @@
13
13
  import { z } from "zod";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { getActiveStep, nextAgentCallInActiveStep } from "../active-step.js";
16
- import { corePause } from "../pause/pause-core.js";
16
+ import { corePause, type PauseRequest } from "../pause/pause-core.js";
17
17
  import { agentLoop } from "./agent-loop.js";
18
18
  import { consumeSteerPending, runControlPoller } from "./steer-control.js";
19
+ import { consumeLocalPauseRequest } from "./local-pause-request.js";
19
20
  import type { AgentLifecycleEvent, AgentLoopResult } from "./agent-loop.js";
20
21
  import { AsyncQueue } from "./async-queue.js";
21
22
  import type { AgentMessage, AgentStatus } from "../types/protocol.js";
@@ -315,7 +316,7 @@ export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopR
315
316
  const activeStepForPause = getActiveStep();
316
317
  const steerPause = activeStepForPause && runId
317
318
  ? function steerPauseFn<T>(
318
- req: { reason: string; correlationKey?: string; schema?: z.ZodType<T> },
319
+ req: PauseRequest<T>,
319
320
  agentScope: { agentId: string; iteration: number },
320
321
  ): Promise<T> {
321
322
  return corePause<T>(req, {
@@ -365,6 +366,9 @@ export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopR
365
366
  // PR 7 steer-pause wiring.
366
367
  mode,
367
368
  consumeSteerPending: () => consumeSteerPending(agentId),
369
+ // `agentc pause` self-pause: the loop reads the marker this agent's CLI
370
+ // dropped in the state dir and stages a snapshot-release pause from it.
371
+ consumeLocalPauseRequest: () => consumeLocalPauseRequest(agentId),
368
372
  ...(steerPause ? { pause: steerPause } : {}),
369
373
  });
370
374
  } finally {
@@ -17,13 +17,27 @@
17
17
  import { z } from "zod";
18
18
 
19
19
  /** What a steer/resume answer carries. Used as the boundary pause's `schema`
20
- * so it is validated client-side in the re-spawned runner — a null/empty
21
- * message is rejected (PauseSchemaError) so an agent never resumes on an
22
- * empty steer. `message` becomes the agent's next user turn. */
23
- export const SteerDecisionSchema = z.object({
24
- message: z.string().min(1),
25
- actor: z.string().nullish(),
26
- });
20
+ * so it is validated client-side in the re-spawned runner — an empty answer
21
+ * is rejected (PauseSchemaError) so an agent never resumes on nothing. The
22
+ * normalised `message` becomes the agent's next user turn.
23
+ *
24
+ * Accepts BOTH resume-payload conventions and normalises to `{ message }`:
25
+ * - `{ message }` — the SDK / API steer convention (`answerSteer`).
26
+ * - `{ decision }` — what the dashboard's RunPausePanel universally sends
27
+ * for every pause (option click or free text). Without this, resuming an
28
+ * agent steer / `needs_input` / `agentc pause` pause from the dashboard
29
+ * failed schema validation in the re-spawned runner and the agent never
30
+ * got the answer — the resume looked like it did nothing. */
31
+ export const SteerDecisionSchema = z
32
+ .object({
33
+ message: z.string().min(1).optional(),
34
+ decision: z.string().min(1).optional(),
35
+ actor: z.string().nullish(),
36
+ })
37
+ .transform((d) => ({ message: (d.message ?? d.decision ?? "").trim(), actor: d.actor ?? null }))
38
+ .refine((d) => d.message.length > 0, {
39
+ message: "a steer/resume answer needs a non-empty `message` or `decision`",
40
+ });
27
41
  export type SteerDecision = z.infer<typeof SteerDecisionSchema>;
28
42
 
29
43
  /** Metadata a pending steer carries from the control message to the boundary
package/src/client.ts CHANGED
@@ -103,7 +103,8 @@ export interface RegisterWorkflowInput {
103
103
  /** All snapshot config — `bootFrom` (where to restore at run start),
104
104
  * `save`, `retain`. See `WorkflowMetadata.snapshots`. */
105
105
  snapshots?: SnapshotConfig;
106
- /** Sandbox machine size (template default). See `WorkflowMetadata.resources`. */
106
+ /** Sandbox machine resources — size + provider (template defaults).
107
+ * See `WorkflowMetadata.resources`. */
107
108
  resources?: SandboxResources;
108
109
  /** Provider-neutral execution plan detected by the CLI bundler. */
109
110
  workflowPlan?: WorkflowPlan;
@@ -121,6 +122,10 @@ export interface RegisterWorkflowInput {
121
122
  inputSchema?: IOSchema;
122
123
  /** Output schema extracted from the workflow's `output` zod schema. */
123
124
  outputSchema?: IOSchema;
125
+ /** Set by `defineSandboxEnvironment` — marks an environment build so the
126
+ * server skips the /factory mount for its runs (#13). See
127
+ * `WorkflowMetadata.environmentBuild`. */
128
+ environmentBuild?: boolean;
124
129
  /** Factory slug. Defaults to `"default"`. */
125
130
  factorySlug?: string;
126
131
  }
@@ -1240,6 +1245,35 @@ export class AgentComposeClient {
1240
1245
  return this.fetch(templatePath(factorySlug, workflowName, "secrets", key), { method: "DELETE" });
1241
1246
  }
1242
1247
 
1248
+ // ── Factory-level secrets (ADR-0014) ────────────────────────────────────────
1249
+ // The inherited tier: a factory secret is visible to EVERY workflow in the
1250
+ // factory; a workflow secret of the same key overrides it. Values are stored
1251
+ // in GCP Secret Manager; never returned by reads.
1252
+
1253
+ /** Create or update a factory-level secret. */
1254
+ setFactorySecret(key: string, value: string, opts?: SecretOptions): Promise<SetSecretResult> {
1255
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
1256
+ return this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/secrets`, {
1257
+ method: "POST",
1258
+ body: { key, value },
1259
+ });
1260
+ }
1261
+
1262
+ /** List factory-level secret keys (metadata only — values are never returned). */
1263
+ async listFactorySecrets(opts?: SecretOptions): Promise<SecretListEntry[]> {
1264
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
1265
+ const body = await this.fetch<{ secrets: Array<{ secretKey: string; createdAt: string; updatedAt: string }> }>(
1266
+ `/api/v1/factories/${encodeURIComponent(factorySlug)}/secrets`,
1267
+ );
1268
+ return body.secrets.map(s => ({ key: s.secretKey, createdAt: s.createdAt, updatedAt: s.updatedAt }));
1269
+ }
1270
+
1271
+ /** Delete a factory-level secret. */
1272
+ deleteFactorySecret(key: string, opts?: SecretOptions): Promise<void> {
1273
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
1274
+ return this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/secrets/${encodeURIComponent(key)}`, { method: "DELETE" });
1275
+ }
1276
+
1243
1277
  // ── API keys ───────────────────────────────────────────────────────────────
1244
1278
  // Both endpoints require an admin-scoped key as the bearer token.
1245
1279
 
package/src/index.ts CHANGED
@@ -73,6 +73,7 @@ export {
73
73
  Verdict,
74
74
  runProcessorChain,
75
75
  denyTools,
76
+ humanApproval,
76
77
  requireScope,
77
78
  redactPattern,
78
79
  } from "./processors/index.js";
@@ -179,9 +180,12 @@ export type { RunEvent } from "./types/events.js";
179
180
 
180
181
  // Sandbox providers
181
182
  export { createSandbox, reconnectSandbox, killAllSandboxes, killSandboxById,
182
- getSandboxQuotas, listOwnedSandboxes, deleteSandboxSnapshot,
183
+ getSandboxQuotas, listOwnedSandboxes, deleteSandboxSnapshot, snapshotResolves,
183
184
  makeSandboxProvider, makeDesktopSandboxProvider,
184
- parseSseExecStream, AGENT_COMPOSE_TAG } from "./sandbox.js";
185
+ parseSseExecStream, AGENT_COMPOSE_TAG,
186
+ SANDBOX_VCPUS, DEFAULT_SANDBOX_SIZE, E2B_TEMPLATE_SIZES,
187
+ isE2bSupportedSize, e2bMachineSpec, e2bBaseTemplate, e2bAgentEnvTemplate,
188
+ isPlatformE2bTemplateAlias } from "./sandbox.js";
185
189
  export { SandboxUnavailableError, SANDBOX_UNAVAILABLE_PREFIX } from "./sandbox-errors.js";
186
190
  export type {
187
191
  SandboxCreateOpts, SandboxNetworkPolicy, SandboxNetworkHeaderTransform,
@@ -252,6 +256,10 @@ export {
252
256
  export type { PauseErrorCode } from "./pause/errors.js";
253
257
  export type { PauseRequest } from "./pause/pause-core.js";
254
258
  export type { WaitForEventRequest } from "./pause/wrappers.js";
259
+ // `agentc pause` writes a local pause-request marker the agent loop consumes
260
+ // (the snapshot-release bridge); the CLI imports the writer.
261
+ export { writeLocalPauseRequest, consumeLocalPauseRequest } from "./agent/local-pause-request.js";
262
+ export type { LocalPauseRequest, PauseOption } from "./agent/local-pause-request.js";
255
263
  export type {
256
264
  StepRequest,
257
265
  StepResult,
@@ -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,29 +17,47 @@
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
24
 
22
25
  import { assertSafeCheckpointName, readCheckpoint, writeCheckpoint } from "./state-dir.js";
23
26
 
24
- /** Read-or-run-and-write helper. `name` is validated by the state-dir
25
- * layer (rejects path-escape attempts); `fn` is awaited if it returns
26
- * a Promise so sync and async callers compose identically. */
27
- export async function checkpoint<T>(name: string, fn: () => Promise<T> | T): Promise<T> {
27
+ /** Result of a memoise call — `restored` distinguishes a disk hit (fn was
28
+ * NOT run, value read from a prior subprocess) from a fresh run (fn ran,
29
+ * value just written). The durable `ctx.step` maps `restored` onto the
30
+ * duration-0 "restored" sub-step status. */
31
+ export interface MemoizeResult<T> {
32
+ value: T;
33
+ restored: boolean;
34
+ }
35
+
36
+ /** Read-or-run-and-write helper reporting whether the value was restored
37
+ * from disk. `name` is validated by the state-dir layer (rejects
38
+ * path-escape attempts); `fn` is awaited if it returns a Promise so sync
39
+ * and async callers compose identically.
40
+ *
41
+ * A pause inside `fn` (PauseSignal) propagates BEFORE any write — a
42
+ * partially-completed body is never memoised, so the resume re-runs it. */
43
+ export async function memoize<T>(name: string, fn: () => Promise<T> | T): Promise<MemoizeResult<T>> {
28
44
  const existing = await readCheckpoint<T>(name);
29
- if (existing.found) return existing.value;
45
+ if (existing.found) return { value: existing.value, restored: true };
30
46
  const value = await fn();
31
47
  await writeCheckpoint(name, value);
32
- return value;
48
+ return { value, restored: false };
33
49
  }
34
50
 
35
- /** Bind user checkpoint names to a runner-owned namespace. The user's
36
- * `name` is still validated independently so a scope prefix cannot turn
37
- * an empty or path-escaping name into a valid filename. */
38
- export function scopedCheckpoint(scope: string): typeof checkpoint {
51
+ /** A memoise function bound to a runner-owned scope. */
52
+ export type ScopedMemoize = <T>(name: string, fn: () => Promise<T> | T) => Promise<MemoizeResult<T>>;
53
+
54
+ /** Bind memoise names to a runner-owned namespace (e.g. `step<idx>`). The
55
+ * caller's `name` is still validated independently so a scope prefix
56
+ * cannot turn an empty or path-escaping name into a valid filename. */
57
+ export function scopedMemoize(scope: string): ScopedMemoize {
39
58
  assertSafeCheckpointName(scope);
40
- return async <T>(name: string, fn: () => Promise<T> | T): Promise<T> => {
59
+ return async <T>(name: string, fn: () => Promise<T> | T): Promise<MemoizeResult<T>> => {
41
60
  assertSafeCheckpointName(name);
42
- return checkpoint(`${scope}.${name}`, fn);
61
+ return memoize(`${scope}.${name}`, fn);
43
62
  };
44
63
  }
@@ -44,7 +44,7 @@ const RunningAgentLoopStateSchema = z.object({
44
44
  blockerStreak: z.object({ key: z.string(), count: z.number().int().nonnegative() }).nullable(),
45
45
  // PR 7: a human-requested steer-pause intent, persisted so it survives a
46
46
  // resume and the loop re-issues the boundary pause on re-entry.
47
- pendingSteerPause: z.object({ reason: z.string(), correlationKey: z.string().nullable(), at: z.number().int() }).nullable().default(null),
47
+ pendingSteerPause: z.object({ reason: z.string(), correlationKey: z.string().nullable(), at: z.number().int(), payload: z.record(z.string(), z.unknown()).optional() }).nullable().default(null),
48
48
  });
49
49
 
50
50
  const SettledAgentLoopStateSchema = z.object({
@@ -76,7 +76,7 @@ export interface AgentLoopProgressState {
76
76
  blockerStreak: { key: string; count: number } | null;
77
77
  /** PR 7 steer-pause intent — see RunningAgentLoopStateSchema. Optional so the
78
78
  * loop can omit it until it wires steer (the schema defaults it to null). */
79
- pendingSteerPause?: { reason: string; correlationKey: string | null; at: number } | null;
79
+ pendingSteerPause?: { reason: string; correlationKey: string | null; at: number; payload?: Record<string, unknown> } | null;
80
80
  }
81
81
 
82
82
  export interface SettledAgentLoopResult<TResponse = unknown> {
@@ -94,6 +94,41 @@ export function isPauseSignal(err: unknown): err is PauseSignal {
94
94
  return typeof err === "object" && err !== null && (err as Record<symbol, unknown>)[PAUSE_SIGNAL_BRAND] === true;
95
95
  }
96
96
 
97
+ /**
98
+ * The run's pause boundary. The runner (`run-agent` `steerPause`) closes a
99
+ * `corePause` over the ambient `runId`/`stepIndex`; the agent loop / runtime
100
+ * supplies the per-iteration `agentScope` so the pauseId stays stable across
101
+ * the loop's iteration-skipping on resume. Absent outside a workflow step
102
+ * (local / non-sandbox callers) — see `boundProcessorPause`.
103
+ */
104
+ export type BoundaryPauseFn = <T = unknown>(
105
+ req: PauseRequest<T>,
106
+ agentScope: { agentId: string; iteration: number },
107
+ ) => Promise<T>;
108
+
109
+ /**
110
+ * Build the `ProcessorContext.pause` callable for a given hook scope: binds the
111
+ * boundary to this hook's `{ agentId, iteration }` so a processor calling
112
+ * `ctx.pause(req)` gets a deterministic, resume-stable pauseId. When no boundary
113
+ * is wired (local tests, non-sandbox callers), the returned fn rejects loudly
114
+ * rather than silently no-op'ing — pause genuinely cannot work without the
115
+ * runner's snapshot/Temporal machinery.
116
+ */
117
+ export function boundProcessorPause(
118
+ boundary: BoundaryPauseFn | undefined,
119
+ scope: { agentId: string; iteration: number },
120
+ ): <T = unknown>(req: PauseRequest<T>) => Promise<T> {
121
+ return <T = unknown>(req: PauseRequest<T>): Promise<T> => {
122
+ if (!boundary) {
123
+ return Promise.reject(new PauseRequestError(
124
+ "ctx.pause is unavailable here: no pause boundary is wired (a processor can " +
125
+ "only pause inside a sandboxed workflow step, not in a local/unit-test run)",
126
+ ));
127
+ }
128
+ return boundary(req, scope);
129
+ };
130
+ }
131
+
97
132
  // ── Deterministic pauseId ───────────────────────────────────────────────────
98
133
 
99
134
  /** Fixed namespace for agent-compose pause ids (UUIDv5). Constant — must
@@ -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()`
@@ -50,7 +50,7 @@ const pausePath = (pauseId: string) => join(pausesDir(), `${pause
50
50
 
51
51
  /** `name` is interpolated into a filename; reject anything outside a
52
52
  * conservative slug. Prevents a workflow author from writing
53
- * `ctx.checkpoint("../../etc/passwd", fn)` and escaping the dir.
53
+ * `ctx.step("../../etc/passwd", fn)` and escaping the dir.
54
54
  *
55
55
  * First character must be alphanumeric or `_` so dotfile names (`.foo`)
56
56
  * and parent-directory tokens (`.`, `..`) are rejected regardless of
@@ -4,7 +4,8 @@
4
4
  * exist as load-bearing examples and as defaults for common policies.
5
5
  */
6
6
 
7
- import type { Processor } from "./processor.js";
7
+ import { z } from "zod";
8
+ import type { Processor, ToolCall } from "./processor.js";
8
9
  import { Verdict } from "./processor.js";
9
10
 
10
11
  /**
@@ -27,6 +28,48 @@ export function denyTools(names: readonly string[]): Processor {
27
28
  };
28
29
  }
29
30
 
31
+ /** Resume payload a reviewer sends back to a `humanApproval` pause. */
32
+ const ApprovalDecision = z.object({
33
+ approved: z.boolean(),
34
+ reason: z.string().optional(),
35
+ });
36
+
37
+ /**
38
+ * Pause the run for HUMAN APPROVAL before a matching tool executes — the
39
+ * human-in-the-loop pre-tool gate (ADR-0006 `ctx.pause`). Over an ACP runtime
40
+ * this is exactly the `session/request_permission` path: the CLI asks to run a
41
+ * tool, the gate runs here, and `ctx.pause` snapshots the workflow and waits
42
+ * durably until a reviewer resolves the pause with `{ approved, reason? }`.
43
+ * Approve → the tool runs; deny → the reason is returned to the model as the
44
+ * tool result and the loop continues.
45
+ *
46
+ * tools? — only these tool names require approval (default: EVERY tool call).
47
+ * reason? — build the human-facing prompt from the call (default names the tool).
48
+ *
49
+ * Dormant on runtimes without pre-tool gating; active on those that wire
50
+ * `processToolCall` (the ACP CLI runtimes, the Claude pre-tool hook, Vercel).
51
+ */
52
+ export function humanApproval(opts?: {
53
+ tools?: readonly string[];
54
+ reason?: (call: ToolCall) => string;
55
+ }): Processor {
56
+ const gated = opts?.tools ? new Set(opts.tools) : null;
57
+ return {
58
+ name: "humanApproval",
59
+ async processToolCall(call, ctx) {
60
+ if (gated && !gated.has(call.toolName)) return Verdict.continue(call);
61
+ const decision = await ctx.pause({
62
+ reason: opts?.reason?.(call) ?? `Approve tool call: ${call.toolName}`,
63
+ payload: { tool: call.toolName, input: call.toolInput, toolUseId: call.toolUseId },
64
+ schema: ApprovalDecision,
65
+ });
66
+ return decision.approved
67
+ ? Verdict.continue(call)
68
+ : Verdict.deny(decision.reason ?? `tool "${call.toolName}" denied by human reviewer`);
69
+ },
70
+ };
71
+ }
72
+
30
73
  /**
31
74
  * Require the calling API key to carry every scope in `required`. Aborts the
32
75
  * agent loop with a clear reason if any are missing — defence-in-depth on
@@ -10,6 +10,7 @@ export { runProcessorChain } from "./runner.js";
10
10
 
11
11
  export {
12
12
  denyTools,
13
+ humanApproval,
13
14
  requireScope,
14
15
  redactPattern,
15
16
  } from "./builtins.js";
@@ -30,6 +30,7 @@
30
30
 
31
31
  import type { AgentMessage } from "../types/protocol.js";
32
32
  import type { RequestContext } from "../request-context/request-context.js";
33
+ import type { PauseRequest } from "../pause/pause-core.js";
33
34
 
34
35
  /** Proposed tool call. Mirrors the relevant fields of AgentMessageToolUse but
35
36
  * lives as its own type so runtime adapters (candidate #1) can populate it
@@ -53,6 +54,18 @@ export interface ProcessorContext {
53
54
  agentId: string;
54
55
  /** Iteration of the agent loop (1-based). */
55
56
  iteration: number;
57
+ /**
58
+ * Pause the run from inside a processor — the human-approval primitive for a
59
+ * pre-tool-use gate (ADR-0006 §"reachable from every user code path";
60
+ * ADR-0020 the ACP `session/request_permission` path). On the fresh pass it
61
+ * throws `PauseSignal` (internal control flow) so the workflow snapshots and
62
+ * waits durably; on step re-entry after resolution it returns the resume
63
+ * payload (validated against `req.schema` if given). The agent loop / runtime
64
+ * wires it to the run's pause boundary with a deterministic, resume-stable
65
+ * pauseId (keyed on this hook's agentId + iteration). Calling it outside a
66
+ * sandboxed workflow step throws — pause requires the runner's pause boundary.
67
+ */
68
+ pause<T = unknown>(req: PauseRequest<T>): Promise<T>;
56
69
  }
57
70
 
58
71
  /**