agentbox-sdk 0.1.514 → 0.1.516

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.
@@ -2,7 +2,7 @@ import {
2
2
  createNormalizedEvent,
3
3
  normalizeRawAgentEvent,
4
4
  toAISDKStream
5
- } from "./chunk-775FIGGL.js";
5
+ } from "./chunk-V4M4LPPT.js";
6
6
  import {
7
7
  AgentBoxError,
8
8
  AsyncQueue,
@@ -1857,6 +1857,9 @@ var BackgroundTaskTracker = class {
1857
1857
  wakeups = /* @__PURE__ */ new Map();
1858
1858
  // tool_use seen, tool_result not yet: a failed schedule adds nothing.
1859
1859
  pendingWakeups = /* @__PURE__ */ new Map();
1860
+ // task_started seen while the command was still in the foreground, kept so
1861
+ // a later "moved to the background" patch can name the task it promotes.
1862
+ foreground = /* @__PURE__ */ new Map();
1860
1863
  afterResult = false;
1861
1864
  seenBackgroundWork = false;
1862
1865
  hasTaskSnapshot = false;
@@ -1878,6 +1881,20 @@ var BackgroundTaskTracker = class {
1878
1881
  hasSeenBackgroundWork() {
1879
1882
  return this.seenBackgroundWork;
1880
1883
  }
1884
+ /**
1885
+ * Prime a run that attaches to a parked harness with what was live when it
1886
+ * was parked: that run never saw the work start, and would otherwise take
1887
+ * its first `result` for the end of the run and tear the harness down.
1888
+ * Snapshots and schedule events replayed afterwards supersede it.
1889
+ */
1890
+ restore(tasks) {
1891
+ for (const task of tasks) {
1892
+ if (!task.id) continue;
1893
+ this.seenBackgroundWork = true;
1894
+ if (task.type === "scheduled_wakeup") this.wakeups.set(task.id, task);
1895
+ else this.tasks.set(task.id, { task, ambient: false });
1896
+ }
1897
+ }
1881
1898
  /** Feed one SDKMessage. Returns true when it started a follow-up turn. */
1882
1899
  ingest(message) {
1883
1900
  const m = asRecord2(message);
@@ -1916,6 +1933,7 @@ var BackgroundTaskTracker = class {
1916
1933
  switch (m.subtype) {
1917
1934
  case "background_tasks_changed":
1918
1935
  this.hasTaskSnapshot = true;
1936
+ this.foreground.clear();
1919
1937
  this.tasks.clear();
1920
1938
  for (const entry of asArray(m.tasks)) {
1921
1939
  const task = asRecord2(entry);
@@ -1923,15 +1941,31 @@ var BackgroundTaskTracker = class {
1923
1941
  }
1924
1942
  return false;
1925
1943
  case "task_started":
1926
- if (!this.hasTaskSnapshot && m.is_backgrounded === true && !m.owned_by_subagent) this.addTask(m);
1944
+ if (this.hasTaskSnapshot || m.owned_by_subagent) return false;
1945
+ if (m.is_backgrounded === true) this.addTask(m);
1946
+ else if (id) this.foreground.set(id, m);
1927
1947
  return false;
1928
1948
  case "task_notification":
1929
- if (!this.hasTaskSnapshot) this.tasks.delete(id);
1949
+ if (!this.hasTaskSnapshot) {
1950
+ this.tasks.delete(id);
1951
+ this.foreground.delete(id);
1952
+ }
1930
1953
  return false;
1931
- case "task_updated":
1932
- if (!this.hasTaskSnapshot && DONE_STATUSES.has(String(asRecord2(m.patch)?.status)))
1954
+ case "task_updated": {
1955
+ if (this.hasTaskSnapshot) return false;
1956
+ const patch = asRecord2(m.patch);
1957
+ if (DONE_STATUSES.has(String(patch?.status))) {
1933
1958
  this.tasks.delete(id);
1959
+ this.foreground.delete(id);
1960
+ return false;
1961
+ }
1962
+ const started = this.foreground.get(id);
1963
+ if (patch?.is_backgrounded === true && started) {
1964
+ this.foreground.delete(id);
1965
+ this.addTask(started);
1966
+ }
1934
1967
  return false;
1968
+ }
1935
1969
  case "init":
1936
1970
  return this.startTurn();
1937
1971
  default:
@@ -2020,6 +2054,10 @@ var BackgroundWait = class {
2020
2054
  }
2021
2055
  this.grace ??= setTimeout(() => this.expire("grace"), this.graceMs);
2022
2056
  }
2057
+ /** End the wait now on the host's request ({@link BackgroundWaitFinish}). */
2058
+ finish() {
2059
+ this.expire("finished");
2060
+ }
2023
2061
  elapsedMs() {
2024
2062
  return Date.now() - this.startedAt;
2025
2063
  }
@@ -2028,9 +2066,23 @@ var BackgroundWait = class {
2028
2066
  clearTimeout(this.ceiling);
2029
2067
  }
2030
2068
  };
2069
+ var BackgroundWaitFinish = class {
2070
+ requested = false;
2071
+ wait;
2072
+ request() {
2073
+ this.requested = true;
2074
+ this.wait?.finish();
2075
+ }
2076
+ /** Follow the run's current wait; a latched request ends a new one at once. */
2077
+ watch(wait) {
2078
+ this.wait = wait;
2079
+ if (this.requested) wait?.finish();
2080
+ return wait;
2081
+ }
2082
+ };
2031
2083
 
2032
2084
  // src/agents/providers/claude-code.ts
2033
- var DAEMON_PROTOCOL_VERSION = "8";
2085
+ var DAEMON_PROTOCOL_VERSION = "10";
2034
2086
  var DAEMON_PORT = 43180;
2035
2087
  var DAEMON_PATH = "/tmp/agentbox/claude-code/daemon.mjs";
2036
2088
  var DAEMON_LOG_PATH = "/tmp/agentbox/claude-code/daemon.log";
@@ -2040,7 +2092,11 @@ var DAEMON_READY_TIMEOUT_MS = 3e4;
2040
2092
  var DAEMON_READY_POLL_INTERVAL_MS = 250;
2041
2093
  function claudeConfigDir(options) {
2042
2094
  return path8.join(
2043
- agentboxRoot(AgentProvider.ClaudeCode, Boolean(options.sandbox), options.stateDirectory),
2095
+ agentboxRoot(
2096
+ AgentProvider.ClaudeCode,
2097
+ Boolean(options.sandbox),
2098
+ options.stateDirectory
2099
+ ),
2044
2100
  ".claude"
2045
2101
  );
2046
2102
  }
@@ -2061,7 +2117,9 @@ function buildClaudeQueryOptions(params) {
2061
2117
  }
2062
2118
  const includeHookEvents = provider?.includeHookEvents ?? false;
2063
2119
  if (run.reasoning === "max" || run.reasoning === "ultra") {
2064
- throw new Error(`Reasoning effort "${run.reasoning}" is only supported by Codex.`);
2120
+ throw new Error(
2121
+ `Reasoning effort "${run.reasoning}" is only supported by Codex.`
2122
+ );
2065
2123
  }
2066
2124
  const effort = provider?.ultracode ? "xhigh" : run.reasoning;
2067
2125
  return {
@@ -2072,7 +2130,10 @@ function buildClaudeQueryOptions(params) {
2072
2130
  ...params.request.options.configuration === "native" && provider?.fastMode !== void 0 ? { settings: { fastMode: provider.fastMode } } : {},
2073
2131
  ...params.request.options.configuration === "native" ? {
2074
2132
  settingSources: ["user", "project", "local"],
2075
- systemPrompt: { type: "preset", preset: "claude_code" }
2133
+ systemPrompt: {
2134
+ type: "preset",
2135
+ preset: "claude_code"
2136
+ }
2076
2137
  } : {},
2077
2138
  extraArgs,
2078
2139
  includePartialMessages: true,
@@ -2164,6 +2225,199 @@ function isAuthorized(req) {
2164
2225
 
2165
2226
  const liveRuns = new Map();
2166
2227
 
2228
+ // A parked run keeps its CLI, and the background work that CLI owns, alive
2229
+ // after the host settled the run and left. Its output is buffered until a
2230
+ // later run for the same session attaches through /start; a turn the CLI
2231
+ // starts on its own (a task finished, a schedule fired) wakes the host.
2232
+ const NDJSON_HEADERS = { "content-type": "application/x-ndjson", "transfer-encoding": "chunked", "x-daemon-version": VERSION };
2233
+ const PARK_BUFFER_LIMIT = 16 * 1024 * 1024;
2234
+ // A host may answer "busy" (non-2xx) for far longer than a fixed ladder: the
2235
+ // run that parked this work may still be finalizing, another job may hold the
2236
+ // task, a deploy may be rolling. Back off to WAKE_RETRY_CEILING_MS and then
2237
+ // keep asking for as long as the park lives \u2014 the park's own TTL is the bound,
2238
+ // not the ladder. A run attaching, or the park ending, stops the retries.
2239
+ const WAKE_RETRY_MS = [0, 1000, 2000, 4000, 8000, 16000, 30000, 60000];
2240
+ const WAKE_RETRY_CEILING_MS = 60000;
2241
+
2242
+ function findParkedRun(sessionId) {
2243
+ for (const run of liveRuns.values()) {
2244
+ if (run.parked && !run.sink && run.sessionId === sessionId) return run;
2245
+ }
2246
+ return undefined;
2247
+ }
2248
+
2249
+ // A run's own teardown. The liveRuns entry is only removed when it is still
2250
+ // ours: hosts reuse a runId across retry attempts, and a successor
2251
+ // registered while our CLI winds down must not be evicted by our exit.
2252
+ function releaseRun(run) {
2253
+ clearInterval(run.heartbeat);
2254
+ clearTimeout(run.parked?.timer);
2255
+ run.parked = null;
2256
+ run.clearPermissions();
2257
+ if (liveRuns.get(run.id) === run) liveRuns.delete(run.id);
2258
+ run.prompt.end();
2259
+ }
2260
+
2261
+ // End the prompt so the CLI winds down with its background work.
2262
+ function endRun(run) {
2263
+ releaseRun(run);
2264
+ run.query?.interrupt().catch(() => {});
2265
+ }
2266
+
2267
+ function writeFrame(run, message) {
2268
+ const line = JSON.stringify(message) + "\\n";
2269
+ if (run.sink && !run.sink.writableEnded) { run.sink.write(line); return; }
2270
+ // Nobody is attached. Partial deltas are rebuilt from the final messages.
2271
+ if (message.type === "stream_event") return;
2272
+ // byteLength, not .length: JSON.stringify keeps non-ASCII verbatim, so
2273
+ // UTF-16 units under-count the bytes this buffer actually holds.
2274
+ const size = Buffer.byteLength(line);
2275
+ if (run.bufferedBytes + size > PARK_BUFFER_LIMIT) {
2276
+ // Out of room while nobody is attached. Keep what the CLI already
2277
+ // produced and drop the rest rather than killing the CLI: the
2278
+ // background work goes on, and the run that attaches learns from the
2279
+ // notice that its transcript is incomplete. Turn accounting is
2280
+ // unaffected \u2014 observeFrame counts results before we are called.
2281
+ if (!run.bufferOverflowed) {
2282
+ run.bufferOverflowed = true;
2283
+ run.buffer.push(JSON.stringify({ _notice: "park_buffer_overflow" }) + "\\n");
2284
+ if (run.parked) wakeHost(run);
2285
+ }
2286
+ return;
2287
+ }
2288
+ run.bufferedBytes += size;
2289
+ run.buffer.push(line);
2290
+ }
2291
+
2292
+ function attachSink(run, res) {
2293
+ clearInterval(run.heartbeat);
2294
+ run.sink = res;
2295
+ // Heartbeat: write a blank NDJSON line every 15s so HTTPS proxies
2296
+ // (Modal/Daytona tunnels, intermediate LBs) don't idle-kill the
2297
+ // stream during long blocking tool calls (e.g. TaskOutput block:true).
2298
+ // The host's parseNdjsonStream skips empty lines, so this is a no-op
2299
+ // for consumers but keeps the underlying TCP stream warm.
2300
+ run.heartbeat = setInterval(() => {
2301
+ if (run.sink === res && !res.writableEnded) res.write("\\n");
2302
+ }, 15000);
2303
+ if (typeof run.heartbeat.unref === "function") run.heartbeat.unref();
2304
+ // Host gone (settled, cancelled or crashed) \u2192 end the prompt so the CLI
2305
+ // winds down instead of living on with its background work, unless the
2306
+ // host parked the run first. Detected on the response: \`req\` emits
2307
+ // "close" as soon as its body is consumed (Node >= 16), long before any
2308
+ // disconnect, while the response only closes early when the socket dies
2309
+ // before the stream finished.
2310
+ res.on("close", () => {
2311
+ if (run.sink !== res) return;
2312
+ run.sink = null;
2313
+ clearInterval(run.heartbeat);
2314
+ if (run.parked) {
2315
+ // The CLI survives, but a permission left outstanding would wedge it:
2316
+ // nobody is attached to decide, and a blocked tool call never ends the
2317
+ // turn, so the host would never be woken either. Decline without
2318
+ // interrupting so the background work carries on.
2319
+ run.declinePermissions();
2320
+ return;
2321
+ }
2322
+ if (res.writableFinished) return;
2323
+ run.clientGone = true;
2324
+ endRun(run);
2325
+ });
2326
+ }
2327
+
2328
+ // Track turn boundaries so an attaching run knows how many results precede
2329
+ // its own, and so a turn nobody is watching wakes the host.
2330
+ function observeFrame(run, message) {
2331
+ const state = message.type === "system" && message.subtype === "session_state_changed"
2332
+ ? message.state : undefined;
2333
+ const started = message.type === "system" &&
2334
+ (message.subtype === "init" || state === "running");
2335
+ if (started) run.turnActive = true;
2336
+ // A turn ends at its result \u2014 but a turn that dies without one (a CLI
2337
+ // error, a killed subagent) would otherwise leave turnActive stuck true
2338
+ // and inflate the turn count adoptRun hands to the next run, which would
2339
+ // then skip its own result. Idle means no turn is running, whatever the
2340
+ // reason, so it clears the flag too.
2341
+ if (message.type === "result" || state === "idle") run.turnActive = false;
2342
+ if (message.type === "result" && !run.sink) run.bufferedResults++;
2343
+ // Anything a parked, unattended CLI produces on its own is news for the
2344
+ // host: the start of a turn, and equally the result of a turn that was
2345
+ // already in flight when the park landed (whose start edge was consumed
2346
+ // while the host was still attached, so it never announced itself).
2347
+ if (run.parked && !run.sink && (started || message.type === "result")) {
2348
+ wakeHost(run);
2349
+ }
2350
+ }
2351
+
2352
+ async function wakeHost(run) {
2353
+ const parked = run.parked;
2354
+ if (!parked || !parked.wakeUrl || parked.waking) return;
2355
+ // A wake the host accepted stands until a run attaches. The job it started
2356
+ // takes a while to boot its side, and every frame the CLI emits meanwhile
2357
+ // calls us again \u2014 without this, one chatty turn is one POST per frame.
2358
+ // Retry only once it is plain that no run is coming.
2359
+ if (parked.wokeAt && Date.now() - parked.wokeAt < WAKE_RETRY_CEILING_MS) return;
2360
+ parked.waking = true;
2361
+ try {
2362
+ for (let attempt = 0; ; attempt++) {
2363
+ const delay = attempt < WAKE_RETRY_MS.length
2364
+ ? WAKE_RETRY_MS[attempt]
2365
+ : WAKE_RETRY_CEILING_MS;
2366
+ if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
2367
+ // Attached or torn down meanwhile: someone is already listening, or
2368
+ // the park's TTL ended it. Either way, stop asking.
2369
+ if (run.parked !== parked || run.sink) return;
2370
+ try {
2371
+ const response = await fetch(parked.wakeUrl, {
2372
+ method: "POST",
2373
+ headers: { "content-type": "application/json", authorization: "Bearer " + parked.wakeToken },
2374
+ body: JSON.stringify({ runId: run.id, sessionId: run.sessionId }),
2375
+ signal: AbortSignal.timeout(10000),
2376
+ });
2377
+ if (response.ok) { parked.wokeAt = Date.now(); return; }
2378
+ } catch {}
2379
+ }
2380
+ } finally {
2381
+ parked.waking = false;
2382
+ }
2383
+ }
2384
+
2385
+ // A later run for a parked session takes the CLI over instead of starting a
2386
+ // second one on the same session: replay what was buffered, then stream.
2387
+ function adoptRun(run, res, runId, prompt, attachOnly, settings) {
2388
+ const turns = run.bufferedResults + (run.turnActive ? 1 : 0);
2389
+ res.writeHead(200, NDJSON_HEADERS);
2390
+ // Nothing happened while parked: leave it for the wake that will.
2391
+ if (attachOnly && turns === 0) {
2392
+ res.end(JSON.stringify({ _notice: "no_parked_turn", sessionId: run.sessionId }) + "\\n");
2393
+ return;
2394
+ }
2395
+ const parked = run.parked;
2396
+ clearTimeout(parked.timer);
2397
+ run.parked = null;
2398
+ // The adopting run's permission settings govern its own turn: a CLI parked
2399
+ // by a full-access run must not keep auto-approving once the task is back
2400
+ // in ask or plan mode. An attach-only run sends no input of its own, so it
2401
+ // leaves the settings of the turn already in flight alone.
2402
+ if (settings) run.settings = settings;
2403
+ liveRuns.delete(run.id);
2404
+ run.id = runId;
2405
+ liveRuns.set(runId, run);
2406
+ attachSink(run, res);
2407
+ // What is left of the background budget, so a run that adopts and parks
2408
+ // again inherits the deadline instead of restarting it.
2409
+ const budgetLeftMs = Number.isFinite(run.parkDeadline)
2410
+ ? Math.max(0, run.parkDeadline - Date.now())
2411
+ : null;
2412
+ res.write(JSON.stringify({ _parked: { tasks: parked.tasks, turns, budgetLeftMs } }) + "\\n");
2413
+ for (const line of run.buffer) res.write(line);
2414
+ run.buffer = [];
2415
+ run.bufferedBytes = 0;
2416
+ run.bufferedResults = 0;
2417
+ run.bufferOverflowed = false;
2418
+ if (prompt) run.prompt.push(prompt);
2419
+ }
2420
+
2167
2421
  // The SDK's default spawn does \`existsSync(pathToClaudeCodeExecutable)\`
2168
2422
  // before invoking child_process.spawn \u2014 that check fails on bare names
2169
2423
  // like "claude" because existsSync doesn't do PATH lookup. Resolve to an
@@ -2247,7 +2501,36 @@ async function handleStart(req, res, runId) {
2247
2501
  res.end(JSON.stringify({ error: String(e?.message ?? e) }));
2248
2502
  return;
2249
2503
  }
2250
- const { prompt, options } = body || {};
2504
+ const { prompt, options, attach } = body || {};
2505
+ const opts = { ...(options || {}) };
2506
+ // Permission shaping belongs to the request, not to the CLI process that
2507
+ // happens to serve it: read it before the adopt branch so a run taking over
2508
+ // a parked CLI applies its own mode rather than inheriting the parking
2509
+ // run's. The rest of \`opts\` configures the CLI at spawn and cannot change
2510
+ // under a live query.
2511
+ const settings = {
2512
+ autoApprove: !!opts.autoApproveTools,
2513
+ interactiveQuestions: !!opts.interactiveQuestions,
2514
+ planning: opts.permissionMode === "plan",
2515
+ };
2516
+ delete opts.autoApproveTools;
2517
+ delete opts.interactiveQuestions;
2518
+ const parkedRun = opts.resume ? findParkedRun(opts.resume) : undefined;
2519
+ if (parkedRun && opts.forkSession) {
2520
+ // The conversation is being rewound: work started on the abandoned
2521
+ // branch goes with it.
2522
+ endRun(parkedRun);
2523
+ } else if (parkedRun) {
2524
+ adoptRun(parkedRun, res, runId, prompt, !!attach, attach ? undefined : settings);
2525
+ return;
2526
+ }
2527
+ // An attach-only request streams the turn a parked CLI started on its own;
2528
+ // without one there is nothing to run.
2529
+ if (attach) {
2530
+ res.writeHead(200, NDJSON_HEADERS);
2531
+ res.end(JSON.stringify({ _notice: "no_parked_run", sessionId: opts.resume ?? "" }) + "\\n");
2532
+ return;
2533
+ }
2251
2534
  if (!prompt) {
2252
2535
  res.writeHead(400);
2253
2536
  res.end("missing prompt");
@@ -2257,70 +2540,63 @@ async function handleStart(req, res, runId) {
2257
2540
  const promptStream = createPromptStream();
2258
2541
  promptStream.push(prompt);
2259
2542
 
2260
- res.writeHead(200, {
2261
- "content-type": "application/x-ndjson",
2262
- "transfer-encoding": "chunked",
2263
- "x-daemon-version": VERSION,
2264
- });
2265
-
2266
- // Heartbeat: write a blank NDJSON line every 15s so HTTPS proxies
2267
- // (Modal/Daytona tunnels, intermediate LBs) don't idle-kill the
2268
- // stream during long blocking tool calls (e.g. TaskOutput block:true).
2269
- // The host's parseNdjsonStream skips empty lines, so this is a no-op
2270
- // for consumers but keeps the underlying TCP stream warm.
2271
- const heartbeat = setInterval(() => {
2272
- if (!res.writableEnded) res.write("\\n");
2273
- }, 15000);
2274
- if (typeof heartbeat.unref === "function") heartbeat.unref();
2543
+ res.writeHead(200, NDJSON_HEADERS);
2275
2544
 
2276
- const opts = { ...(options || {}) };
2277
- const autoApprove = !!opts.autoApproveTools;
2278
- delete opts.autoApproveTools;
2279
- const interactiveQuestions = !!opts.interactiveQuestions;
2280
- delete opts.interactiveQuestions;
2281
- let planning = opts.permissionMode === "plan";
2282
2545
  const permissions = new Map();
2283
- const clearPermissions = () => { for (const resolve of permissions.values()) resolve({ behavior: "deny", message: "Run ended", interrupt: true }); permissions.clear(); };
2546
+ const denyAll = (response) => { for (const resolve of permissions.values()) resolve(response); permissions.clear(); };
2547
+ const clearPermissions = () => denyAll({ behavior: "deny", message: "Run ended", interrupt: true });
2548
+ // Nobody is attached to decide. Declining without \`interrupt\` lets the CLI
2549
+ // report and carry on with its background work instead of blocking forever
2550
+ // on a tool call \u2014 a blocked call never ends the turn, so the host would
2551
+ // never be woken to answer it either.
2552
+ const declinePermissions = () => denyAll({ behavior: "deny", message: "No user is attached to approve this request." });
2284
2553
  const canUseTool = async (toolName, input, context) => {
2285
2554
  const isQuestion = toolName === "AskUserQuestion";
2286
2555
  const isPlan = toolName === "ExitPlanMode";
2556
+ const mode = run.settings;
2287
2557
  if (context.signal.aborted) return { behavior: "deny", message: "Run cancelled", interrupt: true };
2288
- if ((isQuestion || isPlan) && !interactiveQuestions) return { behavior: "deny", message: "No interactive user is available." };
2289
- if (!isQuestion && !isPlan && planning && toolName !== "EnterPlanMode") return { behavior: "deny", message: "Finish planning before requesting write access." };
2290
- if (!isQuestion && !isPlan && autoApprove) return { behavior: "allow", updatedInput: input };
2558
+ if ((isQuestion || isPlan) && !mode.interactiveQuestions) return { behavior: "deny", message: "No interactive user is available." };
2559
+ if (!isQuestion && !isPlan && mode.planning && toolName !== "EnterPlanMode") return { behavior: "deny", message: "Finish planning before requesting write access." };
2560
+ if (!isQuestion && !isPlan && mode.autoApprove) return { behavior: "allow", updatedInput: input };
2561
+ // Parked with nobody attached: there is no one to ask, and waiting would
2562
+ // wedge the CLI out of reach of any wake.
2563
+ if (run.parked && !run.sink) return { behavior: "deny", message: "No user is attached to approve this request." };
2291
2564
  return new Promise((resolve) => {
2292
2565
  const abort = () => { permissions.delete(context.toolUseID); resolve({ behavior: "deny", message: "Run cancelled", interrupt: true }); };
2293
- permissions.set(context.toolUseID, (response) => { context.signal.removeEventListener("abort", abort); if (isPlan && response.behavior === "allow") planning = false; resolve(response); });
2566
+ permissions.set(context.toolUseID, (response) => { context.signal.removeEventListener("abort", abort); if (isPlan && response.behavior === "allow") run.settings.planning = false; resolve(response); });
2294
2567
  context.signal.addEventListener("abort", abort, { once: true });
2295
- res.write(JSON.stringify({ _permission: { requestId: context.toolUseID, toolName, input, title: context.title } }) + "\\n");
2568
+ // Buffered while parked: the run that attaches answers it.
2569
+ writeFrame(run, { _permission: { requestId: context.toolUseID, toolName, input, title: context.title } });
2296
2570
  });
2297
2571
  };
2298
2572
  opts.pathToClaudeCodeExecutable = resolveClaudeBinary(
2299
2573
  opts.pathToClaudeCodeExecutable,
2300
2574
  );
2301
2575
 
2302
- let queryHandle;
2303
- let clientGone = false;
2304
- // This run's own teardown. The liveRuns entry is only removed when it is
2305
- // still ours: hosts reuse a runId across retry attempts, and a successor
2306
- // registered while our CLI winds down must not be evicted by our exit.
2307
- const releaseRun = () => {
2308
- clearInterval(heartbeat);
2309
- clearPermissions();
2310
- if (liveRuns.get(runId)?.query === queryHandle) liveRuns.delete(runId);
2311
- promptStream.end();
2576
+ const run = {
2577
+ id: runId,
2578
+ sessionId: opts.resume || opts.sessionId || "",
2579
+ query: undefined,
2580
+ prompt: promptStream,
2581
+ permissions,
2582
+ clearPermissions,
2583
+ declinePermissions,
2584
+ // Replaced by each run that adopts this CLI out of a park.
2585
+ settings,
2586
+ sink: null,
2587
+ heartbeat: undefined,
2588
+ clientGone: false,
2589
+ parked: null,
2590
+ // Absolute end of the background budget, carried across parks so a CLI
2591
+ // that is adopted and parked again cannot extend its own ceiling.
2592
+ parkDeadline: Infinity,
2593
+ buffer: [],
2594
+ bufferedBytes: 0,
2595
+ bufferedResults: 0,
2596
+ bufferOverflowed: false,
2597
+ turnActive: false,
2312
2598
  };
2313
- // Host gone (settled, cancelled or crashed) \u2192 end the prompt so the CLI
2314
- // winds down instead of living on with its background work. Detected on
2315
- // the response: \`req\` emits "close" as soon as its body is consumed (Node
2316
- // >= 16), long before any disconnect, while the response only closes
2317
- // early when the socket dies before the stream finished.
2318
- res.on("close", () => {
2319
- if (res.writableFinished) return;
2320
- clientGone = true;
2321
- releaseRun();
2322
- queryHandle?.interrupt().catch(() => {});
2323
- });
2599
+ attachSink(run, res);
2324
2600
 
2325
2601
  // Resume-if-exists gate. \`claude --resume <id>\` errors hard with "No
2326
2602
  // conversation found with session ID" when the local session jsonl is
@@ -2353,44 +2629,94 @@ async function handleStart(req, res, runId) {
2353
2629
  }
2354
2630
 
2355
2631
  // Nobody left to stream to: do not start a CLI for it.
2356
- if (clientGone) { res.end(); return; }
2632
+ if (run.clientGone) { res.end(); return; }
2357
2633
  try {
2358
- queryHandle = query({
2634
+ run.query = query({
2359
2635
  prompt: promptStream,
2360
2636
  options: {
2361
2637
  ...opts,
2362
2638
  canUseTool,
2363
2639
  hooks: { PreToolUse: [{ hooks: [async (input) => {
2364
- planning = input.permission_mode === "plan";
2365
- return interactiveQuestions && ["AskUserQuestion", "ExitPlanMode"].includes(input.tool_name)
2640
+ run.settings.planning = input.permission_mode === "plan";
2641
+ return run.settings.interactiveQuestions && ["AskUserQuestion", "ExitPlanMode"].includes(input.tool_name)
2366
2642
  ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask" } } : {};
2367
2643
  }] }] },
2368
2644
  },
2369
2645
  });
2370
2646
  } catch (e) {
2371
- clearInterval(heartbeat);
2647
+ clearInterval(run.heartbeat);
2372
2648
  res.write(JSON.stringify({ _error: String(e?.message ?? e) }) + "\\n");
2373
2649
  res.end();
2374
2650
  return;
2375
2651
  }
2376
2652
 
2377
- liveRuns.set(runId, { query: queryHandle, prompt: promptStream, permissions });
2653
+ liveRuns.set(runId, run);
2378
2654
 
2379
2655
  // Forward every SDKMessage, not just up to the first result: in
2380
2656
  // streaming-input mode the CLI keeps running after a turn ends and
2381
2657
  // re-prompts the model when background work finishes. The host decides
2382
- // when the run is over and disconnects (res "close" above), which ends
2383
- // the prompt and lets the CLI wind down.
2658
+ // when the run is over and disconnects (the sink's "close"), which ends
2659
+ // the prompt and lets the CLI wind down, or parks the run first, which
2660
+ // keeps it. Frames go to whichever run is attached, else to the buffer.
2384
2661
  try {
2385
- for await (const message of queryHandle) {
2386
- res.write(JSON.stringify(message) + "\\n");
2662
+ for await (const message of run.query) {
2663
+ observeFrame(run, message);
2664
+ writeFrame(run, message);
2387
2665
  }
2388
2666
  } catch (e) {
2389
- res.write(JSON.stringify({ _error: String(e?.message ?? e) }) + "\\n");
2667
+ writeFrame(run, { _error: String(e?.message ?? e) });
2390
2668
  } finally {
2391
- releaseRun();
2392
- res.end();
2669
+ const sink = run.sink;
2670
+ releaseRun(run);
2671
+ sink?.end();
2672
+ }
2673
+ }
2674
+
2675
+ // The host settled a run whose background work is still live and is about
2676
+ // to leave: keep the CLI instead of winding it down. \`tasks\` is what the
2677
+ // host saw live (handed back to the run that attaches), \`ttlMs\` what is
2678
+ // left of its background budget, so unattended work never outlives it.
2679
+ async function handlePark(req, res, runId) {
2680
+ let body;
2681
+ try { body = await readJsonBody(req); }
2682
+ catch { res.writeHead(400); res.end(); return; }
2683
+ const run = liveRuns.get(runId);
2684
+ if (!run || !run.query || run.clientGone) {
2685
+ res.writeHead(404, { "content-type": "application/json" });
2686
+ res.end(JSON.stringify({ parked: false }));
2687
+ return;
2393
2688
  }
2689
+ clearTimeout(run.parked?.timer);
2690
+ const parked = {
2691
+ wakeUrl: /^https?:\\/\\//.test(String(body?.wakeUrl ?? "")) ? String(body.wakeUrl) : "",
2692
+ wakeToken: String(body?.wakeToken ?? ""),
2693
+ tasks: Array.isArray(body?.tasks) ? body.tasks : [],
2694
+ waking: false,
2695
+ wokeAt: 0,
2696
+ timer: undefined,
2697
+ };
2698
+ // \`ttlMs: null\` is how the host says "no bound" (backgroundTaskTimeoutMs
2699
+ // of Infinity). Number(null) is 0, so the raw value has to be typed \u2014
2700
+ // coercing it would arm an immediate teardown for an unbounded park.
2701
+ const ttlMs = body?.ttlMs;
2702
+ const requested = typeof ttlMs === "number" && Number.isFinite(ttlMs) && ttlMs >= 0
2703
+ ? ttlMs
2704
+ : Infinity;
2705
+ // Never past a deadline inherited from an earlier park: the ceiling bounds
2706
+ // the work, not each park, so a CLI that is adopted and re-parked on every
2707
+ // wake-up cannot hold the sandbox open forever.
2708
+ run.parkDeadline = Math.min(
2709
+ run.parkDeadline ?? Infinity,
2710
+ requested === Infinity ? Infinity : Date.now() + requested,
2711
+ );
2712
+ if (Number.isFinite(run.parkDeadline)) {
2713
+ const delay = Math.max(0, run.parkDeadline - Date.now());
2714
+ parked.timer = setTimeout(() => { if (run.parked === parked) endRun(run); }, Math.min(delay, 2147483647));
2715
+ if (typeof parked.timer.unref === "function") parked.timer.unref();
2716
+ }
2717
+ run.parked = parked;
2718
+ res.writeHead(200, { "content-type": "application/json" });
2719
+ res.end(JSON.stringify({ parked: true }));
2394
2720
  }
2395
2721
 
2396
2722
  async function handleSendMessage(req, res, runId) {
@@ -2423,6 +2749,14 @@ async function handleAbort(_req, res, runId) {
2423
2749
  res.end();
2424
2750
  return;
2425
2751
  }
2752
+ // Stop means stop: a parked CLI kept for background work must not survive
2753
+ // the abort and wake the host with a new turn after the user pressed it.
2754
+ if (run.parked) {
2755
+ endRun(run);
2756
+ res.writeHead(204);
2757
+ res.end();
2758
+ return;
2759
+ }
2426
2760
  await run.query.interrupt().catch(() => {});
2427
2761
  res.writeHead(204);
2428
2762
  res.end();
@@ -2431,9 +2765,9 @@ async function handleAbort(_req, res, runId) {
2431
2765
  async function handleDelete(_req, res, runId) {
2432
2766
  const run = liveRuns.get(runId);
2433
2767
  if (run) {
2434
- liveRuns.delete(runId);
2435
- run.prompt.end();
2436
- await run.query.interrupt().catch(() => {});
2768
+ // Also drops a park: an explicit delete ends the background work too.
2769
+ releaseRun(run);
2770
+ await run.query?.interrupt().catch(() => {});
2437
2771
  }
2438
2772
  res.writeHead(204);
2439
2773
  res.end();
@@ -2472,6 +2806,10 @@ const server = http.createServer((req, res) => {
2472
2806
  handleAbort(req, res, decodeURIComponent(m[1]));
2473
2807
  return;
2474
2808
  }
2809
+ if (req.method === "POST" && (m = url.match(/^\\/runs\\/([^/]+)\\/park$/))) {
2810
+ handlePark(req, res, decodeURIComponent(m[1])).catch(() => { if (!res.headersSent) res.writeHead(400); res.end(); });
2811
+ return;
2812
+ }
2475
2813
  if (req.method === "DELETE" && (m = url.match(/^\\/runs\\/([^/]+)$/))) {
2476
2814
  handleDelete(req, res, decodeURIComponent(m[1]));
2477
2815
  return;
@@ -2724,7 +3062,16 @@ var ClaudeCodeAgentAdapter = class {
2724
3062
  };
2725
3063
  const mcpConfigJson = buildClaudeMcpConfig(options.mcps) ?? JSON.stringify({ mcpServers: {} }, null, 2);
2726
3064
  const artifacts = [
2727
- ...!sandbox ? [{ path: path8.join(target.layout.claudeDir, ".claude-plugin", "plugin.json"), content: JSON.stringify({ name: "agentbox", version: "1.0.0" }) }] : [],
3065
+ ...!sandbox ? [
3066
+ {
3067
+ path: path8.join(
3068
+ target.layout.claudeDir,
3069
+ ".claude-plugin",
3070
+ "plugin.json"
3071
+ ),
3072
+ content: JSON.stringify({ name: "agentbox", version: "1.0.0" })
3073
+ }
3074
+ ] : [],
2728
3075
  ...skillArtifacts,
2729
3076
  ...buildClaudeCommandArtifacts(options.commands, target.layout),
2730
3077
  ...buildClaudeSubagentArtifacts(options.subAgents, target.layout),
@@ -2790,7 +3137,8 @@ var ClaudeCodeAgentAdapter = class {
2790
3137
  env.ANTHROPIC_CUSTOM_HEADERS = env.ANTHROPIC_CUSTOM_HEADERS ? `${env.ANTHROPIC_CUSTOM_HEADERS}
2791
3138
  ${serialized}` : serialized;
2792
3139
  }
2793
- const inputParts = await time(
3140
+ const attachOnly = request.run.resumeParked === true;
3141
+ const inputParts = attachOnly ? [] : await time(
2794
3142
  debugClaude,
2795
3143
  "validateProviderUserInput",
2796
3144
  () => validateProviderUserInput(request.provider, request.run.input)
@@ -2814,13 +3162,15 @@ ${serialized}` : serialized;
2814
3162
  });
2815
3163
  const autoApproveTools = shouldAutoApproveClaudeTools(request.options);
2816
3164
  const requestBody = {
2817
- prompt: {
2818
- type: "user",
2819
- message: {
2820
- role: "user",
2821
- content: userContent
2822
- },
2823
- parent_tool_use_id: null
3165
+ ...attachOnly ? { attach: true } : {
3166
+ prompt: {
3167
+ type: "user",
3168
+ message: {
3169
+ role: "user",
3170
+ content: userContent
3171
+ },
3172
+ parent_tool_use_id: null
3173
+ }
2824
3174
  },
2825
3175
  options: {
2826
3176
  ...sdkOptions,
@@ -2836,11 +3186,18 @@ ${serialized}` : serialized;
2836
3186
  const runUrl = `${baseUrl}/runs/${encodeURIComponent(request.runId)}`;
2837
3187
  const cleanup = async () => {
2838
3188
  try {
2839
- await fetch(`${runUrl}/abort`, { method: "POST", headers: authHeaders });
3189
+ await fetch(`${runUrl}/abort`, {
3190
+ method: "POST",
3191
+ headers: authHeaders
3192
+ });
2840
3193
  } catch {
2841
3194
  }
2842
3195
  try {
2843
- await fetch(runUrl, { method: "DELETE", headers: authHeaders, signal: AbortSignal.timeout(3e3) });
3196
+ await fetch(runUrl, {
3197
+ method: "DELETE",
3198
+ headers: authHeaders,
3199
+ signal: AbortSignal.timeout(3e3)
3200
+ });
2844
3201
  } catch {
2845
3202
  }
2846
3203
  fetchAbort.abort();
@@ -2911,12 +3268,84 @@ ${serialized}` : serialized;
2911
3268
  const ask = control._permission;
2912
3269
  const isQuestion = ask.toolName === "AskUserQuestion";
2913
3270
  const isPlan = ask.toolName === "ExitPlanMode";
2914
- const answer = await sink.requestPermission({ type: "permission.requested", provider: request.provider, runId: request.runId, timestamp: (/* @__PURE__ */ new Date()).toISOString(), requestId: ask.requestId, kind: isQuestion ? "question" : isPlan ? "plan" : "tool", toolName: ask.toolName, title: isQuestion ? "Your input is needed" : isPlan ? "Review the plan" : ask.title ?? `Allow ${ask.toolName}?`, input: ask.input, ...isQuestion ? { questions: normalizeUserQuestions("claude-code", ask.input) } : {} });
2915
- const reply = await fetch(`${baseUrl}/runs/${encodeURIComponent(request.runId)}/permission`, { method: "POST", headers: { "content-type": "application/json", ...authHeaders }, signal: fetchAbort.signal, body: JSON.stringify({ requestId: ask.requestId, response: answer.decision === "allow" ? { behavior: "allow", updatedInput: isQuestion ? { ...ask.input, answers: questionReply("claude-code", ask.input, answer.answers ?? []) } : ask.input } : { behavior: "deny", message: "The user declined this request." } }) });
2916
- if (!reply.ok) throw new Error(`Claude permission response failed: ${reply.status}`);
3271
+ const answer = await sink.requestPermission({
3272
+ type: "permission.requested",
3273
+ provider: request.provider,
3274
+ runId: request.runId,
3275
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3276
+ requestId: ask.requestId,
3277
+ kind: isQuestion ? "question" : isPlan ? "plan" : "tool",
3278
+ toolName: ask.toolName,
3279
+ title: isQuestion ? "Your input is needed" : isPlan ? "Review the plan" : ask.title ?? `Allow ${ask.toolName}?`,
3280
+ input: ask.input,
3281
+ ...isQuestion ? { questions: normalizeUserQuestions("claude-code", ask.input) } : {}
3282
+ });
3283
+ const reply = await fetch(
3284
+ `${baseUrl}/runs/${encodeURIComponent(request.runId)}/permission`,
3285
+ {
3286
+ method: "POST",
3287
+ headers: { "content-type": "application/json", ...authHeaders },
3288
+ signal: fetchAbort.signal,
3289
+ body: JSON.stringify({
3290
+ requestId: ask.requestId,
3291
+ response: answer.decision === "allow" ? {
3292
+ behavior: "allow",
3293
+ updatedInput: isQuestion ? {
3294
+ ...ask.input,
3295
+ answers: questionReply(
3296
+ "claude-code",
3297
+ ask.input,
3298
+ answer.answers ?? []
3299
+ )
3300
+ } : ask.input
3301
+ } : {
3302
+ behavior: "deny",
3303
+ message: "The user declined this request."
3304
+ }
3305
+ })
3306
+ }
3307
+ );
3308
+ if (!reply.ok)
3309
+ throw new Error(`Claude permission response failed: ${reply.status}`);
2917
3310
  }
2918
3311
  };
2919
- await consumeClaudeMessages(request, sink, permissionMessages(), executeStartedAt, cleanup, () => cancelled, {}, tracker);
3312
+ const parking = request.options.provider?.parkBackgroundWork;
3313
+ await consumeClaudeMessages(
3314
+ request,
3315
+ sink,
3316
+ permissionMessages(),
3317
+ executeStartedAt,
3318
+ cleanup,
3319
+ () => cancelled,
3320
+ {
3321
+ attachOnly,
3322
+ ...parking ? {
3323
+ // The daemon keeps the CLI once told to; only then may the stream be
3324
+ // dropped without the wind-down that `cleanup` asks for.
3325
+ park: async (tasks, ttlMs) => {
3326
+ const response2 = await fetch(`${runUrl}/park`, {
3327
+ method: "POST",
3328
+ headers: {
3329
+ "content-type": "application/json",
3330
+ ...authHeaders
3331
+ },
3332
+ signal: AbortSignal.timeout(1e4),
3333
+ body: JSON.stringify({
3334
+ wakeUrl: parking.wakeUrl,
3335
+ wakeToken: parking.wakeToken,
3336
+ tasks,
3337
+ ttlMs: Number.isFinite(ttlMs) ? ttlMs : null
3338
+ })
3339
+ });
3340
+ return response2.ok && (await response2.json()).parked === true;
3341
+ },
3342
+ detach: async () => {
3343
+ fetchAbort.abort();
3344
+ }
3345
+ } : {}
3346
+ },
3347
+ tracker
3348
+ );
2920
3349
  return async () => void 0;
2921
3350
  }
2922
3351
  /**
@@ -2987,28 +3416,63 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
2987
3416
  let lastTerminalReason;
2988
3417
  let lastIsError = false;
2989
3418
  const rawPayloads = [];
2990
- const timeoutMs = resolveBackgroundTaskTimeoutMs(request.options.backgroundTaskTimeoutMs);
3419
+ const timeoutMs = resolveBackgroundTaskTimeoutMs(
3420
+ request.options.backgroundTaskTimeoutMs
3421
+ );
2991
3422
  const graceMs = wait.graceMs ?? BACKGROUND_TASK_GRACE_MS;
2992
3423
  let pendingWait;
2993
3424
  let waitedMs = 0;
2994
3425
  let expiry;
2995
- let lastTasksKey = JSON.stringify({ tasks: [], waiting: false });
2996
- const emitTasks = (tasks, waiting) => {
2997
- const key = JSON.stringify({ tasks, waiting });
3426
+ const finishWait = new BackgroundWaitFinish();
3427
+ sink.setFinishBackgroundWait?.(() => finishWait.request());
3428
+ let parked = false;
3429
+ let leave = cleanup;
3430
+ let foreignResults = 0;
3431
+ let nothingParked = false;
3432
+ let lastTasksKey = JSON.stringify({
3433
+ tasks: [],
3434
+ waiting: false,
3435
+ isParked: false
3436
+ });
3437
+ const emitTasks = (tasks, waiting, isParked = false) => {
3438
+ const key = JSON.stringify({ tasks, waiting, isParked });
2998
3439
  if (key === lastTasksKey) return;
2999
3440
  lastTasksKey = key;
3000
- sink.emitEvent(createNormalizedEvent("background.tasks", { provider: request.provider, runId: request.runId }, { tasks, waiting }));
3441
+ sink.emitEvent(
3442
+ createNormalizedEvent(
3443
+ "background.tasks",
3444
+ { provider: request.provider, runId: request.runId },
3445
+ { tasks, waiting, ...isParked ? { parked: true } : {} }
3446
+ )
3447
+ );
3448
+ };
3449
+ const budgetLeftMs = () => Math.max(0, timeoutMs - waitedMs - (pendingWait?.elapsedMs() ?? 0));
3450
+ const tryPark = async () => {
3451
+ const live = tracker.liveTasks();
3452
+ const ttlMs = budgetLeftMs();
3453
+ if (!wait.park || live.length === 0 || isAborted() || ttlMs <= 0)
3454
+ return false;
3455
+ parked = await wait.park(live, ttlMs).catch(() => false);
3456
+ if (parked)
3457
+ debugClaude(
3458
+ "\u2605 parked the harness with %d background task(s); settling",
3459
+ live.length
3460
+ );
3461
+ return parked;
3001
3462
  };
3002
3463
  const isAborted = () => wasCancelled() || lastTerminalReason === "aborted_streaming" || lastTerminalReason === "aborted_tools";
3003
3464
  const endWait = () => {
3004
3465
  if (!pendingWait) return;
3005
3466
  waitedMs += pendingWait.elapsedMs();
3006
3467
  pendingWait.clear();
3007
- pendingWait = void 0;
3468
+ pendingWait = finishWait.watch(void 0);
3008
3469
  };
3009
3470
  const settleOnFailure = (error) => {
3010
3471
  if (!pendingWait || !sawResult || lastIsError) return false;
3011
- debugClaude("\u2605 transport failed during background wait; settling on the last result: %o", error);
3472
+ debugClaude(
3473
+ "\u2605 transport failed during background wait; settling on the last result: %o",
3474
+ error
3475
+ );
3012
3476
  expiry = "transport";
3013
3477
  return true;
3014
3478
  };
@@ -3026,7 +3490,8 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3026
3490
  break;
3027
3491
  }
3028
3492
  if ("reason" in step) {
3029
- expiry = step.reason;
3493
+ if (!(step.reason === "finished" && await tryPark()))
3494
+ expiry = step.reason;
3030
3495
  break;
3031
3496
  }
3032
3497
  if (step.result.done) break;
@@ -3038,7 +3503,28 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3038
3503
  if (!settleOnFailure(error)) throw error;
3039
3504
  break;
3040
3505
  }
3506
+ if ("_parked" in ctrl) {
3507
+ const info = ctrl._parked;
3508
+ tracker.restore(Array.isArray(info?.tasks) ? info.tasks : []);
3509
+ foreignResults = Math.max(
3510
+ 0,
3511
+ Number(info?.turns ?? 0) - (wait.attachOnly ? 1 : 0)
3512
+ );
3513
+ const budgetLeft = info?.budgetLeftMs;
3514
+ if (typeof budgetLeft === "number" && Number.isFinite(timeoutMs)) {
3515
+ waitedMs = Math.max(0, timeoutMs - Math.max(0, budgetLeft));
3516
+ }
3517
+ debugClaude(
3518
+ "\u2605 attached to a parked harness: %d task(s) live, %d earlier result(s)",
3519
+ tracker.liveTasks().length,
3520
+ foreignResults
3521
+ );
3522
+ emitTasks(tracker.liveTasks(), false);
3523
+ continue;
3524
+ }
3041
3525
  if ("_notice" in ctrl) {
3526
+ if (ctrl._notice === "no_parked_run" || ctrl._notice === "no_parked_turn")
3527
+ nothingParked = true;
3042
3528
  debugClaude("daemon notice: %o", ctrl);
3043
3529
  sink.emitRaw(
3044
3530
  toRawEvent(
@@ -3054,21 +3540,31 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3054
3540
  rawPayloads.push(message);
3055
3541
  sink.emitRaw(toRawEvent(request.runId, message, message.type));
3056
3542
  if (tracker.ingest(message) && pendingWait) {
3057
- debugClaude("\u2605 follow-up turn started; background wait over (%dms since execute start)", Date.now() - executeStartedAt);
3543
+ debugClaude(
3544
+ "\u2605 follow-up turn started; background wait over (%dms since execute start)",
3545
+ Date.now() - executeStartedAt
3546
+ );
3058
3547
  endWait();
3059
3548
  }
3060
3549
  emitTasks(tracker.liveTasks(), pendingWait !== void 0);
3061
3550
  const sessionState = message.type === "system" && message.subtype === "session_state_changed" ? message.state : void 0;
3062
3551
  if (sessionState) sawSessionState = true;
3063
- pendingWait?.setIdle(!sawSessionState && tracker.liveTasks().length === 0);
3064
- if (sessionState === "idle" && pendingWait && tracker.liveTasks().length === 0) break;
3552
+ pendingWait?.setIdle(
3553
+ !sawSessionState && tracker.liveTasks().length === 0
3554
+ );
3555
+ if (sessionState === "idle" && pendingWait && (tracker.liveTasks().length === 0 || await tryPark()))
3556
+ break;
3065
3557
  if (message.type === "system") {
3066
3558
  const sub = message.subtype;
3067
3559
  if (sub === "init") {
3068
3560
  const sys = message;
3069
- if (request.run.goal && !sys.slash_commands.some((command) => command.replace(/^\//, "") === "goal")) {
3561
+ if (request.run.goal && !sys.slash_commands.some(
3562
+ (command) => command.replace(/^\//, "") === "goal"
3563
+ )) {
3070
3564
  await cleanup();
3071
- throw new Error("This Claude Code installation does not expose the native /goal command.");
3565
+ throw new Error(
3566
+ "This Claude Code installation does not expose the native /goal command."
3567
+ );
3072
3568
  }
3073
3569
  if (sys.session_id) {
3074
3570
  debugClaude(
@@ -3170,6 +3666,12 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3170
3666
  continue;
3171
3667
  }
3172
3668
  if (message.type === "result") {
3669
+ if (foreignResults > 0) {
3670
+ foreignResults--;
3671
+ accumulatedText = "";
3672
+ streamedThinkingChars = 0;
3673
+ continue;
3674
+ }
3173
3675
  sawResult = true;
3174
3676
  const result = message;
3175
3677
  lastTerminalReason = result.terminal_reason;
@@ -3179,30 +3681,55 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3179
3681
  accumulatedText = resultText;
3180
3682
  }
3181
3683
  const live = tracker.liveTasks();
3182
- if (timeoutMs === 0 || !tracker.hasSeenBackgroundWork() || isAborted()) break;
3183
- debugClaude("\u2605 turn ended with %d background task(s); waiting", live.length);
3684
+ if (timeoutMs === 0 || !tracker.hasSeenBackgroundWork() || isAborted())
3685
+ break;
3686
+ debugClaude(
3687
+ "\u2605 turn ended with %d background task(s); waiting",
3688
+ live.length
3689
+ );
3184
3690
  endWait();
3185
- pendingWait = new BackgroundWait(graceMs, Math.max(0, timeoutMs - waitedMs));
3691
+ pendingWait = finishWait.watch(
3692
+ new BackgroundWait(graceMs, Math.max(0, timeoutMs - waitedMs))
3693
+ );
3186
3694
  pendingWait.setIdle(!sawSessionState && live.length === 0);
3187
3695
  emitTasks(live, true);
3188
3696
  continue;
3189
3697
  }
3190
3698
  }
3191
- if (expiry === "ceiling") {
3699
+ if (expiry === "ceiling" || expiry === "finished") {
3192
3700
  const ids = tracker.liveTasks().filter((task) => task.type !== "scheduled_wakeup").map((task) => task.id);
3193
- debugClaude("\u2605 background wait ceiling (%dms) hit; stopping %d task(s)", timeoutMs, ids.length);
3701
+ if (expiry === "ceiling")
3702
+ debugClaude(
3703
+ "\u2605 background wait ceiling (%dms) hit; stopping %d task(s)",
3704
+ timeoutMs,
3705
+ ids.length
3706
+ );
3707
+ else
3708
+ debugClaude(
3709
+ "\u2605 host finished the background wait; stopping %d task(s)",
3710
+ ids.length
3711
+ );
3194
3712
  if (wait.stopTasks) {
3195
- await withTimeout(wait.stopTasks(ids), wait.stopTimeoutMs ?? STOP_TASKS_TIMEOUT_MS).catch(() => void 0);
3713
+ await withTimeout(
3714
+ wait.stopTasks(ids),
3715
+ wait.stopTimeoutMs ?? STOP_TASKS_TIMEOUT_MS
3716
+ ).catch(() => void 0);
3196
3717
  }
3197
3718
  } else if (expiry === "grace") {
3198
3719
  debugClaude("\u2605 background set emptied with no follow-up turn; settling");
3199
3720
  }
3200
3721
  if (pendingWait) {
3201
3722
  endWait();
3202
- emitTasks([], false);
3203
- }
3204
- await cleanup();
3205
- if (!sawResult && !wasCancelled()) throw new Error("Claude Code closed before reporting a result");
3723
+ if (parked) emitTasks(tracker.liveTasks(), false, true);
3724
+ else emitTasks([], false);
3725
+ }
3726
+ if (parked && wait.detach) {
3727
+ await wait.detach();
3728
+ leave = wait.detach;
3729
+ } else await cleanup();
3730
+ if (nothingParked && !sawResult) sawResult = true;
3731
+ if (!sawResult && !wasCancelled())
3732
+ throw new Error("Claude Code closed before reporting a result");
3206
3733
  const finalText = accumulatedText;
3207
3734
  const isCancelled = isAborted();
3208
3735
  const isError = !isCancelled && lastIsError;
@@ -3242,18 +3769,25 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3242
3769
  );
3243
3770
  sink.complete({
3244
3771
  text: finalText,
3245
- costData: extractClaudeCostData(rawPayloads)
3772
+ costData: extractClaudeCostData(rawPayloads),
3773
+ // Told, not guessed: an attach that found nothing parked ran no turn
3774
+ // at all, which from the outside looks exactly like a real tool-only
3775
+ // turn that produced no answer text and no usage.
3776
+ ...nothingParked ? { nothingParked: true } : {}
3246
3777
  });
3247
3778
  }
3248
3779
  } finally {
3249
3780
  pendingWait?.clear();
3250
- await cleanup();
3781
+ await leave();
3251
3782
  }
3252
3783
  }
3253
3784
  async function executeNativeClaude(request, sink, wait = {}) {
3254
3785
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
3255
3786
  const claudeDir = claudeConfigDir(request.options);
3256
- const input = await validateProviderUserInput(request.provider, request.run.input);
3787
+ const input = await validateProviderUserInput(
3788
+ request.provider,
3789
+ request.run.input
3790
+ );
3257
3791
  const prompt = new AsyncQueue();
3258
3792
  const sessionId = request.run.resumeSessionId ?? randomUUID();
3259
3793
  const controller = new AbortController();
@@ -3274,13 +3808,30 @@ async function executeNativeClaude(request, sink, wait = {}) {
3274
3808
  });
3275
3809
  sink.setSessionId(sessionId);
3276
3810
  const messageId = randomUUID();
3277
- prompt.push({ type: "user", uuid: messageId, message: { role: "user", content: mapToClaudeUserContent(input) }, parent_tool_use_id: null });
3278
- const hostEnv = Object.fromEntries(Object.entries({ ...process.env, ...request.options.env }).filter((entry) => entry[1] !== void 0));
3811
+ prompt.push({
3812
+ type: "user",
3813
+ uuid: messageId,
3814
+ message: {
3815
+ role: "user",
3816
+ content: mapToClaudeUserContent(
3817
+ input
3818
+ )
3819
+ },
3820
+ parent_tool_use_id: null
3821
+ });
3822
+ const hostEnv = Object.fromEntries(
3823
+ Object.entries({ ...process.env, ...request.options.env }).filter(
3824
+ (entry) => entry[1] !== void 0
3825
+ )
3826
+ );
3279
3827
  applyCliBackgroundWaitCeiling(hostEnv);
3280
3828
  hostEnv.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS ??= "1";
3281
3829
  if (request.options.customHeaders) {
3282
3830
  const headers = Object.entries(request.options.customHeaders).map(([name, value]) => `${name}: ${value}`).join("\n");
3283
- hostEnv.ANTHROPIC_CUSTOM_HEADERS = [hostEnv.ANTHROPIC_CUSTOM_HEADERS, headers].filter(Boolean).join("\n");
3831
+ hostEnv.ANTHROPIC_CUSTOM_HEADERS = [
3832
+ hostEnv.ANTHROPIC_CUSTOM_HEADERS,
3833
+ headers
3834
+ ].filter(Boolean).join("\n");
3284
3835
  }
3285
3836
  const options = buildClaudeQueryOptions({
3286
3837
  request,
@@ -3296,71 +3847,143 @@ async function executeNativeClaude(request, sink, wait = {}) {
3296
3847
  const interactiveQuestions = hasInteractiveQuestions(request.options);
3297
3848
  let planning = options.permissionMode === "plan";
3298
3849
  try {
3299
- handle = query({ prompt, options: {
3300
- ...options,
3301
- // Use the SDK-matched CLI by default; an installed CLI is an explicit override.
3302
- pathToClaudeCodeExecutable: request.options.provider?.binary,
3303
- abortController: controller,
3304
- // `sessionId` is rejected alongside `resume` unless `forkSession` is
3305
- // set, where it names the forked session. Stamping it on forks keeps
3306
- // the pre-minted id reported via `sink.setSessionId` truthful, so a
3307
- // later run can resume the fork.
3308
- ...request.run.resumeSessionId ? {} : { sessionId },
3309
- ...request.options.configuration === "native" ? {} : { plugins: [{ type: "local", path: claudeDir }] },
3310
- spawnClaudeCodeProcess(spawnOptions) {
3311
- if (cancelled || controller.signal.aborted) throw new Error("Local run was cancelled before startup");
3312
- processHandle = spawnCommand({ ...spawnOptions, processGroup: request.options.processGroup !== "inherited" });
3313
- return processHandle.child;
3314
- },
3315
- hooks: { ...options.hooks, PreToolUse: [...options.hooks?.PreToolUse ?? [], { hooks: [async (input2) => {
3316
- if (input2.hook_event_name !== "PreToolUse") return {};
3317
- planning = input2.permission_mode === "plan";
3318
- return interactiveQuestions && ["AskUserQuestion", "ExitPlanMode"].includes(input2.tool_name) ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask" } } : {};
3319
- }] }] },
3320
- async canUseTool(toolName, input2, context) {
3321
- if (cancelled || context.signal.aborted) return { behavior: "deny", message: "Run cancelled", interrupt: true };
3322
- const isQuestion = toolName === "AskUserQuestion";
3323
- const isPlan = toolName === "ExitPlanMode";
3324
- if ((isQuestion || isPlan) && !interactiveQuestions) return { behavior: "deny", message: "No interactive user is available." };
3325
- if (!isQuestion && !isPlan && planning && toolName !== "EnterPlanMode") return { behavior: "deny", message: "Finish planning before requesting write access." };
3326
- if (!isQuestion && !isPlan && autoApprove) return { behavior: "allow", updatedInput: input2 };
3327
- try {
3328
- const response = await sink.requestPermission({
3329
- type: "permission.requested",
3330
- provider: request.provider,
3331
- runId: request.runId,
3332
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3333
- requestId: context.toolUseID,
3334
- kind: isQuestion ? "question" : isPlan ? "plan" : "tool",
3335
- toolName,
3336
- ...isQuestion ? { questions: normalizeUserQuestions("claude-code", input2) } : {},
3337
- title: isQuestion ? "Your input is needed" : isPlan ? "Review the plan" : context.title ?? `Allow ${toolName}?`,
3338
- message: context.description ?? context.decisionReason,
3339
- input: input2,
3340
- canRemember: false
3850
+ handle = query({
3851
+ prompt,
3852
+ options: {
3853
+ ...options,
3854
+ // Use the SDK-matched CLI by default; an installed CLI is an explicit override.
3855
+ pathToClaudeCodeExecutable: request.options.provider?.binary,
3856
+ abortController: controller,
3857
+ // `sessionId` is rejected alongside `resume` unless `forkSession` is
3858
+ // set, where it names the forked session. Stamping it on forks keeps
3859
+ // the pre-minted id reported via `sink.setSessionId` truthful, so a
3860
+ // later run can resume the fork.
3861
+ ...request.run.resumeSessionId ? {} : { sessionId },
3862
+ ...request.options.configuration === "native" ? {} : { plugins: [{ type: "local", path: claudeDir }] },
3863
+ spawnClaudeCodeProcess(spawnOptions) {
3864
+ if (cancelled || controller.signal.aborted)
3865
+ throw new Error("Local run was cancelled before startup");
3866
+ processHandle = spawnCommand({
3867
+ ...spawnOptions,
3868
+ processGroup: request.options.processGroup !== "inherited"
3341
3869
  });
3342
- if (isPlan && response.decision === "allow") planning = false;
3343
- if (!cancelled && !context.signal.aborted && response.decision === "allow") return {
3344
- behavior: "allow",
3345
- updatedInput: isQuestion ? { ...input2, answers: questionReply("claude-code", input2, response.answers ?? []) } : input2
3346
- };
3347
- return { behavior: "deny", message: "The user denied this action" };
3348
- } catch {
3349
- return { behavior: "deny", message: "Run cancelled", interrupt: true };
3870
+ return processHandle.child;
3871
+ },
3872
+ hooks: {
3873
+ ...options.hooks,
3874
+ PreToolUse: [
3875
+ ...options.hooks?.PreToolUse ?? [],
3876
+ {
3877
+ hooks: [
3878
+ async (input2) => {
3879
+ if (input2.hook_event_name !== "PreToolUse") return {};
3880
+ planning = input2.permission_mode === "plan";
3881
+ return interactiveQuestions && ["AskUserQuestion", "ExitPlanMode"].includes(
3882
+ input2.tool_name
3883
+ ) ? {
3884
+ hookSpecificOutput: {
3885
+ hookEventName: "PreToolUse",
3886
+ permissionDecision: "ask"
3887
+ }
3888
+ } : {};
3889
+ }
3890
+ ]
3891
+ }
3892
+ ]
3893
+ },
3894
+ async canUseTool(toolName, input2, context) {
3895
+ if (cancelled || context.signal.aborted)
3896
+ return {
3897
+ behavior: "deny",
3898
+ message: "Run cancelled",
3899
+ interrupt: true
3900
+ };
3901
+ const isQuestion = toolName === "AskUserQuestion";
3902
+ const isPlan = toolName === "ExitPlanMode";
3903
+ if ((isQuestion || isPlan) && !interactiveQuestions)
3904
+ return {
3905
+ behavior: "deny",
3906
+ message: "No interactive user is available."
3907
+ };
3908
+ if (!isQuestion && !isPlan && planning && toolName !== "EnterPlanMode")
3909
+ return {
3910
+ behavior: "deny",
3911
+ message: "Finish planning before requesting write access."
3912
+ };
3913
+ if (!isQuestion && !isPlan && autoApprove)
3914
+ return { behavior: "allow", updatedInput: input2 };
3915
+ try {
3916
+ const response = await sink.requestPermission({
3917
+ type: "permission.requested",
3918
+ provider: request.provider,
3919
+ runId: request.runId,
3920
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3921
+ requestId: context.toolUseID,
3922
+ kind: isQuestion ? "question" : isPlan ? "plan" : "tool",
3923
+ toolName,
3924
+ ...isQuestion ? { questions: normalizeUserQuestions("claude-code", input2) } : {},
3925
+ title: isQuestion ? "Your input is needed" : isPlan ? "Review the plan" : context.title ?? `Allow ${toolName}?`,
3926
+ message: context.description ?? context.decisionReason,
3927
+ input: input2,
3928
+ canRemember: false
3929
+ });
3930
+ if (isPlan && response.decision === "allow") planning = false;
3931
+ if (!cancelled && !context.signal.aborted && response.decision === "allow")
3932
+ return {
3933
+ behavior: "allow",
3934
+ updatedInput: isQuestion ? {
3935
+ ...input2,
3936
+ answers: questionReply(
3937
+ "claude-code",
3938
+ input2,
3939
+ response.answers ?? []
3940
+ )
3941
+ } : input2
3942
+ };
3943
+ return { behavior: "deny", message: "The user denied this action" };
3944
+ } catch {
3945
+ return {
3946
+ behavior: "deny",
3947
+ message: "Run cancelled",
3948
+ interrupt: true
3949
+ };
3950
+ }
3350
3951
  }
3351
3952
  }
3352
- } });
3953
+ });
3353
3954
  const live = handle;
3354
3955
  sink.setRaw({ query: live, claudeDir, runId: request.runId });
3355
- sink.emitEvent(createNormalizedEvent("run.started", { provider: request.provider, runId: request.runId }));
3356
- sink.emitEvent(createNormalizedEvent("message.started", { provider: request.provider, runId: request.runId }, { messageId }));
3357
- await consumeClaudeMessages(request, sink, live, Date.now(), stop, () => cancelled, {
3358
- ...wait,
3359
- // Native owns the CLI: ask it to stop leftover tasks before closing it.
3360
- stopTasks: async (ids) => {
3361
- await Promise.all(ids.map((id) => live.stopTask(id).catch(() => void 0)));
3362
- }
3363
- }, tracker);
3956
+ sink.emitEvent(
3957
+ createNormalizedEvent("run.started", {
3958
+ provider: request.provider,
3959
+ runId: request.runId
3960
+ })
3961
+ );
3962
+ sink.emitEvent(
3963
+ createNormalizedEvent(
3964
+ "message.started",
3965
+ { provider: request.provider, runId: request.runId },
3966
+ { messageId }
3967
+ )
3968
+ );
3969
+ await consumeClaudeMessages(
3970
+ request,
3971
+ sink,
3972
+ live,
3973
+ Date.now(),
3974
+ stop,
3975
+ () => cancelled,
3976
+ {
3977
+ ...wait,
3978
+ // Native owns the CLI: ask it to stop leftover tasks before closing it.
3979
+ stopTasks: async (ids) => {
3980
+ await Promise.all(
3981
+ ids.map((id) => live.stopTask(id).catch(() => void 0))
3982
+ );
3983
+ }
3984
+ },
3985
+ tracker
3986
+ );
3364
3987
  } catch (error) {
3365
3988
  await stop();
3366
3989
  if (cancelled) sink.cancel();
@@ -4623,6 +5246,8 @@ var CodexAgentAdapter = class {
4623
5246
  const isRootThread = (params) => !params?.threadId || params.threadId === rootThreadId;
4624
5247
  let goalStatus = request.run.goal ? "active" : void 0;
4625
5248
  let pendingWait;
5249
+ const finishWait = new BackgroundWaitFinish();
5250
+ sink.setFinishBackgroundWait?.(() => finishWait.request());
4626
5251
  let waitedMs = 0;
4627
5252
  let turnMessageText = "";
4628
5253
  let lastTurn;
@@ -4636,7 +5261,7 @@ var CodexAgentAdapter = class {
4636
5261
  if (!pendingWait) return;
4637
5262
  waitedMs += pendingWait.elapsedMs();
4638
5263
  pendingWait.clear();
4639
- pendingWait = void 0;
5264
+ pendingWait = finishWait.watch(void 0);
4640
5265
  emitGoalWaiting(false);
4641
5266
  };
4642
5267
  const completion = new Promise((resolve, reject) => {
@@ -4807,7 +5432,7 @@ var CodexAgentAdapter = class {
4807
5432
  }
4808
5433
  debugCodex("\u2605 turn ended with an active goal; waiting for native continuation");
4809
5434
  endWait();
4810
- pendingWait = new BackgroundWait(BACKGROUND_TASK_GRACE_MS, Math.max(0, timeoutMs - waitedMs));
5435
+ pendingWait = finishWait.watch(new BackgroundWait(BACKGROUND_TASK_GRACE_MS, Math.max(0, timeoutMs - waitedMs)));
4811
5436
  pendingWait.setIdle(true);
4812
5437
  emitGoalWaiting(true);
4813
5438
  }
@@ -5665,6 +6290,8 @@ var OpenCodeAgentAdapter = class {
5665
6290
  let pendingWait;
5666
6291
  let waitedMs = 0;
5667
6292
  let expiry;
6293
+ const finishWait = new BackgroundWaitFinish();
6294
+ sink.setFinishBackgroundWait?.(() => finishWait.request());
5668
6295
  let sawParentIdle = false;
5669
6296
  let lastTasksKey = JSON.stringify({ tasks: [], waiting: false });
5670
6297
  const emitTasks = (tasks, waiting) => {
@@ -5683,7 +6310,7 @@ var OpenCodeAgentAdapter = class {
5683
6310
  if (!pendingWait) return;
5684
6311
  waitedMs += pendingWait.elapsedMs();
5685
6312
  pendingWait.clear();
5686
- pendingWait = void 0;
6313
+ pendingWait = finishWait.watch(void 0);
5687
6314
  };
5688
6315
  const onChildrenChanged = () => {
5689
6316
  if (!pendingWait) return;
@@ -5751,6 +6378,7 @@ var OpenCodeAgentAdapter = class {
5751
6378
  expiry = reason;
5752
6379
  resolveSessionTerminal();
5753
6380
  });
6381
+ finishWait.watch(wait);
5754
6382
  emitTasks(live, true);
5755
6383
  };
5756
6384
  const onParentBusy = () => {
@@ -6224,7 +6852,7 @@ var OpenCodeAgentAdapter = class {
6224
6852
  }
6225
6853
  sseAbort.abort();
6226
6854
  await sseTask;
6227
- if (expiry === "ceiling" || (sessionErrorFromSse || dispatchError) && liveChildren().length > 0) {
6855
+ if (expiry === "ceiling" || expiry === "finished" || (sessionErrorFromSse || dispatchError) && liveChildren().length > 0) {
6228
6856
  debugOpencode(
6229
6857
  "\u2605 run over (%s) with %d subagent(s) live; aborting them",
6230
6858
  expiry ?? "failure",
@@ -6457,6 +7085,8 @@ var AgentRunController = class {
6457
7085
  sessionIdReady;
6458
7086
  abortHandler = async () => void 0;
6459
7087
  abortRequested = false;
7088
+ finishBackgroundWaitHandler;
7089
+ finishBackgroundWaitRequested = false;
6460
7090
  eventQueue = new AsyncQueue();
6461
7091
  rawQueue = new AsyncQueue();
6462
7092
  events = [];
@@ -6498,6 +7128,12 @@ var AgentRunController = class {
6498
7128
  void abort();
6499
7129
  }
6500
7130
  }
7131
+ setFinishBackgroundWait(finish) {
7132
+ this.finishBackgroundWaitHandler = finish;
7133
+ if (this.finishBackgroundWaitRequested) {
7134
+ finish();
7135
+ }
7136
+ }
6501
7137
  setSessionId(sessionId) {
6502
7138
  if (this.sessionId) {
6503
7139
  return;
@@ -6614,6 +7250,7 @@ var AgentRunController = class {
6614
7250
  return;
6615
7251
  }
6616
7252
  this.settled = true;
7253
+ const nothingParked = result?.nothingParked === true;
6617
7254
  this.clearPendingPermissions(
6618
7255
  new Error(
6619
7256
  "Agent run completed before pending permission requests resolved."
@@ -6639,7 +7276,8 @@ var AgentRunController = class {
6639
7276
  events: [...this.events],
6640
7277
  costData: this.costData,
6641
7278
  isCancelled: false,
6642
- error: errorMsg
7279
+ error: errorMsg,
7280
+ ...nothingParked ? { nothingParked: true } : {}
6643
7281
  });
6644
7282
  return;
6645
7283
  }
@@ -6651,7 +7289,8 @@ var AgentRunController = class {
6651
7289
  rawEvents: [...this.rawEventsList],
6652
7290
  events: [...this.events],
6653
7291
  costData: this.costData,
6654
- isCancelled: false
7292
+ isCancelled: false,
7293
+ ...nothingParked ? { nothingParked: true } : {}
6655
7294
  });
6656
7295
  }
6657
7296
  cancel(result) {
@@ -6735,6 +7374,10 @@ var AgentRunController = class {
6735
7374
  this.clearPendingPermissions(new Error("Agent run cancelled"));
6736
7375
  await this.abortHandler();
6737
7376
  }
7377
+ async finishBackgroundWait() {
7378
+ this.finishBackgroundWaitRequested = true;
7379
+ this.finishBackgroundWaitHandler?.();
7380
+ }
6738
7381
  rawEvents() {
6739
7382
  return this.rawQueue;
6740
7383
  }
@@ -6848,6 +7491,14 @@ var Agent = class {
6848
7491
  if (runConfig.forkAtMessageId && !runConfig.forkSessionId) {
6849
7492
  throw new Error("AgentRunConfig.forkAtMessageId requires forkSessionId.");
6850
7493
  }
7494
+ if (runConfig.resumeParked) {
7495
+ if (this.provider !== AgentProvider.ClaudeCode || !this.options.sandbox) {
7496
+ throw new Error("AgentRunConfig.resumeParked is only supported for claude-code sandbox runs.");
7497
+ }
7498
+ if (!runConfig.resumeSessionId) {
7499
+ throw new Error("AgentRunConfig.resumeParked requires resumeSessionId.");
7500
+ }
7501
+ }
6851
7502
  const runId = runConfig.runId ?? randomUUID3();
6852
7503
  const streamCalledAt = Date.now();
6853
7504
  debugAgent("stream() provider=%s runId=%s", this.provider, runId);