agentbox-sdk 0.1.513 → 0.1.515

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.
@@ -1857,23 +1857,44 @@ 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;
1865
+ hasTaskSnapshot = false;
1862
1866
  liveTasks() {
1863
- return [...this.tasks.values(), ...this.wakeups.values()];
1867
+ return [
1868
+ ...[...this.tasks.values()].filter(({ ambient }) => !ambient).map(({ task }) => task),
1869
+ ...this.wakeups.values()
1870
+ ];
1864
1871
  }
1865
1872
  /**
1866
1873
  * True once any background task or scheduled wakeup was live in this run.
1867
1874
  * The CLI queues a wake-up for every task that finishes and delivers it as
1868
1875
  * a new turn once the model is idle — including tasks that finished
1869
1876
  * mid-turn, whose queued turn starts right after that turn's `result`
1870
- * with nothing live and nothing observable in between. After background
1871
- * work has been seen, a `result` is therefore never the end of the run on
1872
- * its own; only the grace passing without a new turn is.
1877
+ * with nothing live in between. After background work has been seen, wait
1878
+ * for session_state_changed/idle with an empty live set. Older CLIs that
1879
+ * never emit session state use an idle grace as a compatibility fallback.
1873
1880
  */
1874
1881
  hasSeenBackgroundWork() {
1875
1882
  return this.seenBackgroundWork;
1876
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
+ }
1877
1898
  /** Feed one SDKMessage. Returns true when it started a follow-up turn. */
1878
1899
  ingest(message) {
1879
1900
  const m = asRecord2(message);
@@ -1911,6 +1932,8 @@ var BackgroundTaskTracker = class {
1911
1932
  const id = String(m.task_id ?? "");
1912
1933
  switch (m.subtype) {
1913
1934
  case "background_tasks_changed":
1935
+ this.hasTaskSnapshot = true;
1936
+ this.foreground.clear();
1914
1937
  this.tasks.clear();
1915
1938
  for (const entry of asArray(m.tasks)) {
1916
1939
  const task = asRecord2(entry);
@@ -1918,15 +1941,31 @@ var BackgroundTaskTracker = class {
1918
1941
  }
1919
1942
  return false;
1920
1943
  case "task_started":
1921
- if (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);
1922
1947
  return false;
1923
1948
  case "task_notification":
1924
- this.tasks.delete(id);
1949
+ if (!this.hasTaskSnapshot) {
1950
+ this.tasks.delete(id);
1951
+ this.foreground.delete(id);
1952
+ }
1925
1953
  return false;
1926
- case "task_updated":
1927
- if (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))) {
1928
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
+ }
1929
1967
  return false;
1968
+ }
1930
1969
  case "init":
1931
1970
  return this.startTurn();
1932
1971
  default:
@@ -1936,11 +1975,15 @@ var BackgroundTaskTracker = class {
1936
1975
  addTask(task) {
1937
1976
  const id = String(task.task_id ?? "");
1938
1977
  if (!id) return;
1939
- this.seenBackgroundWork = true;
1978
+ const ambient = task.ambient === true || task.skip_transcript === true;
1979
+ if (!ambient) this.seenBackgroundWork = true;
1940
1980
  this.tasks.set(id, {
1941
- id,
1942
- type: String(task.task_type ?? "task"),
1943
- description: String(task.description ?? "")
1981
+ ambient,
1982
+ task: {
1983
+ id,
1984
+ type: String(task.task_type ?? "task"),
1985
+ description: String(task.description ?? "")
1986
+ }
1944
1987
  });
1945
1988
  }
1946
1989
  ingestToolUses(m) {
@@ -2011,6 +2054,10 @@ var BackgroundWait = class {
2011
2054
  }
2012
2055
  this.grace ??= setTimeout(() => this.expire("grace"), this.graceMs);
2013
2056
  }
2057
+ /** End the wait now on the host's request ({@link BackgroundWaitFinish}). */
2058
+ finish() {
2059
+ this.expire("finished");
2060
+ }
2014
2061
  elapsedMs() {
2015
2062
  return Date.now() - this.startedAt;
2016
2063
  }
@@ -2019,9 +2066,23 @@ var BackgroundWait = class {
2019
2066
  clearTimeout(this.ceiling);
2020
2067
  }
2021
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
+ };
2022
2083
 
2023
2084
  // src/agents/providers/claude-code.ts
2024
- var DAEMON_PROTOCOL_VERSION = "5";
2085
+ var DAEMON_PROTOCOL_VERSION = "10";
2025
2086
  var DAEMON_PORT = 43180;
2026
2087
  var DAEMON_PATH = "/tmp/agentbox/claude-code/daemon.mjs";
2027
2088
  var DAEMON_LOG_PATH = "/tmp/agentbox/claude-code/daemon.log";
@@ -2031,7 +2092,11 @@ var DAEMON_READY_TIMEOUT_MS = 3e4;
2031
2092
  var DAEMON_READY_POLL_INTERVAL_MS = 250;
2032
2093
  function claudeConfigDir(options) {
2033
2094
  return path8.join(
2034
- agentboxRoot(AgentProvider.ClaudeCode, Boolean(options.sandbox), options.stateDirectory),
2095
+ agentboxRoot(
2096
+ AgentProvider.ClaudeCode,
2097
+ Boolean(options.sandbox),
2098
+ options.stateDirectory
2099
+ ),
2035
2100
  ".claude"
2036
2101
  );
2037
2102
  }
@@ -2052,7 +2117,9 @@ function buildClaudeQueryOptions(params) {
2052
2117
  }
2053
2118
  const includeHookEvents = provider?.includeHookEvents ?? false;
2054
2119
  if (run.reasoning === "max" || run.reasoning === "ultra") {
2055
- 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
+ );
2056
2123
  }
