@dreb/coding-agent 2.72.0 → 2.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, } from "node:fs";
3
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, statSync, writeFileSync, } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, join, resolve } from "node:path";
6
6
  import { completeSimple } from "@dreb/ai";
@@ -238,6 +238,13 @@ export function handleChildJsonlLine(line, sinks) {
238
238
  sinks.onProgress(`${sinks.toolNameRef.current} done`);
239
239
  }
240
240
  }
241
+ /**
242
+ * How long a gracefully aborted (RPC `abort`) child may take to finish its run and
243
+ * emit `agent_end` before it is terminated with SIGTERM.
244
+ */
245
+ export const SUBAGENT_ABORT_GRACE_MS = 5000;
246
+ /** Delay between SIGTERM and the SIGKILL backstop for a child that is still alive. */
247
+ export const SUBAGENT_SIGKILL_BACKSTOP_MS = 5000;
241
248
  async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentProvider, sessionDir, parentSessionFile, onChildEvent, thinkingOverride, onControlAvailable) {
242
249
  const drebBin = findDrebBinary();
243
250
  log.debug(`[subagent] spawn: agent=${agentConfig.name} cwd=${cwd}`);
@@ -325,6 +332,7 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
325
332
  }
326
333
  let settled = false;
327
334
  let killTimer = null;
335
+ let abortGraceTimer = null;
328
336
  const collectedMessages = [];
329
337
  const stderrChunks = [];
330
338
  let stderrSize = 0;
@@ -335,6 +343,8 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
335
343
  let resolvedThinking;
336
344
  let rpcRequestId = 0;
337
345
  let rpcCompleted = false;
346
+ /** Set once a graceful RPC `abort` has been sent for a user stop. */
347
+ let gracefulAbortRequested = false;
338
348
  let controlError;
339
349
  const pendingRpc = new Map();
340
350
  const sendRpc = (command) => {
@@ -353,16 +363,11 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
353
363
  getState: async () => sendRpc({ type: "get_state" }),
354
364
  }
355
365
  : undefined;
356
- // Terminally fail a controlled child: remember the first failure, reject all
357
- // in-flight control requests, and stop the process (with the same SIGKILL
358
- // backstop as the abort path) so the close handler settles instead of the
359
- // child idling in RPC mode forever.
360
- const failControlledChild = (err) => {
361
- if (!controlError)
362
- controlError = err;
363
- for (const request of pendingRpc.values())
364
- request.reject(err);
365
- pendingRpc.clear();
366
+ // Escalating termination: SIGTERM now, SIGKILL if the process is still alive
367
+ // after the backstop delay. Liveness is judged by exitCode/signalCode —
368
+ // `proc.killed` only reports that a signal was *sent*, so it is already true
369
+ // after the SIGTERM and cannot gate the SIGKILL escalation.
370
+ const forceKill = () => {
366
371
  try {
367
372
  proc.kill("SIGTERM");
368
373
  }
@@ -370,14 +375,41 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
370
375
  /* process already exited */
371
376
  }
372
377
  killTimer ??= setTimeout(() => {
378
+ if (proc.exitCode !== null || proc.signalCode !== null)
379
+ return;
373
380
  try {
374
- if (!proc.killed)
375
- proc.kill("SIGKILL");
381
+ proc.kill("SIGKILL");
376
382
  }
377
383
  catch {
378
384
  /* process already exited */
379
385
  }
380
- }, 5000);
386
+ }, SUBAGENT_SIGKILL_BACKSTOP_MS);
387
+ };
388
+ // Terminally fail a controlled child: remember the first failure, reject all
389
+ // in-flight control requests, and stop the process (with the same SIGKILL
390
+ // backstop as the abort path) so the close handler settles instead of the
391
+ // child idling in RPC mode forever.
392
+ const failControlledChild = (err) => {
393
+ if (!controlError)
394
+ controlError = err;
395
+ for (const request of pendingRpc.values())
396
+ request.reject(err);
397
+ pendingRpc.clear();
398
+ forceKill();
399
+ };
400
+ // Ask a controlled child to abort its own run (graceful stop). If the request cannot
401
+ // be delivered or the child refuses it, terminate the child instead.
402
+ const requestGracefulAbort = () => {
403
+ if (!proc.stdin?.writable) {
404
+ forceKill();
405
+ return;
406
+ }
407
+ sendRpc({ type: "abort" }).catch((err) => {
408
+ if (settled)
409
+ return;
410
+ log.warn(`[subagent] graceful abort failed (agent=${agentConfig.name}): ${err instanceof Error ? err.message : String(err)}; terminating child`);
411
+ forceKill();
412
+ });
381
413
  };
