@agent-compose/sdk 0.5.9 → 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.
@@ -24,7 +24,7 @@
24
24
  import { randomBytes } from "node:crypto";
25
25
  import { z } from "zod";
26
26
  import { SandboxUnavailableError } from "../sandbox-errors.js";
27
- import type { SandboxProvider } from "../types/sandbox.js";
27
+ import type { SandboxProvider, SandboxBackgroundProcess } from "../types/sandbox.js";
28
28
  import { RUNNER_COMMAND, STEP_ENV, stepResultLinePrefix, stepPauseLinePrefix, requestContextPath, stepInputPath, stepResultFilePath } from "./protocol.js";
29
29
  import { StepPauseRequestSchema } from "./types.js";
30
30
  import type { StepRequest, StepResult } from "./types.js";
@@ -196,40 +196,34 @@ export interface InvokeStepOptions {
196
196
  onStderr?: (line: string) => void;
197
197
  }
198
198
 
199
- export async function invokeStep<TOutput = unknown>(
200
- sandbox: SandboxProvider,
201
- request: StepRequest,
202
- opts?: InvokeStepOptions,
203
- ): Promise<StepResult<TOutput>> {
204
- const resultToken = randomBytes(16).toString("hex");
205
-
206
- await Promise.all([
207
- sandbox.files.write(stepInputPath(request.stepIndex), JSON.stringify(request.input)),
208
- sandbox.files.write(requestContextPath(request.stepIndex), JSON.stringify(request.requestContext)),
209
- ]);
210
-
211
- const envs = {
212
- ...(opts?.envs ?? {}),
213
- ...buildStepEnvs({
214
- runId: request.runId,
215
- stepIndex: request.stepIndex,
216
- resultToken,
217
- isResume: opts?.isResume,
218
- }),
219
- };
199
+ /** A step running as a background command (ADR-0028). The activity races its
200
+ * `wait()` against a server pause request; on a pause it freezes the VM
201
+ * (`pauseProcess`) and persists `runnerPid` + `resultToken` so the resume
202
+ * activity can `reconnectStep(...)` to the SAME process — no re-run. */
203
+ export interface RunningStep<TOutput = unknown> {
204
+ /** OS pid of the background runner inside the VM — the reconnect handle. */
205
+ runnerPid: number;
206
+ /** Per-invocation token keying the durable result file the runner writes. */
207
+ resultToken: string;
208
+ /** Await the runner's exit and classify its output into a `StepResult`. */
209
+ wait(): Promise<StepResult<TOutput>>;
210
+ }
220
211
 
221
- // The sandbox emits stdout as raw chunks, not lines. Buffer between
222
- // emissions so a `console.log` split across two chunks (or a partial
223
- // trailing line) is delivered to onStdout/onStderr as one logical line.
224
- // Sentinel lines (carrying `resultToken`) are filtered out so callers
225
- // never see protocol bytes in user-log capture. Both the result and
226
- // pause prefixes are built from the same shared helpers `parseStepResult`
227
- // uses, so the filter and the parser can't drift apart.
212
+ /** Buffer the sandbox's raw stdout/stderr chunks into whole lines, filtering
213
+ * the protocol sentinel out of `onStdout` so user-log capture never sees
214
+ * protocol bytes. Shared by the foreground (`invokeStep`), background
215
+ * (`launchStep`), and resume (`reconnectStep`) paths so the filter and the
216
+ * result parser can't drift. Returns the per-stream chunk handlers (undefined
217
+ * when the caller passed no sink) plus a `flush` for trailing newline-less
218
+ * output. */
219
+ function makeStreamSplitters(
220
+ opts: Pick<InvokeStepOptions, "onStdout" | "onStderr"> | undefined,
221
+ resultToken: string,
222
+ ): { onStdout?: (chunk: string) => void; onStderr?: (chunk: string) => void; flush: () => void } {
228
223
  const resultSentinel = stepResultLinePrefix(resultToken);
229
224
  const pauseSentinel = stepPauseLinePrefix(resultToken);
230
- const isSentinel = (line: string) =>
231
- line.startsWith(resultSentinel) || line.startsWith(pauseSentinel);
232
- const makeLineSplitter = (sink: ((line: string) => void) | undefined, filterSentinel: boolean) => {
225
+ const isSentinel = (line: string) => line.startsWith(resultSentinel) || line.startsWith(pauseSentinel);
226
+ const make = (sink: ((line: string) => void) | undefined, filterSentinel: boolean) => {
233
227
  if (!sink) return { onChunk: undefined, flush: () => {} };
234
228
  let buf = "";
235
229
  return {
@@ -243,10 +237,6 @@ export async function invokeStep<TOutput = unknown>(
243
237
  sink(line);
244
238
  }
245
239
  },
246
- // Drain any remaining buffered output that ended without a newline.
247
- // Called after `sandbox.commands.run` resolves so a runner that
248
- // exits with `process.stdout.write("final")` (no trailing \n)
249
- // doesn't silently drop its last line.
250
240
  flush: () => {
251
241
  if (buf.length === 0) return;
252
242
  const line = buf;
@@ -256,16 +246,102 @@ export async function invokeStep<TOutput = unknown>(
256
246
  },
257
247
  };
258
248
  };
259
- const stdoutSplitter = makeLineSplitter(opts?.onStdout, true);
260
- const stderrSplitter = makeLineSplitter(opts?.onStderr, false);
249
+ const stdout = make(opts?.onStdout, true);
250
+ const stderr = make(opts?.onStderr, false);
251
+ return {
252
+ ...(stdout.onChunk ? { onStdout: stdout.onChunk } : {}),
253
+ ...(stderr.onChunk ? { onStderr: stderr.onChunk } : {}),
254
+ flush: () => { stdout.flush(); stderr.flush(); },
255
+ };
256
+ }
257
+
258
+ /** Turn the runner's terminal output into a kinded `StepResult`. stdout is the
259
+ * fast path; the runner also persists the sentinel to a durable token-keyed
260
+ * file, so when stdout carries no sentinel (providers drop the tail of heavy
261
+ * streams; a reconnect captures only post-resume output) we read the file
262
+ * back. Shared by all three invocation paths. */
263
+ async function classifyRunnerOutcome<TOutput>(
264
+ sandbox: SandboxProvider,
265
+ output: { stdout: string; stderr: string; exitCode: number },
266
+ resultToken: string,
267
+ ): Promise<StepResult<TOutput>> {
268
+ const parsed = parseStepResult<TOutput>(output.stdout, resultToken);
269
+ if (parsed) return parsed;
270
+
271
+ // No sentinel on stdout — read the durable token-keyed file the runner wrote
272
+ // BEFORE its stdout emit. A runner that exited 1 after a user-step throw
273
+ // still wrote `{ok:false, kind:"user-step"}`, which beats a generic exit.
274
+ const fileRead = await sandbox.commands.run(
275
+ `cat ${stepResultFilePath(resultToken)} 2>/dev/null || true`,
276
+ ).catch(() => null);
277
+ if (fileRead?.stdout) {
278
+ const fromFile = parseStepResult<TOutput>(fileRead.stdout, resultToken);
279
+ if (fromFile) return fromFile;
280
+ }
281
+
282
+ // Distinguish "runner exited badly before emitting" (runner-exit) from
283
+ // "runner exited cleanly but didn't speak the protocol" (protocol).
284
+ if (output.exitCode !== 0) {
285
+ const stderrTail = output.stderr.trim().slice(-2000);
286
+ const stdoutTail = output.stdout.trim().slice(-2000);
287
+ const details = [
288
+ stderrTail ? `stderr:\n${stderrTail}` : "",
289
+ stdoutTail ? `stdout:\n${stdoutTail}` : "",
290
+ ].filter(Boolean).join("\n");
291
+ return {
292
+ ok: false,
293
+ error: {
294
+ kind: "runner-exit",
295
+ message: `runner subprocess exited ${output.exitCode} before emitting a step result${details ? `\n${details}` : ""}`,
296
+ exitCode: output.exitCode,
297
+ },
298
+ };
299
+ }
300
+ const tail = output.stdout.trim().slice(-500);
301
+ return {
302
+ ok: false,
303
+ error: {
304
+ kind: "protocol",
305
+ message: `no tokenised step result on stdout or in the result file (runner exited 0 without emitting)${tail ? `\nstdout tail:\n${tail}` : ""}`,
306
+ },
307
+ };
308
+ }
309
+
310
+ /** Write the step's input + request-context files and build the runner env.
311
+ * Shared by the foreground and background launch paths. Returns the
312
+ * per-invocation `resultToken` + the env map. */
313
+ async function prepareStepLaunch(
314
+ sandbox: SandboxProvider,
315
+ request: StepRequest,
316
+ opts?: InvokeStepOptions,
317
+ ): Promise<{ resultToken: string; envs: Record<string, string> }> {
318
+ const resultToken = randomBytes(16).toString("hex");
319
+ await Promise.all([
320
+ sandbox.files.write(stepInputPath(request.stepIndex), JSON.stringify(request.input)),
321
+ sandbox.files.write(requestContextPath(request.stepIndex), JSON.stringify(request.requestContext)),
322
+ ]);
323
+ const envs = {
324
+ ...(opts?.envs ?? {}),
325
+ ...buildStepEnvs({ runId: request.runId, stepIndex: request.stepIndex, resultToken, isResume: opts?.isResume }),
326
+ };
327
+ return { resultToken, envs };
328
+ }
329
+
330
+ export async function invokeStep<TOutput = unknown>(
331
+ sandbox: SandboxProvider,
332
+ request: StepRequest,
333
+ opts?: InvokeStepOptions,
334
+ ): Promise<StepResult<TOutput>> {
335
+ const { resultToken, envs } = await prepareStepLaunch(sandbox, request, opts);
336
+ const splitters = makeStreamSplitters(opts, resultToken);
261
337
 
262
338
  let result: { stdout: string; stderr: string; exitCode: number };
263
339
  try {
264
340
  result = await sandbox.commands.run(RUNNER_COMMAND, {
265
341
  envs,
266
342
  timeoutMs: 0,
267
- ...(stdoutSplitter.onChunk ? { onStdout: stdoutSplitter.onChunk } : {}),
268
- ...(stderrSplitter.onChunk ? { onStderr: stderrSplitter.onChunk } : {}),
343
+ ...(splitters.onStdout ? { onStdout: splitters.onStdout } : {}),
344
+ ...(splitters.onStderr ? { onStderr: splitters.onStderr } : {}),
269
345
  });
270
346
  } catch (e) {
271
347
  // Typed sandbox-infrastructure failure: the engine's recovery contract
@@ -286,55 +362,90 @@ export async function invokeStep<TOutput = unknown>(
286
362
  exitCode: ce.exitCode ?? ce.result?.exitCode ?? 1,
287
363
  };
288
364
  }
289
- stdoutSplitter.flush();
290
- stderrSplitter.flush();
291
-
292
- const parsed = parseStepResult<TOutput>(result.stdout, resultToken);
293
- if (parsed) return parsed;
365
+ splitters.flush();
366
+ return classifyRunnerOutcome<TOutput>(sandbox, result, resultToken);
367
+ }
294
368
 
295
- // No sentinel on stdout. The runner also persists the sentinel line to a
296
- // token-keyed file before emitting — providers drop the tail of heavy
297
- // stdout streams, so read the durable copy back. Tried before exit-code
298
- // classification: a runner that exited 1 after a user-step throw still
299
- // wrote the file, and its `{ok:false, kind:"user-step"}` beats a generic
300
- // runner-exit error. A runner killed before emitting wrote no file and
301
- // falls through.
302
- const fileRead = await sandbox.commands.run(
303
- `cat ${stepResultFilePath(resultToken)} 2>/dev/null || true`,
304
- ).catch(() => null);
305
- if (fileRead?.stdout) {
306
- const fromFile = parseStepResult<TOutput>(fileRead.stdout, resultToken);
307
- if (fromFile) return fromFile;
369
+ /**
370
+ * Launch the step runner as a BACKGROUND command (ADR-0028) and return a handle
371
+ * the activity drives: it races `wait()` against a server pause request and, on
372
+ * a pause, freezes the VM (`pauseProcess`) and persists `runnerPid` +
373
+ * `resultToken` so `reconnectStep` can continue the SAME process — no re-run.
374
+ * Requires a provider with `commands.runBackground` (E2B); the foreground
375
+ * `invokeStep` is the path for providers without it (Vercel).
376
+ */
377
+ export async function launchStep<TOutput = unknown>(
378
+ sandbox: SandboxProvider,
379
+ request: StepRequest,
380
+ opts?: InvokeStepOptions,
381
+ ): Promise<RunningStep<TOutput>> {
382
+ if (!sandbox.commands.runBackground) {
383
+ throw new Error("launchStep requires a provider with background-command support (commands.runBackground)");
308
384
  }
385
+ const { resultToken, envs } = await prepareStepLaunch(sandbox, request, opts);
386
+ const splitters = makeStreamSplitters(opts, resultToken);
387
+ const proc = await sandbox.commands.runBackground(RUNNER_COMMAND, {
388
+ envs,
389
+ timeoutMs: 0,
390
+ ...(splitters.onStdout ? { onStdout: splitters.onStdout } : {}),
391
+ ...(splitters.onStderr ? { onStderr: splitters.onStderr } : {}),
392
+ });
393
+ return {
394
+ runnerPid: proc.pid,
395
+ resultToken,
396
+ async wait() {
397
+ const result = await proc.wait();
398
+ splitters.flush();
399
+ return classifyRunnerOutcome<TOutput>(sandbox, result, resultToken);
400
+ },
401
+ };
402
+ }
309
403
 
310
- // No tokenised sentinel on stdout. Distinguish "runner exited badly
311
- // before emitting" (runner-exit) from "runner exited cleanly but didn't
312
- // speak the protocol" (protocol) — the exit code is the evidence.
313
- if (result.exitCode !== 0) {
314
- const stderrTail = result.stderr.trim().slice(-2000);
315
- const stdoutTail = result.stdout.trim().slice(-2000);
316
- const details = [
317
- stderrTail ? `stderr:\n${stderrTail}` : "",
318
- stdoutTail ? `stdout:\n${stdoutTail}` : "",
319
- ].filter(Boolean).join("\n");
320
- return {
321
- ok: false,
322
- error: {
323
- kind: "runner-exit",
324
- message: `runner subprocess exited ${result.exitCode} before emitting a step result${details ? `\n${details}` : ""}`,
325
- exitCode: result.exitCode,
326
- },
327
- };
404
+ /**
405
+ * Resume a previously-paused background runner (ADR-0028) and return a
406
+ * `RunningStep` handle — uniform with `launchStep` so the activity can race
407
+ * `wait()` against a fresh pause request (a resumed step can pause again). After
408
+ * the workflow reconnects the suspended VM (`Sandbox.connect` auto-resumes it),
409
+ * this re-attaches to the still-running runner by `runnerPid`; `wait()` awaits
410
+ * its exit (event-driven — no polling) and classifies the output (the durable
411
+ * result file is authoritative on this path). If the runner already exited
412
+ * during resume, the re-attach fails and `wait()` classifies from the file.
413
+ */
414
+ export async function reconnectStep<TOutput = unknown>(
415
+ sandbox: SandboxProvider,
416
+ resume: { runnerPid: number; resultToken: string; stepIndex: number },
417
+ opts?: Pick<InvokeStepOptions, "onStdout" | "onStderr">,
418
+ ): Promise<RunningStep<TOutput>> {
419
+ if (!sandbox.commands.connectProcess) {
420
+ throw new Error("reconnectStep requires a provider with background-command support (commands.connectProcess)");
421
+ }
422
+ const { runnerPid, resultToken } = resume;
423
+ const splitters = makeStreamSplitters(opts, resultToken);
424
+ let proc: SandboxBackgroundProcess | null = null;
425
+ try {
426
+ proc = await sandbox.commands.connectProcess(runnerPid, {
427
+ ...(splitters.onStdout ? { onStdout: splitters.onStdout } : {}),
428
+ ...(splitters.onStderr ? { onStderr: splitters.onStderr } : {}),
429
+ });
430
+ } catch (e) {
431
+ // A sandbox-infrastructure failure must propagate intact (recovery
432
+ // contract). Anything else means the runner already exited during resume
433
+ // (we re-attached too late) — its durable result file is written, so the
434
+ // handle's wait() classifies from the file below.
435
+ if (e instanceof SandboxUnavailableError) throw e;
328
436
  }
329
- // Carry the stdout tail: "no tokenised step result" alone is useless to
330
- // an operator — the tail usually shows whether the runner finished its
331
- // work (output truncated by the provider) or never got there.
332
- const tail = result.stdout.trim().slice(-500);
437
+ const attached = proc;
333
438
  return {
334
- ok: false,
335
- error: {
336
- kind: "protocol",
337
- message: `no tokenised step result on stdout or in the result file (runner exited 0 without emitting)${tail ? `\nstdout tail:\n${tail}` : ""}`,
439
+ runnerPid,
440
+ resultToken,
441
+ async wait() {
442
+ let output: { stdout: string; stderr: string; exitCode: number } = { stdout: "", stderr: "", exitCode: 0 };
443
+ if (attached) {
444
+ try { output = await attached.wait(); }
445
+ catch (e) { if (e instanceof SandboxUnavailableError) throw e; }
446
+ }
447
+ splitters.flush();
448
+ return classifyRunnerOutcome<TOutput>(sandbox, output, resultToken);
338
449
  },
339
450
  };
340
451
  }
@@ -32,6 +32,13 @@ export interface RuntimeOptions {
32
32
  /** Agent id and label for processor context / adapter logs. */
33
33
  agentId?: string;
34
34
  iteration?: number;
35
+ /** The platform manual (file conventions, connectors & access, how to pause).
36
+ * `agent()` builds it per-run (`buildAgentContextDoc`) and threads it here so
37
+ * a runtime that supports a system-prompt append (the claude runtime) injects
38
+ * it directly — instead of relying on the agent to `cat` the on-disk
39
+ * AGENTS.md/CLAUDE.md, which the Agent SDK doesn't auto-load and which can
40
+ * fail to write on a read-only/degraded working dir. */
41
+ agentManual?: string;
35
42
  /** The run's pause boundary, threaded from the agent loop so a runtime-driven
36
43
  * pre-tool gate (e.g. the ACP `session/request_permission` path through
37
44
  * `gateToolCall`) can raise a human-approval `ctx.pause`. The runtime binds it
@@ -25,6 +25,30 @@ export interface SandboxCommandResult {
25
25
  stderr: string;
26
26
  }
27
27
 
28
+ /** A long-running command launched in the background (ADR-0028). Unlike
29
+ * `commands.run` (which awaits completion on one connection), a background
30
+ * command keeps running inside the VM independent of the launching
31
+ * connection: it survives `pauseProcess()`/resume and is re-attachable by
32
+ * `pid` after a fresh `Sandbox.connect`. This is what lets the platform
33
+ * freeze an agent mid-turn for a human-in-the-loop pause and continue the
34
+ * SAME process on resume — no re-run.
35
+ *
36
+ * Implemented ONLY by process-resume-capable providers (E2B); the presence
37
+ * of `commands.runBackground` IS the capability flag, paired with
38
+ * `pauseProcess`. Providers without it leave both undefined and pause via
39
+ * `snapshot()` + re-run instead. */
40
+ export interface SandboxBackgroundProcess {
41
+ /** OS pid inside the VM — the durable handle used to reconnect after a
42
+ * pause/resume cycle via `commands.connectProcess(pid)`. */
43
+ pid: number;
44
+ /** Resolve when the process exits, with its buffered result. Live output
45
+ * streams to the `onStdout`/`onStderr` passed at launch / connect time.
46
+ * Does NOT throw on a non-zero exit — the result carries `exitCode`. */
47
+ wait(): Promise<SandboxCommandResult>;
48
+ /** Force-terminate the process. */
49
+ kill(): Promise<void>;
50
+ }
51
+
28
52
  /** A spawned long-lived command with a writable stdin and readable stdout,
29
53
  * exposed as byte web-streams. Unlike `commands.run` (which buffers to
30
54
  * completion and exposes stdout only via an `onStdout` callback), a duplex
@@ -85,6 +109,16 @@ export interface SandboxProvider {
85
109
  * view). The vercel/e2b providers (server→sandbox) leave it undefined; an
86
110
  * ACP caller that finds it absent falls back to the JSONL transport. */
87
111
  spawnDuplex?(cmd: string, opts?: SandboxSpawnDuplexOptions): SandboxDuplexProcess;
112
+ /** Launch a command in the background and return immediately with a
113
+ * reconnectable handle (ADR-0028). The process survives the launching
114
+ * connection dropping AND a `pauseProcess()`/resume cycle. OPTIONAL —
115
+ * only process-resume providers (E2B) implement it; its presence (paired
116
+ * with `pauseProcess`) is the native-pause capability flag. */
117
+ runBackground?(cmd: string, opts?: SandboxCommandRunOptions): Promise<SandboxBackgroundProcess>;
118
+ /** Re-attach to a background command by `pid` after a fresh
119
+ * `Sandbox.connect` (the resume half of `runBackground`). OPTIONAL,
120
+ * E2B-only. Throws if no process with that pid is running. */
121
+ connectProcess?(pid: number, opts?: Pick<SandboxCommandRunOptions, "onStdout" | "onStderr" | "timeoutMs">): Promise<SandboxBackgroundProcess>;
88
122
  };
89
123
  files: {
90
124
  write(path: string, content: string): Promise<void>;
@@ -99,6 +133,16 @@ export interface SandboxProvider {
99
133
  * be omitted when the provider doesn't expose it; the server stores
100
134
  * `null` for missing values rather than estimating. */
101
135
  snapshot?(): Promise<{ snapshotId: string; sizeBytes?: number }>;
136
+ /** Suspend the live VM in place and return a handle to resume it (ADR-0027).
137
+ * Present ONLY on process-resume-capable providers (E2B via `sandbox.pause()`,
138
+ * returning the sandbox id; resume is `Sandbox.connect(handle)`, which
139
+ * auto-resumes the paused VM). Unlike `snapshot()` — which captures an FS
140
+ * image, kills the origin, and re-runs the step from a fresh sandbox — a
141
+ * process-resume pause FREEZES the live process (zero compute) and continues
142
+ * it exactly where it blocked. The presence of this method IS the capability
143
+ * flag: providers without native VM-suspend leave it undefined and fall back
144
+ * to `snapshot()` + re-run. */
145
+ pauseProcess?(): Promise<{ resumeHandle: string }>;
102
146
  /** Replace the live sandbox's egress policy in place — so the server can
103
147
  * push a freshly resolved policy (with re-minted connector access tokens)
104
148
  * before each step instead of relying on the policy baked at create.
@@ -1,49 +0,0 @@
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
- /** A choice offered to the human. A bare string is shorthand for
29
- * `{ label, value }` with both equal — exactly what the dashboard's
30
- * `readOptions` accepts. */
31
- export type PauseOption = string | {
32
- label: string;
33
- value: string;
34
- };
35
- /** What `agentc pause` records for the loop to turn into a `ctx.pause`. */
36
- export interface LocalPauseRequest {
37
- /** The question shown to the human (becomes the pause `reason`). */
38
- reason: string;
39
- /** Optional offered choices, rendered as buttons in the dashboard. */
40
- options?: PauseOption[];
41
- }
42
- /** Write the marker atomically (tmp → rename) so the loop never reads a
43
- * partial file mid-write. Called by `agentc pause`. */
44
- export declare function writeLocalPauseRequest(agentId: string | undefined | null, req: LocalPauseRequest): void;
45
- /** Take-once read: returns + deletes this agent's pending pause request, else
46
- * null. Checks the agent-scoped slot first, then the unscoped fallback. The
47
- * loop calls this once per turn; a malformed marker is dropped (deleted +
48
- * null) rather than wedging the loop. */
49
- export declare function consumeLocalPauseRequest(agentId: string | undefined | null): LocalPauseRequest | null;
@@ -1,90 +0,0 @@
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
- }