2057
2124
  const effort = provider?.ultracode ? "xhigh" : run.reasoning;
2058
2125
  return {
@@ -2063,7 +2130,10 @@ function buildClaudeQueryOptions(params) {
2063
2130
  ...params.request.options.configuration === "native" && provider?.fastMode !== void 0 ? { settings: { fastMode: provider.fastMode } } : {},
2064
2131
  ...params.request.options.configuration === "native" ? {
2065
2132
  settingSources: ["user", "project", "local"],
2066
- systemPrompt: { type: "preset", preset: "claude_code" }
2133
+ systemPrompt: {
2134
+ type: "preset",
2135
+ preset: "claude_code"
2136
+ }
2067
2137
  } : {},
2068
2138
  extraArgs,
2069
2139
  includePartialMessages: true,
@@ -2130,7 +2200,7 @@ function createClaudeCodeDaemonScript() {
2130
2200
  return `import http from "node:http";
2131
2201
  import { execSync } from "node:child_process";
2132
2202
  import { existsSync, readFileSync } from "node:fs";
2133
- import { timingSafeEqual } from "node:crypto";
2203
+ import { randomUUID, timingSafeEqual } from "node:crypto";
2134
2204
  import { query, getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
2135
2205
 
2136
2206
  const VERSION = ${version};
@@ -2155,6 +2225,199 @@ function isAuthorized(req) {
2155
2225
 
2156
2226
  const liveRuns = new Map();
2157
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
+
2158
2421
  // The SDK's default spawn does \`existsSync(pathToClaudeCodeExecutable)\`
2159
2422
  // before invoking child_process.spawn \u2014 that check fails on bare names
2160
2423
  // like "claude" because existsSync doesn't do PATH lookup. Resolve to an
@@ -2238,7 +2501,36 @@ async function handleStart(req, res, runId) {
2238
2501
  res.end(JSON.stringify({ error: String(e?.message ?? e) }));
2239
2502
  return;
2240
2503
  }
2241
- 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
+ }
2242
2534
  if (!prompt) {
2243
2535
  res.writeHead(400);
2244
2536
  res.end("missing prompt");
@@ -2248,70 +2540,63 @@ async function handleStart(req, res, runId) {
2248
2540
  const promptStream = createPromptStream();
2249
2541
  promptStream.push(prompt);
2250
2542
 
2251
- res.writeHead(200, {
2252
- "content-type": "application/x-ndjson",
2253
- "transfer-encoding": "chunked",
2254
- "x-daemon-version": VERSION,
2255
- });
2256
-
2257
- // Heartbeat: write a blank NDJSON line every 15s so HTTPS proxies
2258
- // (Modal/Daytona tunnels, intermediate LBs) don't idle-kill the
2259
- // stream during long blocking tool calls (e.g. TaskOutput block:true).
2260
- // The host's parseNdjsonStream skips empty lines, so this is a no-op
2261
- // for consumers but keeps the underlying TCP stream warm.
2262
- const heartbeat = setInterval(() => {
2263
- if (!res.writableEnded) res.write("\\n");
2264
- }, 15000);
2265
- if (typeof heartbeat.unref === "function") heartbeat.unref();
2543
+ res.writeHead(200, NDJSON_HEADERS);
2266
2544
 
2267
- const opts = { ...(options || {}) };
2268
- const autoApprove = !!opts.autoApproveTools;
2269
- delete opts.autoApproveTools;
2270
- const interactiveQuestions = !!opts.interactiveQuestions;
2271
- delete opts.interactiveQuestions;
2272
- let planning = opts.permissionMode === "plan";
2273
2545
  const permissions = new Map();
2274
- 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." });
2275
2553
  const canUseTool = async (toolName, input, context) => {
2276
2554
  const isQuestion = toolName === "AskUserQuestion";
2277
2555
  const isPlan = toolName === "ExitPlanMode";
2556
+ const mode = run.settings;
2278
2557
  if (context.signal.aborted) return { behavior: "deny", message: "Run cancelled", interrupt: true };
2279
- if ((isQuestion || isPlan) && !interactiveQuestions) return { behavior: "deny", message: "No interactive user is available." };
2280
- if (!isQuestion && !isPlan && planning && toolName !== "EnterPlanMode") return { behavior: "deny", message: "Finish planning before requesting write access." };
2281
- 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." };
2282
2564
  return new Promise((resolve) => {
2283
2565
  const abort = () => { permissions.delete(context.toolUseID); resolve({ behavior: "deny", message: "Run cancelled", interrupt: true }); };
2284
- 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); });
2285
2567
  context.signal.addEventListener("abort", abort, { once: true });
2286
- 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 } });
2287
2570
  });
2288
2571
  };
2289
2572
  opts.pathToClaudeCodeExecutable = resolveClaudeBinary(
2290
2573
  opts.pathToClaudeCodeExecutable,
2291
2574
  );
2292
2575
 
2293
- let queryHandle;
2294
- let clientGone = false;
2295
- // This run's own teardown. The liveRuns entry is only removed when it is
2296
- // still ours: hosts reuse a runId across retry attempts, and a successor
2297
- // registered while our CLI winds down must not be evicted by our exit.
2298
- const releaseRun = () => {
2299
- clearInterval(heartbeat);
2300
- clearPermissions();
2301
- if (liveRuns.get(runId)?.query === queryHandle) liveRuns.delete(runId);
2302
- 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,
2303
2598
  };
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. Detected on
2306
- // the response: \`req\` emits "close" as soon as its body is consumed (Node
2307
- // >= 16), long before any disconnect, while the response only closes
2308
- // early when the socket dies before the stream finished.
2309
- res.on("close", () => {
2310
- if (res.writableFinished) return;
2311
- clientGone = true;
2312
- releaseRun();
2313
- queryHandle?.interrupt().catch(() => {});
2314
- });
2599
+ attachSink(run, res);
2315
2600
 
2316
2601
  // Resume-if-exists gate. \`claude --resume <id>\` errors hard with "No
2317
2602
  // conversation found with session ID" when the local session jsonl is
@@ -2344,44 +2629,94 @@ async function handleStart(req, res, runId) {
2344
2629
  }
2345
2630
 
2346
2631
  // Nobody left to stream to: do not start a CLI for it.
2347
- if (clientGone) { res.end(); return; }
2632
+ if (run.clientGone) { res.end(); return; }
2348
2633
  try {
2349
- queryHandle = query({
2634
+ run.query = query({
2350
2635
  prompt: promptStream,
2351
2636
  options: {
2352
2637
  ...opts,
2353
2638
  canUseTool,
2354
2639
  hooks: { PreToolUse: [{ hooks: [async (input) => {
2355
- planning = input.permission_mode === "plan";
2356
- 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)
2357
2642
  ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask" } } : {};
2358
2643
  }] }] },
2359
2644
  },
2360
2645
  });
2361
2646
  } catch (e) {
2362
- clearInterval(heartbeat);
2647
+ clearInterval(run.heartbeat);
2363
2648
  res.write(JSON.stringify({ _error: String(e?.message ?? e) }) + "\\n");
2364
2649
  res.end();
2365
2650
  return;
2366
2651
  }
2367
2652
 
2368
- liveRuns.set(runId, { query: queryHandle, prompt: promptStream, permissions });
2653
+ liveRuns.set(runId, run);
2369
2654
 
2370
2655
  // Forward every SDKMessage, not just up to the first result: in
2371
2656
  // streaming-input mode the CLI keeps running after a turn ends and
2372
2657
  // re-prompts the model when background work finishes. The host decides
2373
- // when the run is over and disconnects (res "close" above), which ends
2374
- // 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.
2375
2661
  try {
2376
- for await (const message of queryHandle) {
2377
- res.write(JSON.stringify(message) + "\\n");
2662
+ for await (const message of run.query) {
2663
+ observeFrame(run, message);
2664
+ writeFrame(run, message);
2378
2665
  }
2379
2666
  } catch (e) {
2380
- res.write(JSON.stringify({ _error: String(e?.message ?? e) }) + "\\n");
2667
+ writeFrame(run, { _error: String(e?.message ?? e) });
2381
2668
  } finally {
2382
- releaseRun();
2383
- 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;
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();
2384
2716
  }
2717
+ run.parked = parked;
2718
+ res.writeHead(200, { "content-type": "application/json" });
2719
+ res.end(JSON.stringify({ parked: true }));
2385
2720
  }
2386
2721
 
2387
2722
  async function handleSendMessage(req, res, runId) {
@@ -2414,6 +2749,14 @@ async function handleAbort(_req, res, runId) {
2414
2749
  res.end();
2415
2750
  return;
2416
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
+ }
2417
2760
  await run.query.interrupt().catch(() => {});
2418
2761
  res.writeHead(204);
2419
2762
  res.end();
@@ -2422,9 +2765,9 @@ async function handleAbort(_req, res, runId) {
2422
2765
  async function handleDelete(_req, res, runId) {
2423
2766
  const run = liveRuns.get(runId);
2424
2767
  if (run) {
2425
- liveRuns.delete(runId);
2426
- run.prompt.end();
2427
- 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(() => {});
2428
2771
  }
2429
2772
  res.writeHead(204);
2430
2773
  res.end();
@@ -2463,6 +2806,10 @@ const server = http.createServer((req, res) => {
2463
2806
  handleAbort(req, res, decodeURIComponent(m[1]));
2464
2807
  return;
2465
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
+ }
2466
2813
  if (req.method === "DELETE" && (m = url.match(/^\\/runs\\/([^/]+)$/))) {
2467
2814
  handleDelete(req, res, decodeURIComponent(m[1]));
2468
2815
  return;
@@ -2715,7 +3062,16 @@ var ClaudeCodeAgentAdapter = class {
2715
3062
  };
2716
3063
  const mcpConfigJson = buildClaudeMcpConfig(options.mcps) ?? JSON.stringify({ mcpServers: {} }, null, 2);
2717
3064
  const artifacts = [
2718
- ...!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
+ ] : [],
2719
3075
  ...skillArtifacts,
2720
3076
  ...buildClaudeCommandArtifacts(options.commands, target.layout),
2721
3077
  ...buildClaudeSubagentArtifacts(options.subAgents, target.layout),
@@ -2774,13 +3130,15 @@ var ClaudeCodeAgentAdapter = class {
2774
3130
  IS_SANDBOX: "1"
2775
3131
  };
2776
3132
  applyCliBackgroundWaitCeiling(env);
3133
+ env.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS ??= "1";
2777
3134
  const customHeaders = request.options.customHeaders;
2778
3135
  if (customHeaders && Object.keys(customHeaders).length > 0) {
2779
3136
  const serialized = Object.entries(customHeaders).map(([name, value]) => `${name}: ${value}`).join("\n");
2780
3137
  env.ANTHROPIC_CUSTOM_HEADERS = env.ANTHROPIC_CUSTOM_HEADERS ? `${env.ANTHROPIC_CUSTOM_HEADERS}
2781
3138
  ${serialized}` : serialized;
2782
3139
  }
2783
- const inputParts = await time(
3140
+ const attachOnly = request.run.resumeParked === true;
3141
+ const inputParts = attachOnly ? [] : await time(
2784
3142
  debugClaude,
2785
3143
  "validateProviderUserInput",
2786
3144
  () => validateProviderUserInput(request.provider, request.run.input)
@@ -2804,13 +3162,15 @@ ${serialized}` : serialized;
2804
3162
  });
2805
3163
  const autoApproveTools = shouldAutoApproveClaudeTools(request.options);
2806
3164
  const requestBody = {
2807
- prompt: {
2808
- type: "user",
2809
- message: {
2810
- role: "user",
2811
- content: userContent
2812
- },
2813
- 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
+ }
2814
3174
  },
2815
3175
  options: {
2816
3176
  ...sdkOptions,
@@ -2826,11 +3186,18 @@ ${serialized}` : serialized;
2826
3186
  const runUrl = `${baseUrl}/runs/${encodeURIComponent(request.runId)}`;
2827
3187
  const cleanup = async () => {
2828
3188
  try {
2829
- await fetch(`${runUrl}/abort`, { method: "POST", headers: authHeaders });
3189
+ await fetch(`${runUrl}/abort`, {
3190
+ method: "POST",
3191
+ headers: authHeaders
3192
+ });
2830
3193
  } catch {
2831
3194
  }
2832
3195
  try {
2833
- 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
+ });
2834
3201
  } catch {
2835
3202
  }
2836
3203
  fetchAbort.abort();
@@ -2890,6 +3257,7 @@ ${serialized}` : serialized;
2890
3257
  { messageId: initialUuid }
2891
3258
  )
2892
3259
  );