382
414
  proc.stdin?.on("error", (err) => {
383
415
  log.warn(`[subagent] stdin stream error (agent=${agentConfig.name}): ${err.message}`);
@@ -405,7 +437,17 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
405
437
  const message = JSON.parse(line);
406
438
  if (message?.type === "agent_end" && !rpcCompleted) {
407
439
  rpcCompleted = true;
408
- setImmediate(() => proc.kill("SIGTERM"));
440
+ setImmediate(forceKill);
441
+ }
442
+ if (message?.type === "agent_start" && gracefulAbortRequested && !rpcCompleted) {
443
+ // The child handles commands concurrently. An abort that arrived while
444
+ // its initial prompt was still in its pre-run preamble (API-key
445
+ // resolution, input/before-start hooks) was acknowledged without
446
+ // aborting anything, because no run existed yet. Abort the run that
447
+ // has now started, before it can execute tools the user just stopped.
448
+ // Otherwise it would keep working until the grace period expires, and
449
+ // SIGTERM would orphan any tool process tree it had started.
450
+ requestGracefulAbort();
409
451
  }
410
452
  if (message?.type === "response" && typeof message.id === "string") {
411
453
  const pending = pendingRpc.get(message.id);
@@ -455,23 +497,22 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
455
497
  failControlledChild(error);
456
498
  });
457
499
  }