3260
+ const tracker = new BackgroundTaskTracker();
2893
3261
  const permissionMessages = async function* () {
2894
3262
  for await (const item of parseNdjsonStream(response.body)) {
2895
3263
  const control = item;
@@ -2900,12 +3268,84 @@ ${serialized}` : serialized;
2900
3268
  const ask = control._permission;
2901
3269
  const isQuestion = ask.toolName === "AskUserQuestion";
2902
3270
  const isPlan = ask.toolName === "ExitPlanMode";
2903
- 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) } : {} });
2904
- 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." } }) });
2905
- 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}`);
2906
3310
  }
2907
3311
  };
2908
- await consumeClaudeMessages(request, sink, permissionMessages(), executeStartedAt, cleanup, () => cancelled);
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
+ );
2909
3349
  return async () => void 0;
2910
3350
  }
2911
3351
  /**
@@ -2966,38 +3406,73 @@ ${serialized}` : serialized;
2966
3406
  }
2967
3407
  }
2968
3408
  };
2969
- async function consumeClaudeMessages(request, sink, messages, executeStartedAt, cleanup, wasCancelled = () => false, wait = {}) {
3409
+ async function consumeClaudeMessages(request, sink, messages, executeStartedAt, cleanup, wasCancelled = () => false, wait = {}, tracker = new BackgroundTaskTracker()) {
2970
3410
  let accumulatedText = "";
2971
3411
  let streamedThinkingChars = 0;
2972
3412
  let sawResult = false;
3413
+ let sawSessionState = false;
2973
3414
  let firstStreamEventLogged = false;
2974
3415
  let firstTextDeltaLogged = false;
2975
3416
  let lastTerminalReason;
2976
3417
  let lastIsError = false;
2977
3418
  const rawPayloads = [];
2978
- const tracker = new BackgroundTaskTracker();
2979
- const timeoutMs = resolveBackgroundTaskTimeoutMs(request.options.backgroundTaskTimeoutMs);
3419
+ const timeoutMs = resolveBackgroundTaskTimeoutMs(
3420
+ request.options.backgroundTaskTimeoutMs
3421
+ );
2980
3422
  const graceMs = wait.graceMs ?? BACKGROUND_TASK_GRACE_MS;
2981
3423
  let pendingWait;
2982
3424
  let waitedMs = 0;
2983
3425
  let expiry;
2984
- let lastTasksKey = JSON.stringify({ tasks: [], waiting: false });
2985
- const emitTasks = (tasks, waiting) => {
2986
- 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 });
2987
3439
  if (key === lastTasksKey) return;
2988
3440
  lastTasksKey = key;
2989
- 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;
2990
3462
  };
2991
3463
  const isAborted = () => wasCancelled() || lastTerminalReason === "aborted_streaming" || lastTerminalReason === "aborted_tools";
2992
3464
  const endWait = () => {
2993
3465
  if (!pendingWait) return;
2994
3466
  waitedMs += pendingWait.elapsedMs();
2995
3467
  pendingWait.clear();
2996
- pendingWait = void 0;
3468
+ pendingWait = finishWait.watch(void 0);
2997
3469
  };
2998
3470
  const settleOnFailure = (error) => {
2999
3471
  if (!pendingWait || !sawResult || lastIsError) return false;
3000
- 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
+ );
3001
3476
  expiry = "transport";
3002
3477
  return true;
3003
3478
  };
@@ -3015,7 +3490,8 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3015
3490
  break;
3016
3491
  }
3017
3492
  if ("reason" in step) {
3018
- expiry = step.reason;
3493
+ if (!(step.reason === "finished" && await tryPark()))
3494
+ expiry = step.reason;
3019
3495
  break;
3020
3496
  }
3021
3497
  if (step.result.done) break;
@@ -3027,7 +3503,28 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3027
3503
  if (!settleOnFailure(error)) throw error;
3028
3504
  break;
3029
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
+ }
3030
3525
  if ("_notice" in ctrl) {
3526
+ if (ctrl._notice === "no_parked_run" || ctrl._notice === "no_parked_turn")
3527
+ nothingParked = true;
3031
3528
  debugClaude("daemon notice: %o", ctrl);
3032
3529
  sink.emitRaw(
3033
3530
  toRawEvent(
@@ -3043,18 +3540,31 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3043
3540
  rawPayloads.push(message);
3044
3541
  sink.emitRaw(toRawEvent(request.runId, message, message.type));
3045
3542
  if (tracker.ingest(message) && pendingWait) {
3046
- 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
+ );
3047
3547
  endWait();
3048
3548
  }
3049
3549
  emitTasks(tracker.liveTasks(), pendingWait !== void 0);
3050
- pendingWait?.setIdle(tracker.liveTasks().length === 0);
3550
+ const sessionState = message.type === "system" && message.subtype === "session_state_changed" ? message.state : void 0;
3551
+ if (sessionState) sawSessionState = true;
3552
+ pendingWait?.setIdle(
3553
+ !sawSessionState && tracker.liveTasks().length === 0
3554
+ );
3555
+ if (sessionState === "idle" && pendingWait && (tracker.liveTasks().length === 0 || await tryPark()))
3556
+ break;
3051
3557
  if (message.type === "system") {
3052
3558
  const sub = message.subtype;
3053
3559
  if (sub === "init") {
3054
3560
  const sys = message;
3055
- 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
+ )) {
3056
3564
  await cleanup();
3057
- 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
+ );
3058
3568
  }
3059
3569
  if (sys.session_id) {
3060
3570
  debugClaude(
@@ -3156,6 +3666,12 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3156
3666
  continue;
3157
3667
  }
3158
3668
  if (message.type === "result") {
3669
+ if (foreignResults > 0) {
3670
+ foreignResults--;
3671
+ accumulatedText = "";
3672
+ streamedThinkingChars = 0;
3673
+ continue;
3674
+ }
3159
3675
  sawResult = true;
3160
3676
  const result = message;
3161
3677
  lastTerminalReason = result.terminal_reason;
@@ -3165,30 +3681,55 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3165
3681
  accumulatedText = resultText;
3166
3682
  }
3167
3683
  const live = tracker.liveTasks();
3168
- if (timeoutMs === 0 || !tracker.hasSeenBackgroundWork() || isAborted()) break;
3169
- 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
+ );
3170
3690
  endWait();
3171
- pendingWait = new BackgroundWait(graceMs, Math.max(0, timeoutMs - waitedMs));
3172
- pendingWait.setIdle(live.length === 0);
3691
+ pendingWait = finishWait.watch(
3692
+ new BackgroundWait(graceMs, Math.max(0, timeoutMs - waitedMs))
3693
+ );
3694
+ pendingWait.setIdle(!sawSessionState && live.length === 0);
3173
3695
  emitTasks(live, true);
3174
3696
  continue;
3175
3697
  }
3176
3698
  }
3177
- if (expiry === "ceiling") {
3699
+ if (expiry === "ceiling" || expiry === "finished") {
3178
3700
  const ids = tracker.liveTasks().filter((task) => task.type !== "scheduled_wakeup").map((task) => task.id);
3179
- 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
+ );
3180
3712
  if (wait.stopTasks) {
3181
- 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);
3182
3717
  }
3183
3718
  } else if (expiry === "grace") {
3184
3719
  debugClaude("\u2605 background set emptied with no follow-up turn; settling");
3185
3720
  }
3186
3721
  if (pendingWait) {
3187
3722
  endWait();
3188
- emitTasks([], false);
3189
- }
3190
- await cleanup();
3191
- 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");
3192
3733
  const finalText = accumulatedText;
3193
3734
  const isCancelled = isAborted();
3194
3735
  const isError = !isCancelled && lastIsError;
@@ -3228,21 +3769,29 @@ async function consumeClaudeMessages(request, sink, messages, executeStartedAt,
3228
3769
  );
3229
3770
  sink.complete({
3230
3771
  text: finalText,
3231
- 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 } : {}
3232
3777
  });
3233
3778
  }
3234
3779
  } finally {
3235
3780
  pendingWait?.clear();
3236
- await cleanup();
3781
+ await leave();
3237
3782
  }
3238
3783
  }
3239
3784
  async function executeNativeClaude(request, sink, wait = {}) {
3240
3785
  const { query } = await import("@anthropic-ai/claude-agent-sdk");
3241
3786
  const claudeDir = claudeConfigDir(request.options);
3242
- const input = await validateProviderUserInput(request.provider, request.run.input);
3787
+ const input = await validateProviderUserInput(
3788
+ request.provider,
3789
+ request.run.input
3790
+ );
3243
3791
  const prompt = new AsyncQueue();
3244
3792
  const sessionId = request.run.resumeSessionId ?? randomUUID();
3245
3793
  const controller = new AbortController();
3794
+ const tracker = new BackgroundTaskTracker();
3246
3795
  let handle;
3247
3796
  let processHandle;
3248
3797
  let stopped;
@@ -3259,12 +3808,30 @@ async function executeNativeClaude(request, sink, wait = {}) {
3259
3808
  });
3260
3809
  sink.setSessionId(sessionId);
3261
3810
  const messageId = randomUUID();
3262
- prompt.push({ type: "user", uuid: messageId, message: { role: "user", content: mapToClaudeUserContent(input) }, parent_tool_use_id: null });
3263
- 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
+ );
3264
3827
  applyCliBackgroundWaitCeiling(hostEnv);
3828
+ hostEnv.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS ??= "1";
3265
3829
  if (request.options.customHeaders) {
3266
3830
  const headers = Object.entries(request.options.customHeaders).map(([name, value]) => `${name}: ${value}`).join("\n");
3267
- 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");
3268
3835
  }
3269
3836
  const options = buildClaudeQueryOptions({
3270
3837
  request,
@@ -3280,71 +3847,143 @@ async function executeNativeClaude(request, sink, wait = {}) {
3280
3847
  const interactiveQuestions = hasInteractiveQuestions(request.options);
3281
3848
  let planning = options.permissionMode === "plan";
3282
3849
  try {
3283
- handle = query({ prompt, options: {
3284
- ...options,
3285
- // Use the SDK-matched CLI by default; an installed CLI is an explicit override.
3286
- pathToClaudeCodeExecutable: request.options.provider?.binary,
3287
- abortController: controller,
3288
- // `sessionId` is rejected alongside `resume` unless `forkSession` is
3289
- // set, where it names the forked session. Stamping it on forks keeps
3290
- // the pre-minted id reported via `sink.setSessionId` truthful, so a
3291
- // later run can resume the fork.
3292
- ...request.run.resumeSessionId ? {} : { sessionId },
3293
- ...request.options.configuration === "native" ? {} : { plugins: [{ type: "local", path: claudeDir }] },
3294
- spawnClaudeCodeProcess(spawnOptions) {
3295
- if (cancelled || controller.signal.aborted) throw new Error("Local run was cancelled before startup");
3296
- processHandle = spawnCommand({ ...spawnOptions, processGroup: request.options.processGroup !== "inherited" });
3297
- return processHandle.child;
3298
- },
3299
- hooks: { ...options.hooks, PreToolUse: [...options.hooks?.PreToolUse ?? [], { hooks: [async (input2) => {
3300
- if (input2.hook_event_name !== "PreToolUse") return {};
3301
- planning = input2.permission_mode === "plan";
3302
- return interactiveQuestions && ["AskUserQuestion", "ExitPlanMode"].includes(input2.tool_name) ? { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "ask" } } : {};
3303
- }] }] },
3304
- async canUseTool(toolName, input2, context) {
3305
- if (cancelled || context.signal.aborted) return { behavior: "deny", message: "Run cancelled", interrupt: true };
3306
- const isQuestion = toolName === "AskUserQuestion";
3307
- const isPlan = toolName === "ExitPlanMode";
3308
- if ((isQuestion || isPlan) && !interactiveQuestions) return { behavior: "deny", message: "No interactive user is available." };
3309
- if (!isQuestion && !isPlan && planning && toolName !== "EnterPlanMode") return { behavior: "deny", message: "Finish planning before requesting write access." };
3310
- if (!isQuestion && !isPlan && autoApprove) return { behavior: "allow", updatedInput: input2 };
3311
- try {
3312
- const response = await sink.requestPermission({
3313
- type: "permission.requested",
3314
- provider: request.provider,
3315
- runId: request.runId,
3316
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3317
- requestId: context.toolUseID,
3318
- kind: isQuestion ? "question" : isPlan ? "plan" : "tool",
3319
- toolName,
3320
- ...isQuestion ? { questions: normalizeUserQuestions("claude-code", input2) } : {},
3321
- title: isQuestion ? "Your input is needed" : isPlan ? "Review the plan" : context.title ?? `Allow ${toolName}?`,
3322
- message: context.description ?? context.decisionReason,
3323
- input: input2,
3324
- 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"
3325
3869
  });
3326
- if (isPlan && response.decision === "allow") planning = false;
3327
- if (!cancelled && !context.signal.aborted && response.decision === "allow") return {
3328
- behavior: "allow",
3329
- updatedInput: isQuestion ? { ...input2, answers: questionReply("claude-code", input2, response.answers ?? []) } : input2
3330
- };
3331
- return { behavior: "deny", message: "The user denied this action" };
3332
- } catch {
3333
- 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
+ }
3334
3951
  }
3335
3952
  }
3336
- } });
3953
+ });
3337
3954
  const live = handle;
3338
3955
  sink.setRaw({ query: live, claudeDir, runId: request.runId });
3339
- sink.emitEvent(createNormalizedEvent("run.started", { provider: request.provider, runId: request.runId }));
3340
- sink.emitEvent(createNormalizedEvent("message.started", { provider: request.provider, runId: request.runId }, { messageId }));
3341
- await consumeClaudeMessages(request, sink, live, Date.now(), stop, () => cancelled, {
3342
- ...wait,
3343
- // Native owns the CLI: ask it to stop leftover tasks before closing it.
3344
- stopTasks: async (ids) => {
3345
- await Promise.all(ids.map((id) => live.stopTask(id).catch(() => void 0)));
3346
- }
3347
- });
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
+ );
3348
3987
  } catch (error) {
3349
3988
  await stop();
3350
3989
  if (cancelled) sink.cancel();
@@ -4607,6 +5246,8 @@ var CodexAgentAdapter = class {
4607
5246
  const isRootThread = (params) => !params?.threadId || params.threadId === rootThreadId;
4608
5247
  let goalStatus = request.run.goal ? "active" : void 0;
4609
5248
  let pendingWait;
5249
+ const finishWait = new BackgroundWaitFinish();
5250
+ sink.setFinishBackgroundWait?.(() => finishWait.request());
4610
5251
  let waitedMs = 0;
4611
5252
  let turnMessageText = "";
4612
5253
  let lastTurn;
@@ -4620,7 +5261,7 @@ var CodexAgentAdapter = class {
4620
5261
  if (!pendingWait) return;
4621
5262
  waitedMs += pendingWait.elapsedMs();
4622
5263
  pendingWait.clear();
4623
- pendingWait = void 0;
5264
+ pendingWait = finishWait.watch(void 0);
4624
5265
  emitGoalWaiting(false);
4625
5266
  };
4626
5267
  const completion = new Promise((resolve, reject) => {
@@ -4791,7 +5432,7 @@ var CodexAgentAdapter = class {
4791
5432
  }
4792
5433
  debugCodex("\u2605 turn ended with an active goal; waiting for native continuation");
4793
5434
  endWait();
4794
- 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)));
4795
5436
  pendingWait.setIdle(true);
4796
5437
  emitGoalWaiting(true);
4797
5438
  }
@@ -5649,6 +6290,8 @@ var OpenCodeAgentAdapter = class {
5649
6290
  let pendingWait;
5650
6291
  let waitedMs = 0;
5651
6292
  let expiry;
6293
+ const finishWait = new BackgroundWaitFinish();
6294
+ sink.setFinishBackgroundWait?.(() => finishWait.request());
5652
6295
  let sawParentIdle = false;
5653
6296
  let lastTasksKey = JSON.stringify({ tasks: [], waiting: false });
5654
6297
  const emitTasks = (tasks, waiting) => {
@@ -5667,7 +6310,7 @@ var OpenCodeAgentAdapter = class {
5667
6310
  if (!pendingWait) return;
5668
6311
  waitedMs += pendingWait.elapsedMs();
5669
6312
  pendingWait.clear();
5670
- pendingWait = void 0;
6313
+ pendingWait = finishWait.watch(void 0);
5671
6314
  };
5672
6315
  const onChildrenChanged = () => {
5673
6316
  if (!pendingWait) return;
@@ -5735,6 +6378,7 @@ var OpenCodeAgentAdapter = class {
5735
6378
  expiry = reason;
5736
6379
  resolveSessionTerminal();
5737
6380
  });
6381
+ finishWait.watch(wait);
5738
6382
  emitTasks(live, true);
5739
6383
  };
5740
6384
  const onParentBusy = () => {
@@ -6208,7 +6852,7 @@ var OpenCodeAgentAdapter = class {
6208
6852
  }
6209
6853
  sseAbort.abort();
6210
6854
  await sseTask;
6211
- if (expiry === "ceiling" || (sessionErrorFromSse || dispatchError) && liveChildren().length > 0) {
6855
+ if (expiry === "ceiling" || expiry === "finished" || (sessionErrorFromSse || dispatchError) && liveChildren().length > 0) {
6212
6856
  debugOpencode(
6213
6857
  "\u2605 run over (%s) with %d subagent(s) live; aborting them",
6214
6858
  expiry ?? "failure",
@@ -6441,6 +7085,8 @@ var AgentRunController = class {
6441
7085
  sessionIdReady;
6442
7086
  abortHandler = async () => void 0;
6443
7087
  abortRequested = false;
7088
+ finishBackgroundWaitHandler;
7089
+ finishBackgroundWaitRequested = false;
6444
7090
  eventQueue = new AsyncQueue();
6445
7091
  rawQueue = new AsyncQueue();
6446
7092
  events = [];
@@ -6482,6 +7128,12 @@ var AgentRunController = class {
6482
7128
  void abort();
6483
7129
  }
6484
7130
  }
7131
+ setFinishBackgroundWait(finish) {
7132
+ this.finishBackgroundWaitHandler = finish;
7133
+ if (this.finishBackgroundWaitRequested) {
7134
+ finish();
7135
+ }
7136
+ }
6485
7137
  setSessionId(sessionId) {
6486
7138
  if (this.sessionId) {
6487
7139
  return;
@@ -6598,6 +7250,7 @@ var AgentRunController = class {
6598
7250
  return;
6599
7251
  }
6600
7252
  this.settled = true;
7253
+ const nothingParked = result?.nothingParked === true;
6601
7254
  this.clearPendingPermissions(
6602
7255
  new Error(
6603
7256
  "Agent run completed before pending permission requests resolved."
@@ -6623,7 +7276,8 @@ var AgentRunController = class {
6623
7276
  events: [...this.events],
6624
7277
  costData: this.costData,
6625
7278
  isCancelled: false,
6626
- error: errorMsg
7279
+ error: errorMsg,
7280
+ ...nothingParked ? { nothingParked: true } : {}
6627
7281
  });
6628
7282
  return;
6629
7283
  }
@@ -6635,7 +7289,8 @@ var AgentRunController = class {
6635
7289
  rawEvents: [...this.rawEventsList],
6636
7290
  events: [...this.events],
6637
7291
  costData: this.costData,
6638
- isCancelled: false
7292
+ isCancelled: false,
7293
+ ...nothingParked ? { nothingParked: true } : {}
6639
7294
  });
6640
7295
  }
6641
7296
  cancel(result) {
@@ -6719,6 +7374,10 @@ var AgentRunController = class {
6719
7374
  this.clearPendingPermissions(new Error("Agent run cancelled"));
6720
7375
  await this.abortHandler();
6721
7376
  }
7377
+ async finishBackgroundWait() {
7378
+ this.finishBackgroundWaitRequested = true;
7379
+ this.finishBackgroundWaitHandler?.();
7380
+ }
6722
7381
  rawEvents() {
6723
7382
  return this.rawQueue;
6724
7383
  }
@@ -6832,6 +7491,14 @@ var Agent = class {
6832
7491
  if (runConfig.forkAtMessageId && !runConfig.forkSessionId) {
6833
7492
  throw new Error("AgentRunConfig.forkAtMessageId requires forkSessionId.");
6834
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
+ }
6835
7502
  const runId = runConfig.runId ?? randomUUID3();
6836
7503
  const streamCalledAt = Date.now();
6837
7504
  debugAgent("stream() provider=%s runId=%s", this.provider, runId);