458
- // Handle abort signal (guard kill() against ESRCH race if process already exited)
500
+ // Handle abort signal (user stop / stop-all). A controlled child is stopped
501
+ // gracefully first: the RPC `abort` makes the child abort its own run, so its
502
+ // tools unwind — notably the bash tool kills its detached process group on its
503
+ // abort signal, which a bare SIGTERM to this child would orphan. The child's
504
+ // resulting agent_end triggers the normal SIGTERM above; a run that starts only
505
+ // after the abort was handled is aborted again on its agent_start. If the abort
506
+ // cannot be delivered or the child does not finish within the grace period,
507
+ // escalate to SIGTERM and then the SIGKILL backstop.
459
508
  const onAbort = () => {
460
- try {
461
- proc.kill("SIGTERM");
462
- }
463
- catch {
464
- /* process already exited */
509
+ if (!controlClient || rpcCompleted || controlError || !proc.stdin?.writable) {
510
+ forceKill();
511
+ return;
465
512
  }
466
- killTimer = setTimeout(() => {
467
- try {
468
- if (!proc.killed)
469
- proc.kill("SIGKILL");
470
- }
471
- catch {
472
- /* process already exited */
473
- }
474
- }, 5000);
513
+ gracefulAbortRequested = true;
514
+ abortGraceTimer ??= setTimeout(forceKill, SUBAGENT_ABORT_GRACE_MS);
515
+ requestGracefulAbort();
475
516
  };
476
517
  signal?.addEventListener("abort", onAbort, { once: true });
477
518
  proc.on("error", (err) => {
@@ -484,6 +525,8 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
484
525
  pendingRpc.clear();
485
526
  if (killTimer)
486
527
  clearTimeout(killTimer);
528
+ if (abortGraceTimer)
529
+ clearTimeout(abortGraceTimer);
487
530
  signal?.removeEventListener("abort", onAbort);
488
531
  rejectPromise(new Error(`Subagent process error: ${err.message}`));
489
532
  });
@@ -497,6 +540,8 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
497
540
  pendingRpc.clear();
498
541
  if (killTimer)
499
542
  clearTimeout(killTimer);
543
+ if (abortGraceTimer)
544
+ clearTimeout(abortGraceTimer);
500
545
  signal?.removeEventListener("abort", onAbort);
501
546
  const exitCode = rpcCompleted ? 0 : (code ?? 1);
502
547
  const stderr = stderrChunks.join("");
@@ -928,10 +973,6 @@ export function formatModelFallbackSummary(skippedModels, proposalModel, finalMo
928
973
  : `using "${finalModel ?? proposalModel ?? "unknown"}".`;
929
974
  return `[MODEL FALLBACK: skipped ${skippedModels.length} unavailable model(s); ${routeSummary}]\n${skipped}`;
930
975
  }
931
- export function prependModelFallbackSummary(output, skippedModels, proposalModel, finalModel) {
932
- const fallbackSummary = formatModelFallbackSummary(skippedModels, proposalModel, finalModel);
933
- return fallbackSummary ? `${fallbackSummary}\n\n${output}` : output;
934
- }
935
976
  function formatSkippedModelFailureDetails(skippedModels) {
936
977
  if (skippedModels.length === 0)
937
978
  return undefined;
@@ -939,6 +980,13 @@ function formatSkippedModelFailureDetails(skippedModels) {
939
980
  }
940
981
  const MAX_PARALLEL_TASKS = 8;
941
982
  const MAX_TASK_LENGTH = 32_768; // 32 KB — prevent E2BIG from oversized argv
983
+ /** Thrown by {@link SubagentConcurrencyGate.acquire} when its signal aborts before a slot is granted. */
984
+ export class SubagentSlotWaitAbortedError extends Error {
985
+ constructor() {
986
+ super("Cancelled while waiting for a subagent concurrency slot.");
987
+ this.name = "SubagentSlotWaitAbortedError";
988
+ }
989
+ }
942
990
  /**
943
991
  * Create a concurrency gate limited to `maxConcurrent` simultaneous holders. The count lives in
944
992
  * this closure, so a single gate instance shared across tool rebuilds keeps counting in-flight
@@ -951,16 +999,27 @@ export function createSubagentConcurrencyGate(maxConcurrent) {
951
999
  let running = 0;
952
1000
  const waiters = [];
953
1001
  return {
954
- acquire() {
1002
+ acquire(signal) {
1003
+ if (signal?.aborted)
1004
+ return Promise.reject(new SubagentSlotWaitAbortedError());
955
1005
  if (running < maxConcurrent) {
956
1006
  running++;
957
1007
  return Promise.resolve();
958
1008
  }
959
- return new Promise((resolve) => {
960
- waiters.push(() => {
1009
+ return new Promise((resolve, reject) => {
1010
+ const grant = () => {
1011
+ signal?.removeEventListener("abort", onAbort);
961
1012
  running++;
962
1013
  resolve();
963
- });
1014
+ };
1015
+ const onAbort = () => {
1016
+ const index = waiters.indexOf(grant);
1017
+ if (index !== -1)
1018
+ waiters.splice(index, 1);
1019
+ reject(new SubagentSlotWaitAbortedError());
1020
+ };
1021
+ waiters.push(grant);
1022
+ signal?.addEventListener("abort", onAbort, { once: true });
964
1023
  });
965
1024
  },
966
1025
  release() {
@@ -1272,11 +1331,19 @@ singleModelMode) {
1272
1331
  onProgress?.(`Running ${name} agent${usedModel ? ` (${usedModel})` : ""}...`);
1273
1332
  const result = await spawnSubagent(effectiveConfig, task, cwd, signal, onProgress, resolvedProvider, sessionDir, parentSessionFile, onChildEvent, finalThinking, onControlAvailable);
1274
1333
  const finalSelectedModel = result.model ?? (usedModel ? canonicalModelRef(resolvedProvider, usedModel) : undefined);
1275
- result.output = prependModelFallbackSummary(result.output, skippedModels, arbitrationEnabled ? proposalSelectedModel : finalSelectedModel, arbitrationEnabled ? finalSelectedModel : undefined);
1334
+ // Spawn-time routing notes are system text, not the child's words: keep them out of
1335
+ // `output` so the parent sees them outside the agent-output block (and so a chain's
1336
+ // `{previous}` carries only the previous agent's output).
1337
+ const notes = [];
1276
1338
  if (warning) {
1277
1339
  const warningContext = arbitrationEnabled ? `Proposal resolution: ${warning}` : warning;
1278
- result.output = `[WARNING: ${warningContext}]\n\n${result.output}`;
1340
+ notes.push(`[WARNING: ${warningContext}]`);
1279
1341
  }
1342
+ const fallbackSummary = formatModelFallbackSummary(skippedModels, arbitrationEnabled ? proposalSelectedModel : finalSelectedModel, arbitrationEnabled ? finalSelectedModel : undefined);
1343
+ if (fallbackSummary)
1344
+ notes.push(fallbackSummary);
1345
+ if (notes.length > 0)
1346
+ result.notes = notes;
1280
1347
  return result;
1281
1348
  }
1282
1349
  async function executeChain(agents, chain, defaultCwd, signal, onProgress, parentProvider, registry, sessionBaseDir, defaultAgent, defaultModel, defaultThinking, parentModel, getAgentModelsForAgentFn, parentSessionFile, onChildEvent, arbitration, onControlAvailable,
@@ -1540,6 +1607,11 @@ function findFirstUserMessageSummary(sessionFile) {
1540
1607
  }
1541
1608
  return undefined;
1542
1609
  }
1610
+ /**
1611
+ * Infer a finished child's status from the last assistant message in its session log.
1612
+ * `aborted` only occurs when the parent gracefully cancelled the child (user stop), so it
1613
+ * maps to `cancelled`; `error` maps to `failed`.
1614
+ */
1543
1615
  function inferCompletedSessionStatus(sessionFile) {
1544
1616
  const tail = readFileTail(sessionFile, TAIL_READ_BYTES);
1545
1617
  if (!tail)
@@ -1554,10 +1626,88 @@ function inferCompletedSessionStatus(sessionFile) {
1554
1626
  if (entry.message.role !== "assistant")
1555
1627
  continue;
1556
1628
  const stopReason = entry.message.stopReason;
1557
- return stopReason === "error" || stopReason === "aborted" ? "failed" : "completed";
1629
+ if (stopReason === "aborted")
1630
+ return "cancelled";
1631
+ return stopReason === "error" ? "failed" : "completed";
1558
1632
  }
1559
1633
  return "completed";
1560
1634
  }
1635
+ /**
1636
+ * File in a background agent's session directory recording that the user stopped it.
1637
+ * A stop doesn't always leave a trailing `aborted` message in the child's log: a chain
1638
+ * stopped between steps, a child terminated after the grace period, and an agent stopped
1639
+ * while queued or before spawn (which has no log at all). The marker is the authoritative
1640
+ * record that rehydration checks first.
1641
+ */
1642
+ export const SUBAGENT_CANCELLED_MARKER_FILE = "cancelled.json";
1643
+ /**
1644
+ * Record a user stop in the agent's session directory (created if the agent never
1645
+ * spawned). Agents without a session directory or parent session file can't be
1646
+ * rehydrated, so nothing is written for them. A write failure doesn't block the stop
1647
+ * itself. It is logged as an error, because the agent may show as completed or be
1648
+ * missing after a reload.
1649
+ */
1650
+ function writeCancellationMarker(info) {
1651
+ if (!info.sessionDir || !info.parentSessionFile)
1652
+ return;
1653
+ const marker = {
1654
+ type: "subagent_cancelled",
1655
+ version: 1,
1656
+ cancelledAt: new Date().toISOString(),
1657
+ parentSession: info.parentSessionFile,
1658
+ agentType: info.agentType,
1659
+ taskSummary: info.taskSummary,
1660
+ startedAt: new Date(info.startedAt).toISOString(),
1661
+ ...(info.cwd ? { cwd: info.cwd } : {}),
1662
+ };
1663
+ try {
1664
+ mkdirSync(info.sessionDir, { recursive: true });
1665
+ writeFileSync(join(info.sessionDir, SUBAGENT_CANCELLED_MARKER_FILE), `${JSON.stringify(marker)}\n`);
1666
+ }
1667
+ catch (err) {
1668
+ log.error(`[subagent] Failed to record the cancellation of background agent ${info.agentId} in ${info.sessionDir}: ${err instanceof Error ? err.message : String(err)}. After a reload it may show as completed or be missing.`);
1669
+ }
1670
+ }
1671
+ function isCancellationMarker(value) {
1672
+ return (isRecord(value) &&
1673
+ value.type === "subagent_cancelled" &&
1674
+ typeof value.parentSession === "string" &&
1675
+ typeof value.agentType === "string" &&
1676
+ value.agentType.trim() !== "" &&
1677
+ typeof value.taskSummary === "string" &&
1678
+ typeof value.startedAt === "string" &&
1679
+ Number.isFinite(Date.parse(value.startedAt)) &&
1680
+ (value.cwd === undefined || typeof value.cwd === "string"));
1681
+ }
1682
+ /**
1683
+ * Read an agent's cancellation marker. Returns `undefined` when there is none, the parsed
1684
+ * marker when valid, and `"malformed"` (after a warning) when the file exists but can't be
1685
+ * used. A malformed marker still proves the stop happened, but can't identify the parent.
1686
+ * Filesystem errors other than absence propagate to the caller's recovery policy.
1687
+ */
1688
+ function readCancellationMarker(sessionDir) {
1689
+ const markerPath = join(sessionDir, SUBAGENT_CANCELLED_MARKER_FILE);
1690
+ let raw;
1691
+ try {
1692
+ raw = readFileSync(markerPath, "utf8");
1693
+ }
1694
+ catch (err) {
1695
+ if (isMissingFilesystemError(err))
1696
+ return undefined;
1697
+ throw err;
1698
+ }
1699
+ let parsed;
1700
+ try {
1701
+ parsed = JSON.parse(raw);
1702
+ }
1703
+ catch {
1704
+ parsed = undefined;
1705
+ }
1706
+ if (isCancellationMarker(parsed))
1707
+ return parsed;
1708
+ log.warn(`[subagent] ignoring malformed cancellation marker ${markerPath}; the agent is still reported as cancelled`);
1709
+ return "malformed";
1710
+ }
1561
1711
  function parseStartedAt(header, sessionFile) {
1562
1712
  if (typeof header.timestamp === "string") {
1563
1713
  const timestamp = Date.parse(header.timestamp);
@@ -1575,21 +1725,38 @@ function parseStartedAt(header, sessionFile) {
1575
1725
  }
1576
1726
  function hasRegisteredSession(sessionDir, sessionFile) {
1577
1727
  const comparableSessionDir = canonicalExistingPath(sessionDir);
1578
- const comparableSessionFile = canonicalExistingPath(sessionFile);
1728
+ const comparableSessionFile = sessionFile ? canonicalExistingPath(sessionFile) : undefined;
1579
1729
  for (const agent of backgroundAgentRegistry.values()) {
1580
1730
  if (agent.sessionDir && canonicalExistingPath(agent.sessionDir) === comparableSessionDir)
1581
1731
  return true;
1582
- if (agent.sessionFile && canonicalExistingPath(agent.sessionFile) === comparableSessionFile)
1732
+ if (comparableSessionFile &&
1733
+ agent.sessionFile &&
1734
+ canonicalExistingPath(agent.sessionFile) === comparableSessionFile) {
1583
1735
  return true;
1736
+ }
1584
1737
  }
1585
1738
  return false;
1586
1739
  }
1740
+ /** A registry ID for a rehydrated agent, derived from its session directory name and kept unique. */
1741
+ function nextRehydratedAgentId(sessionDirName) {
1742
+ const baseAgentId = `${REHYDRATED_AGENT_ID_PREFIX}${sessionDirName}`;
1743
+ let agentId = baseAgentId;
1744
+ let suffix = 2;
1745
+ while (backgroundAgentRegistry.has(agentId)) {
1746
+ agentId = `${baseAgentId}-${suffix++}`;
1747
+ }
1748
+ return agentId;
1749
+ }
1587
1750
  /**
1588
1751
  * Recover completed background subagents after a dashboard/RPC process resumes
1589
1752
  * an existing parent session. Live background-agent state is an in-memory
1590
1753
  * registry, while child sessions are durable JSONL files under the subagent
1591
1754
  * sessions directory.
1592
1755
  *
1756
+ * A user stop's cancellation marker ({@link SUBAGENT_CANCELLED_MARKER_FILE}) takes
1757
+ * precedence over status inferred from the log. An agent stopped before it spawned has
1758
+ * only the marker, and is recovered from it.
1759
+ *
1593
1760
  * The first root is authoritative: absence races and malformed JSON are skipped,
1594
1761
  * while operational filesystem failures throw. Later roots are compatibility
1595
1762
  * stores, so their operational filesystem failures are warned and skipped.
@@ -1640,8 +1807,29 @@ export function rehydrateBackgroundAgentsFromDisk(parentSessionFile, subagentSes
1640
1807
  continue;
1641
1808
  seenSessionDirs.add(canonicalSessionDir);
1642
1809
  const sessionFiles = discoverSessionFiles(sessionDir, entry.name, { throwOnOperationalError: true });
1643
- if (sessionFiles.length === 0)
1810
+ const cancellationMarker = readCancellationMarker(sessionDir);
1811
+ if (sessionFiles.length === 0) {
1812
+ // A user stop while the agent was queued or before it spawned leaves no log,
1813
+ // only the cancellation marker.
1814
+ if (cancellationMarker === undefined || cancellationMarker === "malformed")
1815
+ continue;
1816
+ if (!parentSessionMatches(cancellationMarker.parentSession, parentSessionFile))
1817
+ continue;
1818
+ if (hasRegisteredSession(sessionDir))
1819
+ continue;
1820
+ const agentId = nextRehydratedAgentId(entry.name);
1821
+ backgroundAgentRegistry.set(agentId, {
1822
+ agentId,
1823
+ agentType: cancellationMarker.agentType,
1824
+ taskSummary: cancellationMarker.taskSummary,
1825
+ startedAt: Date.parse(cancellationMarker.startedAt),
1826
+ status: "cancelled",
1827
+ sessionDir,
1828
+ cwd: cancellationMarker.cwd,
1829
+ });
1830
+ registered++;
1644
1831
  continue;
1832
+ }
1645
1833
  let sessionFile;
1646
1834
  let header;
1647
1835
  for (const candidateFile of sessionFiles) {
@@ -1660,12 +1848,7 @@ export function rehydrateBackgroundAgentsFromDisk(parentSessionFile, subagentSes
1660
1848
  }
1661
1849
  if (!sessionFile || !header || hasRegisteredSession(sessionDir, sessionFile))
1662
1850
  continue;
1663
- const baseAgentId = `${REHYDRATED_AGENT_ID_PREFIX}${entry.name}`;
1664
- let agentId = baseAgentId;
1665
- let suffix = 2;
1666
- while (backgroundAgentRegistry.has(agentId)) {
1667
- agentId = `${baseAgentId}-${suffix++}`;
1668
- }
1851
+ const agentId = nextRehydratedAgentId(entry.name);
1669
1852
  const statusFile = sessionFiles[sessionFiles.length - 1] ?? sessionFile;
1670
1853
  const agentType = typeof header.agentType === "string" && header.agentType.trim() ? header.agentType : "agent";
1671
1854
  const taskSummary = findFirstUserMessageSummary(sessionFile) ?? `${agentType} (${entry.name})`;
@@ -1674,7 +1857,9 @@ export function rehydrateBackgroundAgentsFromDisk(parentSessionFile, subagentSes
1674
1857
  agentType,
1675
1858
  taskSummary,
1676
1859
  startedAt: parseStartedAt(header, sessionFile),
1677
- status: inferCompletedSessionStatus(statusFile),
1860
+ // The marker is authoritative: a stop between chain steps or a terminated child
1861
+ // doesn't leave a trailing `aborted` message for inference to find.
1862
+ status: cancellationMarker !== undefined ? "cancelled" : inferCompletedSessionStatus(statusFile),
1678
1863
  sessionDir,
1679
1864
  sessionFile,
1680
1865
  cwd: typeof header.cwd === "string" ? header.cwd : undefined,
@@ -1725,14 +1910,49 @@ export async function getBackgroundAgentPendingSteering(agentId) {
1725
1910
  const [state, pending] = await Promise.all([client.getState(), client.getPendingMessages()]);
1726
1911
  return { steeringMode: state.steeringMode, pending };
1727
1912
  }
1728
- /** Abort all running background agents. */
1913
+ /**
1914
+ * Mark one registry entry cancelled, record the stop on disk, and abort its
1915
+ * controller. The status is set before aborting so anything reacting
1916
+ * synchronously to the abort (and the lifecycle, which skips status updates once
1917
+ * its signal is aborted) observes `cancelled` rather than `running`. The
1918
+ * controller's abort listeners perform the graceful child shutdown (RPC abort
1919
+ * first, then SIGTERM/SIGKILL backstops).
1920
+ */
1921
+ function cancelBackgroundAgentEntry(agentId, controller) {
1922
+ const entry = backgroundAgentRegistry.get(agentId);
1923
+ if (entry && entry.status === "running") {
1924
+ entry.status = "cancelled";
1925
+ writeCancellationMarker(entry);
1926
+ }
1927
+ backgroundAbortControllers.delete(agentId);
1928
+ backgroundControlClients.delete(agentId);
1929
+ controller.abort();
1930
+ }
1931
+ /**
1932
+ * Cancel exactly one running background agent (user stop). Siblings, the parent
1933
+ * session, and their control channels are untouched. Works for agents that are
1934
+ * executing, queued for a concurrency slot, or between chain steps; a chain is
1935
+ * stopped as a whole. Completion is reported asynchronously through the normal
1936
+ * background-complete path with `cancelled = true`.
1937
+ *
1938
+ * Throws for unknown agents and agents that are no longer running (completed,
1939
+ * failed, cancelled, or rehydrated from disk) — never a silent no-op.
1940
+ */
1941
+ export function abortBackgroundAgent(agentId) {
1942
+ const info = backgroundAgentRegistry.get(agentId);
1943
+ if (!info)
1944
+ throw new Error(`Unknown background agent "${agentId}".`);
1945
+ if (info.status !== "running")
1946
+ throw new Error(`Background agent "${agentId}" is no longer running.`);
1947
+ const controller = backgroundAbortControllers.get(agentId);
1948
+ if (!controller)
1949
+ throw new Error(`Background agent "${agentId}" has no abort controller; it cannot be stopped.`);
1950
+ cancelBackgroundAgentEntry(agentId, controller);
1951
+ }
1952
+ /** Cancel all running background agents (Esc / parent stop). */
1729
1953
  export function abortBackgroundAgents() {
1730
- for (const [id, controller] of backgroundAbortControllers) {
1731
- controller.abort();
1732
- const entry = backgroundAgentRegistry.get(id);
1733
- if (entry && entry.status === "running") {
1734
- entry.status = "failed";
1735
- }
1954
+ for (const [id, controller] of [...backgroundAbortControllers]) {
1955
+ cancelBackgroundAgentEntry(id, controller);
1736
1956
  }
1737
1957
  backgroundAbortControllers.clear();
1738
1958
  backgroundControlClients.clear();
@@ -1860,7 +2080,22 @@ function formatSubagentResult(result, options, theme, showImages) {
1860
2080
  }
1861
2081
  return text;
1862
2082
  }
1863
- export function formatSingleResult(result) {
2083
+ /**
2084
+ * Wrap a subagent's own output in labelled delimiter lines so it can't be mistaken for
2085
+ * the system lines around it (execution metadata, cancellation/error notes, session log,
2086
+ * still-running list). `partial` marks output from a run that did not finish cleanly
2087
+ * (cancelled, failed, or truncated). Blank lines around the delimiters keep them as
2088
+ * separate paragraphs when the text is rendered as Markdown (dashboard, Telegram).
2089
+ */
2090
+ export function formatAgentOutputBlock(output, partial) {
2091
+ return `--- agent output${partial ? " (partial)" : ""} ---\n\n${output}\n\n--- end of agent output ---`;
2092
+ }
2093
+ /**
2094
+ * Render one subagent result (used for each chain step). The agent's output is wrapped by
2095
+ * `formatAgentOutputBlock`; `cancelled` marks a step that was stopped by the user, whose
2096
+ * output is therefore partial.
2097
+ */
2098
+ export function formatSingleResult(result, cancelled = false) {
1864
2099
  const metadata = [
1865
2100
  result.model ? `model: ${result.model}` : undefined,
1866
2101
  result.thinking ? `thinking: ${result.thinking}` : undefined,
@@ -1878,8 +2113,12 @@ export function formatSingleResult(result) {
1878
2113
  // Clean exit but an error was surfaced (e.g. truncation at the token limit).
1879
2114
  text += `**Error**: ${result.errorMessage}\n`;
1880
2115
  }
2116
+ if (result.notes && result.notes.length > 0) {
2117
+ text += `\n${result.notes.join("\n\n")}\n`;
2118
+ }
1881
2119
  if (result.output) {
1882
- text += `\n${result.output}`;
2120
+ const partial = cancelled || result.exitCode !== 0 || !!result.errorMessage;
2121
+ text += `\n${formatAgentOutputBlock(result.output, partial)}`;
1883
2122
  }
1884
2123
  else if (result.exitCode === 0 && !result.errorMessage) {
1885
2124
  text += "\n(No output)";
@@ -1914,7 +2153,7 @@ export function createSubagentToolDefinition(cwd, options) {
1914
2153
  // supplied (external SDK callers, tests) the tool owns a fresh per-instance gate, which
1915
2154
  // still keeps separately embedded sessions from coupling through module-global state.
1916
2155
  const concurrencyGate = options?.concurrencyGate ?? createSubagentConcurrencyGate(maxConcurrentSubagents);
1917
- const acquireBackgroundSlot = () => concurrencyGate.acquire();
2156
+ const acquireBackgroundSlot = (signal) => concurrencyGate.acquire(signal);
1918
2157
  const releaseBackgroundSlot = () => concurrencyGate.release();
1919
2158
  // Discover agents at definition time to build the prompt guidelines.
1920
2159
  // This is cheap (reads .md files) and the same call happens on every execute().
@@ -2010,6 +2249,7 @@ export function createSubagentToolDefinition(cwd, options) {
2010
2249
  status: "running",
2011
2250
  sessionDir,
2012
2251
  cwd: agentCwd,
2252
+ parentSessionFile: getParentSessionFile(),
2013
2253
  });
2014
2254
  backgroundAbortControllers.set(agentId, bgAbort);
2015
2255
  onBackgroundStart?.(agentId, agentName, taskSummary, sessionDir);
@@ -2051,8 +2291,12 @@ export function createSubagentToolDefinition(cwd, options) {
2051
2291
  }
2052
2292
  };
2053
2293
  const run = async () => {
2054
- await acquireBackgroundSlot();
2294
+ // A stop while queued for a slot settles immediately (the gate rejects
2295
+ // without taking a slot), so only release a slot this run acquired.
2296
+ let slotAcquired = false;
2055
2297
  try {
2298
+ await acquireBackgroundSlot(bgSignal);
2299
+ slotAcquired = true;
2056
2300
  const result = await runFn(bgSignal, onChildEvent, onArbitrationRecord, onControlAvailable);
2057
2301
  const entry = backgroundAgentRegistry.get(agentId);
2058
2302
  if (entry && !bgSignal.aborted)
@@ -2078,7 +2322,8 @@ export function createSubagentToolDefinition(cwd, options) {
2078
2322
  }
2079
2323
  finally {
2080
2324
  backgroundControlClients.delete(agentId);
2081
- releaseBackgroundSlot();
2325
+ if (slotAcquired)
2326
+ releaseBackgroundSlot();
2082
2327
  }
2083
2328
  };
2084
2329
  run().catch((err) => {
@@ -2191,8 +2436,11 @@ export function createSubagentToolDefinition(cwd, options) {
2191
2436
  getAgentModelsForAgent,
2192
2437
  }
2193
2438
  : undefined, onControlAvailable, getParentSessionId(), getSingleModelMode);
2439
+ // A user stop ends the chain at its current (last) step, so only that step's
2440
+ // output can be cut short.
2441
+ const stoppedAtLastStep = signal.aborted;
2194
2442
  const resultText = results
2195
- .map((r, i) => `### Step ${i + 1}\n${formatSingleResult(r)}`)
2443
+ .map((r, i) => `### Step ${i + 1}\n${formatSingleResult(r, stoppedAtLastStep && i === results.length - 1)}`)
2196
2444
  .join("\n\n---\n\n");
2197
2445
  const failed = results.filter((r) => r.exitCode !== 0);
2198
2446
  // Per-step session logs are already embedded in resultText via formatSingleResult