@gajae-code/coding-agent 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +8 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +1 -0
  37. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  38. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  39. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  41. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  42. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  43. package/dist/types/sdk/receipt-state.d.ts +2 -0
  44. package/dist/types/sdk/router/session-router.d.ts +4 -0
  45. package/dist/types/sdk/turn-result.d.ts +6 -0
  46. package/dist/types/session/agent-session.d.ts +47 -7
  47. package/dist/types/session/fold-coordinator.d.ts +29 -2
  48. package/dist/types/session/session-manager.d.ts +14 -2
  49. package/dist/types/tools/ask.d.ts +1 -0
  50. package/dist/types/tools/bash.d.ts +12 -0
  51. package/dist/types/tools/browser/launch.d.ts +10 -0
  52. package/dist/types/tools/browser/registry.d.ts +8 -0
  53. package/dist/types/tools/index.d.ts +6 -10
  54. package/dist/types/tools/job.d.ts +3 -1
  55. package/dist/types/utils/external-editor.d.ts +7 -0
  56. package/examples/extensions/README.md +1 -1
  57. package/examples/extensions/pirate.ts +7 -4
  58. package/package.json +7 -7
  59. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  60. package/src/async/job-manager.ts +107 -9
  61. package/src/browser-backend.ts +29 -0
  62. package/src/cli/plugin-cli.ts +29 -0
  63. package/src/cli/update-cli.ts +63 -12
  64. package/src/commands/sdk.ts +22 -7
  65. package/src/config/billing-path.ts +42 -0
  66. package/src/config/config-schema-version.ts +18 -1
  67. package/src/config/model-preset-registry.ts +177 -18
  68. package/src/config/model-registry.ts +268 -42
  69. package/src/config/settings-schema.ts +19 -7
  70. package/src/config/settings.ts +46 -15
  71. package/src/coordinator-mcp/projection-scan.ts +6 -6
  72. package/src/coordinator-mcp/server.ts +21 -1
  73. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  74. package/src/extensibility/extensions/runner.ts +27 -0
  75. package/src/extensibility/extensions/types.ts +8 -3
  76. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  77. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  78. package/src/extensibility/skills.ts +22 -12
  79. package/src/gjc-runtime/launch-worktree.ts +285 -4
  80. package/src/gjc-runtime/session-state-lock.ts +154 -28
  81. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  82. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  83. package/src/internal-urls/docs-index.generated.ts +10 -10
  84. package/src/main.ts +4 -1
  85. package/src/modes/acp/acp-agent.ts +5 -5
  86. package/src/modes/components/model-selector.ts +76 -8
  87. package/src/modes/components/session-selector.ts +23 -15
  88. package/src/modes/components/sessions-dashboard.ts +8 -5
  89. package/src/modes/controllers/command-controller.ts +1 -1
  90. package/src/modes/controllers/event-controller.ts +30 -4
  91. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  92. package/src/modes/controllers/input-controller.ts +2 -2
  93. package/src/modes/controllers/selector-controller.ts +10 -3
  94. package/src/modes/interactive-mode.ts +36 -12
  95. package/src/modes/prompt-action-autocomplete.ts +30 -4
  96. package/src/modes/runtime-init.ts +12 -9
  97. package/src/modes/theme/theme.ts +5 -0
  98. package/src/modes/types.ts +2 -0
  99. package/src/modes/utils/ui-helpers.ts +22 -3
  100. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  101. package/src/prompts/tools/ask.md +1 -1
  102. package/src/prompts/tools/bash.md +1 -0
  103. package/src/prompts/tools/job.md +1 -1
  104. package/src/sdk/acp/adapter.ts +5 -0
  105. package/src/sdk/broker/broker.ts +10 -1
  106. package/src/sdk/broker/ensure.ts +76 -3
  107. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  108. package/src/sdk/broker/lifecycle.ts +254 -34
  109. package/src/sdk/broker/spawn-substrate.ts +45 -2
  110. package/src/sdk/broker/startup-budget.ts +144 -2
  111. package/src/sdk/broker/state-version.ts +17 -0
  112. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  113. package/src/sdk/bus/chat-daemon-runtime.ts +111 -24
  114. package/src/sdk/bus/discord-daemon.ts +50 -10
  115. package/src/sdk/bus/index.ts +33 -3
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  117. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  118. package/src/sdk/bus/notification-service.ts +22 -5
  119. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  120. package/src/sdk/bus/reconciliation-store.ts +55 -31
  121. package/src/sdk/bus/slack-conversation.ts +1 -1
  122. package/src/sdk/bus/slack-daemon.ts +72 -38
  123. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  124. package/src/sdk/cli/rows.ts +30 -7
  125. package/src/sdk/cli/session-cli.ts +33 -21
  126. package/src/sdk/host/control/dispatch.ts +2 -2
  127. package/src/sdk/host/events.ts +1 -0
  128. package/src/sdk/host/host.ts +2 -0
  129. package/src/sdk/host/query/cursor.ts +52 -7
  130. package/src/sdk/host/query/handlers.ts +33 -12
  131. package/src/sdk/host/query/revision-store.ts +12 -0
  132. package/src/sdk/host/session-runtime.test.ts +463 -8
  133. package/src/sdk/host/session-runtime.ts +383 -54
  134. package/src/sdk/host/surface-policy.ts +3 -3
  135. package/src/sdk/lifecycle-session.ts +7 -2
  136. package/src/sdk/prompt-deadline-manager.ts +2 -1
  137. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  138. package/src/sdk/protocol/operation-registry.ts +1 -1
  139. package/src/sdk/receipt-state.ts +10 -0
  140. package/src/sdk/router/session-router.ts +152 -59
  141. package/src/sdk/session.ts +60 -41
  142. package/src/sdk/turn-result.ts +22 -0
  143. package/src/session/agent-session.ts +1033 -260
  144. package/src/session/fold-coordinator.ts +40 -3
  145. package/src/session/session-manager.ts +517 -77
  146. package/src/slash-commands/builtin-registry.ts +41 -0
  147. package/src/task/executor.ts +9 -4
  148. package/src/tools/ask-contract.test.ts +64 -0
  149. package/src/tools/ask-contract.ts +36 -1
  150. package/src/tools/ask.ts +10 -8
  151. package/src/tools/bash-interactive.ts +11 -2
  152. package/src/tools/bash.ts +156 -33
  153. package/src/tools/browser/launch.ts +37 -0
  154. package/src/tools/browser/registry.ts +99 -9
  155. package/src/tools/cron.ts +6 -4
  156. package/src/tools/descriptors.test.ts +15 -0
  157. package/src/tools/descriptors.ts +5 -1
  158. package/src/tools/index.ts +6 -6
  159. package/src/tools/job.ts +4 -1
  160. package/src/tools/monitor.ts +12 -9
  161. package/src/tools/resolve.ts +30 -10
  162. package/src/tools/tool-catalog.generated.ts +2 -2
  163. package/src/utils/external-editor.ts +72 -1
@@ -201,6 +201,8 @@ import {
201
201
  type AsyncJobDeliveryState,
202
202
  AsyncJobManager,
203
203
  asyncJobEndpointId as deriveAsyncJobEndpointId,
204
+ type FoldReason,
205
+ type JobFoldEvent,
204
206
  type OwnerSubagentShutdownLease,
205
207
  } from "../async";
206
208
  import { reset as resetCapabilities } from "../capability";
@@ -296,6 +298,7 @@ import type { SessionSwitchEvent } from "../extensibility/shared-events";
296
298
  import {
297
299
  buildSkillPromptMessage,
298
300
  getSkillSlashCommandName,
301
+ isNamespacedSkillSlashCommandName,
299
302
  parseSkillInvocations,
300
303
  type Skill,
301
304
  type SkillWarning,
@@ -311,11 +314,14 @@ import {
311
314
  import {
312
315
  assertNonEmptyGjcSessionId,
313
316
  modeStatePath as sessionModeStatePath,
317
+ sessionRuntimeDir,
314
318
  sessionStateDir,
315
319
  } from "../gjc-runtime/session-layout";
320
+ import { sessionStateLockFailureFields, shouldWarnPersistFailure } from "../gjc-runtime/session-state-lock";
316
321
  import {
317
322
  type CoordinatorToolObservation,
318
323
  clearCoordinatorRuntimeStateRescope,
324
+ GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
319
325
  hasCoordinatorRuntimeStateRescopeJournal,
320
326
  markCoordinatorRuntimeStateRescopePublishing,
321
327
  ownerTerminalContextFromEnvironment,
@@ -453,7 +459,13 @@ import {
453
459
  } from "./contribution-prep";
454
460
  import { canonicalCoordinatorToolLabel } from "./coordinator-tool-label";
455
461
  import { pruneStaleFileMentions } from "./file-mention-pruning";
456
- import { describeFoldReceipt, FOLD_WAKE_MERGE_WINDOW_MS, type FoldAdapter, FoldCoordinator } from "./fold-coordinator";
462
+ import {
463
+ describeFoldReceipt,
464
+ FOLD_WAKE_MERGE_WINDOW_MS,
465
+ type FoldAdapter,
466
+ FoldCoordinator,
467
+ type ForegroundFoldOutcome,
468
+ } from "./fold-coordinator";
457
469
  import type { MemoryGuardRestoreResult } from "./memory-guard-checkpoint-participant";
458
470
  import {
459
471
  type BashExecutionMessage,
@@ -1062,6 +1074,8 @@ export interface PromptOptions {
1062
1074
  streamingBehavior?: "steer" | "followUp";
1063
1075
  /** When set to "sequential", this follow-up is delivered one prompt at a time even if followUpMode is "all". */
1064
1076
  followUpQueuePolicy?: "respect-mode" | "sequential";
1077
+ /** When set to "sequential", this steer is delivered on its own even if steeringMode is "all". */
1078
+ steerQueuePolicy?: "respect-mode" | "sequential";
1065
1079
  /** Optional tool choice override for the next LLM call. */
1066
1080
  toolChoice?: ToolChoice;
1067
1081
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
@@ -1095,6 +1109,7 @@ type InternalCustomMessageOptions = Pick<
1095
1109
  | "streamingBehavior"
1096
1110
  | "toolChoice"
1097
1111
  | "followUpQueuePolicy"
1112
+ | "steerQueuePolicy"
1098
1113
  | "onPreflightAccepted"
1099
1114
  | "onPreflightAcceptCommit"
1100
1115
  | "preflightSignal"
@@ -1562,6 +1577,21 @@ interface EphemeralTurnResult {
1562
1577
  */
1563
1578
  const SIGNAL_TEARDOWN_TIMEOUT_MS = 5_000;
1564
1579
 
1580
+ export class SessionDisposalIncompleteError extends Error {
1581
+ constructor(label?: string) {
1582
+ super(
1583
+ label
1584
+ ? `Session disposal exceeded its bounded caller deadline while waiting for ${label}.`
1585
+ : "Session disposal exceeded its bounded caller deadline.",
1586
+ );
1587
+ this.name = "SessionDisposalIncompleteError";
1588
+ }
1589
+ }
1590
+
1591
+ export function isSessionDisposalIncompleteError(error: unknown): error is SessionDisposalIncompleteError {
1592
+ return error instanceof Error && error.name === "SessionDisposalIncompleteError";
1593
+ }
1594
+
1565
1595
  const AGENT_BASH_ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
1566
1596
 
1567
1597
  function boundAgentBashArtifactSaveDiagnostic(error: unknown): string {
@@ -2012,6 +2042,7 @@ function sanitizePostPublicationError(error: string | undefined): string | undef
2012
2042
 
2013
2043
  export class WorkerIntegrationRequestScheduler {
2014
2044
  #inFlight: Promise<WorkerIntegrationOutcome> | undefined = undefined;
2045
+ #ownedRequests = new Set<Promise<void>>();
2015
2046
  #pending = false;
2016
2047
 
2017
2048
  constructor(
@@ -2039,6 +2070,12 @@ export class WorkerIntegrationRequestScheduler {
2039
2070
  return outcome;
2040
2071
  }
2041
2072
 
2073
+ async joinOwnedRequests(): Promise<void> {
2074
+ while (this.#ownedRequests.size > 0) {
2075
+ await Promise.allSettled([...this.#ownedRequests]);
2076
+ }
2077
+ }
2078
+
2042
2079
  #start(): void {
2043
2080
  this.#pending = false;
2044
2081
  const controller = new AbortController();
@@ -2048,6 +2085,8 @@ export class WorkerIntegrationRequestScheduler {
2048
2085
  } catch {
2049
2086
  request = Promise.reject(new Error("Worker integration request failed before dispatch."));
2050
2087
  }
2088
+ this.#ownedRequests.add(request);
2089
+ void request.finally(() => this.#ownedRequests.delete(request)).catch(() => {});
2051
2090
  let timeout: ReturnType<typeof setTimeout> | undefined;
2052
2091
  const deadline = new Promise<WorkerIntegrationOutcome>(resolve => {
2053
2092
  timeout = setTimeout(() => {
@@ -2283,6 +2322,7 @@ type SessionAdmissionEntry = {
2283
2322
  settled: PromiseWithResolvers<void>;
2284
2323
  released: boolean;
2285
2324
  selectionFenceGeneration: number;
2325
+ selectionTransaction?: symbol;
2286
2326
  continuationCapability?: symbol;
2287
2327
  };
2288
2328
 
@@ -2352,6 +2392,12 @@ interface CanonicalMessageAdmission {
2352
2392
  release: () => void;
2353
2393
  }
2354
2394
 
2395
+ type CoordinatorRuntimeStatePersistContext = {
2396
+ sessionId: string;
2397
+ cwd: string;
2398
+ sessionFile: string | undefined;
2399
+ stateFile: string | null;
2400
+ };
2355
2401
  export class AgentSession {
2356
2402
  #provisionalStreamingToolCallIds = new Set<string>();
2357
2403
  readonly agent: Agent;
@@ -2399,6 +2445,8 @@ export class AgentSession {
2399
2445
  #selectionFenceGenerationContext = new AsyncLocalStorage<number>();
2400
2446
  #selectionFenceTail: Promise<void> = Promise.resolve();
2401
2447
  #pendingSelectionFences = 0;
2448
+ #selectionAwaitingMutationTransaction: symbol | undefined;
2449
+ #authorizedClosedSelectionTransaction: symbol | undefined;
2402
2450
  #selectionFenceDeferredContinuations = new Map<number, number>();
2403
2451
  #scopedSettlementWaiters = new Set<() => void>();
2404
2452
  #oldestPendingSelectionFenceGeneration = 0;
@@ -2498,6 +2546,10 @@ export class AgentSession {
2498
2546
  // continuation of the aborted attempt that the contract requires to be
2499
2547
  // blocked (review thread P1).
2500
2548
  readonly #externalSteerMessages = new WeakSet<AgentMessage>();
2549
+ /** Steers explicitly requested as one-per-poll; preserve this policy if a run disowns them into follow-ups. */
2550
+ readonly #sequentialSteerMessages = new WeakSet<AgentMessage>();
2551
+ /** Non-admitted steers collected while one abort unwind owns delivery. */
2552
+ #abortUnwindSteerFallbacks: AgentMessage[] = [];
2501
2553
  /** Per-message SDK requester-ownership correlation for queued client steers:
2502
2554
  * fired exactly once when the steer's run accepts it — via the idle
2503
2555
  * auto-continue or the terminal-abort rearm (review thread P1). */
@@ -2543,6 +2595,157 @@ export class AgentSession {
2543
2595
  this.#deepInterviewGenuineUserMessageEpochs.delete(message);
2544
2596
  }
2545
2597
  }
2598
+
2599
+ /**
2600
+ * Queued messages that are independent ROOT requests: they must not run under
2601
+ * a terminally fenced lineage, so a fresh identity is minted when one of them
2602
+ * is actually delivered. Tracked per message rather than as a session latch,
2603
+ * so removing the message (queue edit, producer purge, queue clear) removes
2604
+ * its requirement too and no unrelated continuation inherits it. The mint
2605
+ * happens at the last synchronous point before `agent.continue*` entry —
2606
+ * never at enqueue, which would race the ending run's event settlement, and
2607
+ * never lost when a busy owner defers delivery.
2608
+ */
2609
+ #freshRootLineageRequests = new WeakSet<AgentMessage>();
2610
+
2611
+ /**
2612
+ * Bind a tag-only display chip (registered by `enqueueCustomMessageDisplay`
2613
+ * before dispatch) to the executable message that was actually queued, and
2614
+ * move it to the list matching how the message was queued.
2615
+ *
2616
+ * Without the binding the chip keeps no identity link, so edit/remove/pop fall
2617
+ * back to POSITIONAL addressing against the Agent queue — and a hidden
2618
+ * `display:false` custom message occupying an earlier queue slot makes that
2619
+ * index point at the wrong message. Migrating the entry also keeps the chip's
2620
+ * mode honest when a requested steer is rerouted to a follow-up.
2621
+ */
2622
+ #bindCustomDisplayEntry(message: AgentMessage, mode: "steer" | "followUp"): void {
2623
+ const tag = message.role === "custom" ? readPendingDisplayTag(message.details) : undefined;
2624
+ if (tag === undefined) return;
2625
+ const from = mode === "steer" ? this.#followUpMessages : this.#steeringMessages;
2626
+ const to = mode === "steer" ? this.#steeringMessages : this.#followUpMessages;
2627
+ const already = to.find(entry => entry.tag === tag);
2628
+ if (already) {
2629
+ already.message = message;
2630
+ return;
2631
+ }
2632
+ const index = from.findIndex(entry => entry.tag === tag);
2633
+ if (index === -1) return;
2634
+ const [entry] = from.splice(index, 1);
2635
+ if (!entry) return;
2636
+ entry.message = message;
2637
+ to.push(entry);
2638
+ }
2639
+
2640
+ /** True when a queued message still carries an unfulfilled fresh-root requirement. */
2641
+ #hasQueuedFreshRootRequest(): boolean {
2642
+ const { steering, followUp } = this.agent.snapshotQueues();
2643
+ return [...steering, ...followUp].some(message => this.#freshRootLineageRequests.has(message));
2644
+ }
2645
+
2646
+ /**
2647
+ * Disposition of steering the current run leaves unconsumed when it exits.
2648
+ * Set by the abort paths before the run ends and consumed exactly once by
2649
+ * `#settleDisownedSteering` on the run's `agent_end`. Absent means the run
2650
+ * ended on its own (completed/error/pause): steering it never polled is
2651
+ * re-routed as sequential follow-ups owned by the next turn.
2652
+ */
2653
+ #disownedSteeringDisposition:
2654
+ | { kind: "rearm" }
2655
+ | { kind: "terminal"; preserve: (message: AgentMessage) => boolean; snapshotSeq: number }
2656
+ | { kind: "drop" }
2657
+ | { kind: "held" }
2658
+ | undefined;
2659
+
2660
+ /**
2661
+ * The single owner of steering that a run admitted but never consumed. The
2662
+ * Agent already cleared its queue; this decides once, per message, whether
2663
+ * it is delivered as a follow-up of a fresh turn (user interrupt, natural
2664
+ * end, or a post-snapshot external steer surviving a terminal abort) or
2665
+ * dropped with its removal disposition (terminal abort, teardown), and keeps
2666
+ * the display list aligned with that decision.
2667
+ */
2668
+ #settleDisownedSteering(event: Extract<AgentEvent, { type: "agent_end" }>): void {
2669
+ const disowned = event.disownedSteering ?? [];
2670
+ const disposition = this.#disownedSteeringDisposition;
2671
+ this.#disownedSteeringDisposition = undefined;
2672
+ // A cancel-and-submit snapshotted the queues before aborting and re-queues
2673
+ // them itself once the abort settles; the display list stays as-is.
2674
+ if (disposition?.kind === "held") return;
2675
+ const dropped: AgentMessage[] = [];
2676
+ const rearmed: AgentMessage[] = [];
2677
+ for (const message of disowned) {
2678
+ const keep =
2679
+ disposition === undefined || disposition.kind === "rearm"
2680
+ ? true
2681
+ : disposition.kind === "terminal"
2682
+ ? disposition.preserve(message)
2683
+ : false;
2684
+ (keep ? rearmed : dropped).push(message);
2685
+ }
2686
+ const droppedSet = new Set(dropped);
2687
+ const rearmedSet = new Set(rearmed);
2688
+ const rearmedDisplays: QueuedDisplayEntry[] = [];
2689
+ this.#steeringMessages = this.#steeringMessages.filter(entry => {
2690
+ if (entry.message !== undefined && droppedSet.has(entry.message)) return false;
2691
+ if (entry.message !== undefined && rearmedSet.has(entry.message)) {
2692
+ rearmedDisplays.push(entry);
2693
+ return false;
2694
+ }
2695
+ // Tag-only display entries (custom skill chips) belong to whichever
2696
+ // custom message carries their tag.
2697
+ if (entry.tag !== undefined) {
2698
+ const owner = disowned.find(
2699
+ message => message.role === "custom" && readPendingDisplayTag(message.details) === entry.tag,
2700
+ );
2701
+ if (owner && droppedSet.has(owner)) return false;
2702
+ if (owner && rearmedSet.has(owner)) {
2703
+ rearmedDisplays.push(entry);
2704
+ return false;
2705
+ }
2706
+ }
2707
+ return true;
2708
+ });
2709
+ // Dropped steering leaves without consumption: terminalize its accepted
2710
+ // SDK submissions boundedly (#4668 review P1).
2711
+ this.#fireQueuedRemovalHooks(dropped);
2712
+ if (rearmed.length === 0) return;
2713
+ // Re-route as sequential follow-ups of the NEXT turn, ahead of any
2714
+ // follow-up queued after them, preserving submission order. Marking
2715
+ // each as sequential first, then restoring ahead of the existing queue,
2716
+ // keeps the existing follow-ups' identity and per-message marks intact.
2717
+ let batch: AgentMessage[] = [];
2718
+ const flushBatch = () => {
2719
+ if (batch.length > 1) this.agent.markFollowUpBatch(batch);
2720
+ batch = [];
2721
+ };
2722
+ for (const message of rearmed) {
2723
+ if (this.#sequentialSteerMessages.has(message)) {
2724
+ flushBatch();
2725
+ this.agent.markFollowUpSequential(message);
2726
+ } else if (this.agent.getSteeringMode() === "all") {
2727
+ batch.push(message);
2728
+ }
2729
+ }
2730
+ flushBatch();
2731
+ this.agent.restoreFollowUp(rearmed);
2732
+ this.#followUpMessages = [...rearmedDisplays, ...this.#followUpMessages];
2733
+ for (const message of rearmed) {
2734
+ // External SDK steers keep their promotion hook: it now fires at the
2735
+ // follow-up promotion boundary with startsOwnRun: true.
2736
+ const steerHook = this.#steerPromotionHooks.get(message);
2737
+ if (steerHook) {
2738
+ this.#steerPromotionHooks.delete(message);
2739
+ this.#followUpPromotionHooks.set(message, steerHook);
2740
+ }
2741
+ this.#externalFollowUps.add(message);
2742
+ }
2743
+ // Nothing else owns delivery of a rearm that did not come from an abort
2744
+ // (a natural end, or a fold whose wake turn must carry the steer): the
2745
+ // abort paths schedule their own continuation and this one is a no-op
2746
+ // once the queue is drained.
2747
+ this.#scheduleQueuedDelivery();
2748
+ }
2546
2749
  #fireQueuedPromotionHooks(messages: readonly AgentMessage[], promotion?: { startsOwnRun?: boolean }): void {
2547
2750
  for (const message of messages) {
2548
2751
  const steerHook = this.#steerPromotionHooks.get(message);
@@ -2633,6 +2836,8 @@ export class AgentSession {
2633
2836
 
2634
2837
  // Compaction state
2635
2838
  #compactionAbortController: AbortController | undefined = undefined;
2839
+ #compactionCompletion: Promise<void> | undefined;
2840
+ #autoCompactionCompletions = new Set<Promise<AutoCompactionTerminalStatus>>();
2636
2841
  #autoCompactionAbortController: AbortController | undefined = undefined;
2637
2842
 
2638
2843
  /** Invocation-scoped EventStream drain barriers owned by active maintenance calls. */
@@ -2876,10 +3081,18 @@ export class AgentSession {
2876
3081
  #providerCacheSessionId: string | undefined;
2877
3082
  readonly #asyncJobProviderSessionId: string | undefined;
2878
3083
  #isDisposed = false;
2879
- #disposePromise: Promise<void> | undefined;
3084
+ #disposeRunPromise: Promise<void> | undefined;
3085
+ #disposeCallerPromise: Promise<void> | undefined;
3086
+ #disposeCompleted = false;
3087
+ #disposeTerminalError: unknown;
2880
3088
  readonly #disposeAbortController = new AbortController();
2881
3089
  #disposeAdmissionClosed: Promise<void> | undefined;
2882
3090
  #disposePostPromptDrain: Promise<void> | undefined;
3091
+ #disposeDeadline = 0;
3092
+ #disposeDeadlineTimer: NodeJS.Timeout | undefined;
3093
+ #disposeDeadlineExpired: PromiseWithResolvers<void> | undefined;
3094
+ #disposeTimeoutMs = SIGNAL_TEARDOWN_TIMEOUT_MS;
3095
+ #disposeActiveStepLabel: string | undefined;
2883
3096
  readonly #toolSessionCleanups = new Set<() => Promise<void> | void>();
2884
3097
  readonly #toolSessionTransitionCleanups = new Set<() => Promise<void> | void>();
2885
3098
  readonly #deferredOwnerShutdownFinalizations = new Set<Promise<void>>();
@@ -3315,8 +3528,17 @@ export class AgentSession {
3315
3528
  }
3316
3529
 
3317
3530
  #activateNextSessionAdmission(): void {
3318
- if (this.#activeSessionAdmission || this.#sessionAdmissionClosed) return;
3319
- const next = this.#sessionAdmissionQueue.shift();
3531
+ if (this.#activeSessionAdmission) return;
3532
+ let next: SessionAdmissionEntry | undefined;
3533
+ if (this.#sessionAdmissionClosed) {
3534
+ const index = this.#sessionAdmissionQueue.findIndex(
3535
+ entry => entry.selectionTransaction === this.#authorizedClosedSelectionTransaction,
3536
+ );
3537
+ if (index < 0) return;
3538
+ [next] = this.#sessionAdmissionQueue.splice(index, 1);
3539
+ } else {
3540
+ next = this.#sessionAdmissionQueue.shift();
3541
+ }
3320
3542
  if (!next) return;
3321
3543
  this.#activeSessionAdmission = next;
3322
3544
  next.ready.resolve();
@@ -3398,6 +3620,9 @@ export class AgentSession {
3398
3620
  continuationAdmission?: ScheduledContinuationAdmission,
3399
3621
  options?: {
3400
3622
  allowDuringClosing?: boolean;
3623
+ closedSelectionTransaction?: symbol;
3624
+ selectionTransaction?: symbol;
3625
+ onAfterReadyForTests?: () => Promise<void>;
3401
3626
  bypassSelectionFenceGeneration?: number;
3402
3627
  allowPromptContinuationReentry?: boolean;
3403
3628
  idleDelivery?: boolean;
@@ -3442,8 +3667,11 @@ export class AgentSession {
3442
3667
  ) {
3443
3668
  await awaitPromptInvocationPreflight(this.#selectionFenceTail, signal);
3444
3669
  }
3670
+ const closedSelectionAuthorized = (): boolean =>
3671
+ options?.closedSelectionTransaction !== undefined &&
3672
+ options.closedSelectionTransaction === this.#authorizedClosedSelectionTransaction;
3445
3673
  if (
3446
- this.#sessionAdmissionClosed ||
3674
+ (this.#sessionAdmissionClosed && !closedSelectionAuthorized()) ||
3447
3675
  ((this.#sessionAdmissionClosing || this.#isDisposed) && options?.allowDuringClosing !== true)
3448
3676
  )
3449
3677
  throw this.#sessionAdmissionBusyError();
@@ -3464,6 +3692,7 @@ export class AgentSession {
3464
3692
  settled: Promise.withResolvers<void>(),
3465
3693
  released: false,
3466
3694
  selectionFenceGeneration: this.#selectionFenceGeneration,
3695
+ ...(options?.selectionTransaction ? { selectionTransaction: options.selectionTransaction } : {}),
3467
3696
  ...(kind === "prompt" ? { continuationCapability: Symbol("scheduled-continuation") } : {}),
3468
3697
  };
3469
3698
  const releaseEntry = () => {
@@ -3483,39 +3712,33 @@ export class AgentSession {
3483
3712
  releaseEntry();
3484
3713
  throw error;
3485
3714
  }
3486
- if (
3487
- this.#sessionAdmissionClosed ||
3488
- ((this.#sessionAdmissionClosing || this.#isDisposed) && options?.allowDuringClosing !== true)
3489
- ) {
3490
- entry.released = true;
3491
- entry.settled.resolve();
3492
- if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
3493
- this.#activateNextSessionAdmission();
3494
- throw this.#sessionAdmissionBusyError();
3495
- }
3496
- // Re-check the handoff fence after activation: a prompt queued before the
3497
- // transition began must not start once the fence is up.
3498
- if (kind === "prompt" && this.#handoffTransitionActive) {
3499
- entry.released = true;
3500
- entry.settled.resolve();
3501
- if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
3502
- this.#activateNextSessionAdmission();
3503
- throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
3504
- code: "busy",
3505
- });
3506
- }
3507
-
3508
- const release = () => {
3509
- releaseEntry();
3510
- };
3511
3715
  try {
3716
+ await options?.onAfterReadyForTests?.();
3717
+ if (
3718
+ entry.released ||
3719
+ (this.#sessionAdmissionClosed && !closedSelectionAuthorized()) ||
3720
+ ((this.#sessionAdmissionClosing || this.#isDisposed) && options?.allowDuringClosing !== true)
3721
+ ) {
3722
+ throw this.#sessionAdmissionBusyError();
3723
+ }
3724
+ // Re-check the handoff fence after activation: a prompt queued before the
3725
+ // transition began must not start once the fence is up.
3726
+ if (kind === "prompt" && this.#handoffTransitionActive) {
3727
+ throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
3728
+ code: "busy",
3729
+ });
3730
+ }
3731
+
3732
+ const release = () => {
3733
+ releaseEntry();
3734
+ };
3512
3735
  return await this.#sessionAdmissionContext.run(entry, () => body({ release }));
3513
3736
  } finally {
3514
- release();
3737
+ releaseEntry();
3515
3738
  }
3516
3739
  }
3517
3740
 
3518
- async #closeSessionAdmission(): Promise<void> {
3741
+ async #closeSessionAdmission(options?: { waitForActive?: boolean }): Promise<void> {
3519
3742
  this.#sessionAdmissionClosing = true;
3520
3743
  const active = this.#activeSessionAdmission;
3521
3744
  if (active?.kind === "prompt") {
@@ -3523,6 +3746,29 @@ export class AgentSession {
3523
3746
  this.#promptPreflightCancellationGeneration++;
3524
3747
  this.#promptPreflightAbortController.abort();
3525
3748
  }
3749
+ if (options?.waitForActive === false) {
3750
+ this.#authorizedClosedSelectionTransaction =
3751
+ active?.kind === "prompt"
3752
+ ? undefined
3753
+ : (active?.selectionTransaction ?? this.#selectionAwaitingMutationTransaction);
3754
+ this.#sessionAdmissionClosed = true;
3755
+ const queued = this.#sessionAdmissionQueue.splice(0);
3756
+ for (const entry of queued) {
3757
+ entry.released = true;
3758
+ entry.ready.resolve();
3759
+ entry.settled.resolve();
3760
+ }
3761
+ // Only an active prompt may outlive bounded disposal. A selection mutates
3762
+ // session/model state and must finish (or observe the closed fence) before
3763
+ // teardown closes those resources; its own finally resolves the fence tail.
3764
+ if (active?.kind === "selection") await active.settled.promise;
3765
+ // An abort-ignoring prompt may have queued selection fences behind it, so
3766
+ // that one case must bypass the fence. Otherwise the fence covers a
3767
+ // selection between its validation and mutation admissions as well as one
3768
+ // currently admitted.
3769
+ if (active?.kind !== "prompt") await this.#selectionFenceTail;
3770
+ return;
3771
+ }
3526
3772
  if (active) await active.settled.promise;
3527
3773
  await this.#selectionFenceTail;
3528
3774
  this.#sessionAdmissionClosed = true;
@@ -4027,11 +4273,8 @@ export class AgentSession {
4027
4273
  lease: RunResourceProducerLease | undefined,
4028
4274
  workerIntegrationSettled: boolean,
4029
4275
  ): Promise<void> {
4030
- const publicationContext = {
4031
- sessionId: this.sessionId,
4032
- cwd: this.sessionManager.getCwd(),
4033
- sessionFile: this.sessionManager.getSessionFile(),
4034
- };
4276
+ const publicationContext = this.#captureCoordinatorRuntimeStatePersistContext();
4277
+
4035
4278
  const publicationScope = (pending as AgentSessionEvent & { scope?: AttemptScopeRef }).scope as
4036
4279
  | AttemptScope
4037
4280
  | undefined;
@@ -4102,7 +4345,6 @@ export class AgentSession {
4102
4345
  error: String(error),
4103
4346
  },
4104
4347
  );
4105
- logger.warn("Failed to persist terminal coordinator runtime state", { error: String(error) });
4106
4348
  },
4107
4349
  );
4108
4350
  extensionDelivery = this.#queueExtensionEvent(
@@ -4122,9 +4364,7 @@ export class AgentSession {
4122
4364
  ),
4123
4365
  );
4124
4366
  await extensionDelivery;
4125
- void terminalPersistence.catch(error =>
4126
- logger.warn("Terminal persistence continued after SDK publication", { error }),
4127
- );
4367
+ void terminalPersistence;
4128
4368
  } else {
4129
4369
  // Non-SDK/public publication has already awaited worker integration above.
4130
4370
  }
@@ -5376,21 +5616,26 @@ export class AgentSession {
5376
5616
 
5377
5617
  async #runToolSessionTransitionCleanups(): Promise<void> {
5378
5618
  const cleanups = Array.from(this.#toolSessionTransitionCleanups);
5379
- this.#toolSessionTransitionCleanups.clear();
5380
5619
  const results = await Promise.allSettled(cleanups.map(async cleanup => await cleanup()));
5381
- for (const result of results) {
5382
- if (result.status === "rejected")
5383
- logger.warn("Tool session transition cleanup failed", { error: String(result.reason) });
5620
+ const failures: unknown[] = [];
5621
+ for (let index = 0; index < results.length; index++) {
5622
+ const result = results[index]!;
5623
+ if (result.status === "fulfilled") this.#toolSessionTransitionCleanups.delete(cleanups[index]!);
5624
+ else failures.push(result.reason);
5384
5625
  }
5626
+ if (failures.length > 0) throw new AggregateError(failures, "Tool session transition cleanup failed.");
5385
5627
  }
5386
5628
 
5387
5629
  async #runToolSessionCleanups(): Promise<void> {
5388
5630
  const cleanups = Array.from(this.#toolSessionCleanups);
5389
- this.#toolSessionCleanups.clear();
5390
5631
  const results = await Promise.allSettled(cleanups.map(async cleanup => await cleanup()));
5391
- for (const result of results) {
5392
- if (result.status === "rejected") logger.warn("Tool session cleanup failed", { error: String(result.reason) });
5632
+ const failures: unknown[] = [];
5633
+ for (let index = 0; index < results.length; index++) {
5634
+ const result = results[index]!;
5635
+ if (result.status === "fulfilled") this.#toolSessionCleanups.delete(cleanups[index]!);
5636
+ else failures.push(result.reason);
5393
5637
  }
5638
+ if (failures.length > 0) throw new AggregateError(failures, "Tool session cleanup failed.");
5394
5639
  }
5395
5640
 
5396
5641
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
@@ -5663,6 +5908,10 @@ export class AgentSession {
5663
5908
  // First statement of the listener: the observation must precede every claim,
5664
5909
  // reservation, and async hop this handler performs.
5665
5910
  this.#observeCoordinatorToolEvent(event);
5911
+ // The run that owned the steering queue is over. Decide ONCE, synchronously
5912
+ // and before any ownership gate, what happens to steering it never consumed
5913
+ // — a late terminal of a superseded run must still settle its own steering.
5914
+ if (event.type === "agent_end" && event.disownedSteering?.length) this.#settleDisownedSteering(event);
5666
5915
  // Reserve canonical message order synchronously. Agent listeners are not
5667
5916
  // awaited, so a tool-result spill may yield while a later continuation
5668
5917
  // otherwise overtakes it in persisted/display context.
@@ -5781,7 +6030,7 @@ export class AgentSession {
5781
6030
  #coordinatorPersistQueue: Promise<void> = Promise.resolve();
5782
6031
 
5783
6032
  #recordPostPublicationOutcome(
5784
- context: { sessionId: string; cwd: string; sessionFile: string | undefined },
6033
+ context: CoordinatorRuntimeStatePersistContext,
5785
6034
  correlationId: string | undefined,
5786
6035
  kind: "worker_integration" | "terminal_persistence",
5787
6036
  outcome: { status: "completed" | "failed" | "timed_out"; error?: string },
@@ -5801,19 +6050,12 @@ export class AgentSession {
5801
6050
  const persist = async () => {
5802
6051
  if (barrier) {
5803
6052
  await barrier;
5804
- return await persistCoordinatorWorkerIntegrationOutcome(
5805
- {
5806
- sessionId: this.sessionId,
5807
- cwd: this.sessionManager.getCwd(),
5808
- sessionFile: this.sessionManager.getSessionFile(),
5809
- },
5810
- {
5811
- ...outcome,
5812
- kind,
5813
- correlationId,
5814
- error: sanitizePostPublicationError(outcome.error),
5815
- },
5816
- );
6053
+ return await persistCoordinatorWorkerIntegrationOutcome(context, {
6054
+ ...outcome,
6055
+ kind,
6056
+ correlationId,
6057
+ error: sanitizePostPublicationError(outcome.error),
6058
+ });
5817
6059
  }
5818
6060
  return generation === this.#coordinatorPersistGeneration
5819
6061
  ? await persistCoordinatorWorkerIntegrationOutcome(context, {
@@ -5830,10 +6072,30 @@ export class AgentSession {
5830
6072
  if (barrier) this.#trackReleasedBarrierPersist(queued);
5831
6073
  else this.#trackUnbarrieredCoordinatorPersist(queued);
5832
6074
  void queued.catch(error =>
5833
- logger.warn("Failed to persist terminal reconciliation outcome", { error: String(error) }),
6075
+ this.#warnPersistFailure(
6076
+ "Failed to persist terminal reconciliation outcome",
6077
+ error,
6078
+ context.stateFile,
6079
+ context.sessionId,
6080
+ ),
5834
6081
  );
5835
6082
  }
5836
6083
 
6084
+ #captureCoordinatorRuntimeStatePersistContext(): CoordinatorRuntimeStatePersistContext {
6085
+ const context = {
6086
+ sessionId: this.sessionId,
6087
+ cwd: this.sessionManager.getCwd(),
6088
+ sessionFile: this.sessionManager.getSessionFile(),
6089
+ };
6090
+ const explicitStateFile = process.env[GJC_COORDINATOR_SESSION_STATE_FILE_ENV]?.trim();
6091
+ const stateFile =
6092
+ explicitStateFile ||
6093
+ (context.sessionId.trim()
6094
+ ? path.join(sessionRuntimeDir(context.cwd, context.sessionId), "runtime-state.json")
6095
+ : null);
6096
+ return { ...context, stateFile };
6097
+ }
6098
+
5837
6099
  /**
5838
6100
  * Reserve this event's place in the sidecar write order.
5839
6101
  *
@@ -5854,26 +6116,14 @@ export class AgentSession {
5854
6116
  if (barrier) {
5855
6117
  const run = async () => {
5856
6118
  await barrier;
5857
- await this.#persistRuntimeStateInBackground(
5858
- event,
5859
- {
5860
- sessionId: this.sessionId,
5861
- cwd: this.sessionManager.getCwd(),
5862
- sessionFile: this.sessionManager.getSessionFile(),
5863
- },
5864
- observation,
5865
- propagateFailure,
5866
- );
6119
+ const context = this.#captureCoordinatorRuntimeStatePersistContext();
6120
+ await this.#persistRuntimeStateInBackground(event, context, observation, propagateFailure);
5867
6121
  };
5868
6122
  const queued = barrier.then(() => this.#appendCoordinatorPersist(run));
5869
6123
  this.#trackReleasedBarrierPersist(queued);
5870
6124
  return queued;
5871
6125
  }
5872
- const context = {
5873
- sessionId: this.sessionId,
5874
- cwd: this.sessionManager.getCwd(),
5875
- sessionFile: this.sessionManager.getSessionFile(),
5876
- };
6126
+ const context = this.#captureCoordinatorRuntimeStatePersistContext();
5877
6127
  const generation = admission?.persistGeneration ?? this.#coordinatorPersistGeneration;
5878
6128
  const run = () =>
5879
6129
  generation === this.#coordinatorPersistGeneration
@@ -5886,18 +6136,41 @@ export class AgentSession {
5886
6136
 
5887
6137
  async #persistRuntimeStateInBackground(
5888
6138
  event: AgentSessionEvent,
5889
- context: { sessionId: string; cwd: string; sessionFile: string | undefined },
6139
+ context: CoordinatorRuntimeStatePersistContext,
5890
6140
  observation: CoordinatorToolObservation | undefined,
5891
6141
  propagateFailure: boolean,
5892
6142
  ): Promise<void> {
5893
6143
  try {
5894
6144
  await persistCoordinatorRuntimeStateFromEvent(event, context, observation);
5895
6145
  } catch (error) {
5896
- logger.warn("Failed to persist coordinator runtime state", { event: event.type });
6146
+ this.#warnPersistFailure(
6147
+ "Failed to persist coordinator runtime state",
6148
+ error,
6149
+ context.stateFile,
6150
+ context.sessionId,
6151
+ { event: event.type },
6152
+ );
5897
6153
  if (propagateFailure) throw error;
5898
6154
  }
5899
6155
  }
5900
6156
 
6157
+ #warnPersistFailure(
6158
+ message: string,
6159
+ error: unknown,
6160
+ stateFile: string | null,
6161
+ sessionId: string,
6162
+ extra: Record<string, unknown> = {},
6163
+ ): void {
6164
+ const fields = {
6165
+ ...extra,
6166
+ ...sessionStateLockFailureFields(error),
6167
+ ...(stateFile === null ? {} : { stateFile }),
6168
+ };
6169
+ const key = stateFile ?? `session:${sessionId}`;
6170
+ if (shouldWarnPersistFailure(key, error)) logger.warn(message, fields);
6171
+ else logger.debug(message, { ...fields, suppressed: true });
6172
+ }
6173
+
5901
6174
  async #emitSessionEvent(event: AgentSessionEvent, eventLease?: RunResourceProducerLease): Promise<void> {
5902
6175
  const attemptScope = (event as AgentSessionEvent & { scope?: AttemptScope }).scope;
5903
6176
  if (event.type === "turn_start") {
@@ -5955,9 +6228,7 @@ export class AgentSession {
5955
6228
  // Do not let a lock-hostile sidecar suppress the terminal event itself.
5956
6229
  const terminalPersistence = this.#queueCoordinatorRuntimeStatePersist(event);
5957
6230
  this.#emit(event);
5958
- void terminalPersistence.catch(error => {
5959
- logger.warn("Failed to persist terminal coordinator runtime state", { error: String(error) });
5960
- });
6231
+ void terminalPersistence;
5961
6232
  await this.#emitExtensionEvent(event);
5962
6233
  return;
5963
6234
  }
@@ -7161,10 +7432,14 @@ export class AgentSession {
7161
7432
  skip("queue_drained");
7162
7433
  return;
7163
7434
  }
7164
- // A scheduled same-turn continuation of a terminally aborted turn is
7165
- // denied at the final synchronous boundary before agent.continue
7166
- // entry; no await intervenes between this check and method entry.
7167
- // Owned-completion deliveries are NOT affected (they use followUp).
7435
+ // Final synchronous boundary before agent.continue* entry; no await
7436
+ // intervenes between here and method entry. A same-turn continuation
7437
+ // of a terminally aborted turn is denied here, while an independent
7438
+ // root request queued past that fence mints its fresh identity here —
7439
+ // so a cancelled or emptied continuation never leaves the session on
7440
+ // an identity that escapes the fence. Owned-completion deliveries are
7441
+ // NOT affected (they use followUp).
7442
+ if (this.#hasQueuedFreshRootRequest()) this.#resumeFromOwnedCompletion();
7168
7443
  if (this.#isTurnContinuationBlocked()) {
7169
7444
  skip("terminal_turn");
7170
7445
  return;
@@ -8712,26 +8987,86 @@ export class AgentSession {
8712
8987
  */
8713
8988
  dispose(): Promise<void> {
8714
8989
  this.#evalExecutionDisposing = true;
8715
- if (this.#disposePromise) return this.#disposePromise;
8716
- const { promise, resolve, reject } = Promise.withResolvers<void>();
8717
- this.#disposePromise = promise;
8718
- this.#abortAdmissionEpoch++;
8719
- this.#isDisposed = true;
8720
- this.#disposeAbortController.abort();
8721
- this.#disposeAdmissionClosed = this.#closeSessionAdmission();
8722
- this.#disposePostPromptDrain = this.#cancelPostPromptTasks();
8723
- this.#settleDeliveredOwnedRegistrations(this.#pendingNextTurnMessages.map(entry => entry.message));
8724
- this.#pendingNextTurnMessages = [];
8725
- this.#scheduledHiddenNextTurnGeneration = undefined;
8726
- this.#abortActiveMidRunBarriers();
8727
- this.abortCompaction();
8728
- this.abortRetry();
8729
- this.#quarantineAgentRunResources();
8730
- this.agent.abort();
8731
- this.agent.setMainAttemptScopeObserver(undefined);
8732
- this.#disconnectFromAgent();
8733
- void this.#dispose().then(resolve, reject);
8734
- return promise;
8990
+ if (this.#disposeCompleted) return Promise.resolve();
8991
+ if (this.#disposeTerminalError !== undefined) return Promise.reject(this.#disposeTerminalError);
8992
+ if (this.#disposeCallerPromise) return this.#disposeCallerPromise;
8993
+ if (!this.#disposeRunPromise) {
8994
+ this.#disposeDeadline = Date.now() + this.#disposeTimeoutMs;
8995
+ this.#disposeDeadlineExpired = Promise.withResolvers<void>();
8996
+ this.#disposeDeadlineTimer = setTimeout(() => this.#disposeDeadlineExpired?.resolve(), this.#disposeTimeoutMs);
8997
+ this.#disposeDeadlineTimer.unref?.();
8998
+ this.#abortAdmissionEpoch++;
8999
+ this.#isDisposed = true;
9000
+ // Invalidate every coordinator event admitted before disposal. Handlers may
9001
+ // still unwind, but their captured generation can no longer enqueue a write.
9002
+ this.#coordinatorPersistGeneration += 1;
9003
+ this.#disposeAbortController.abort();
9004
+ // Disposal owns a bounded Agent abort below. Waiting for the active prompt's
9005
+ // admission here would put that unbounded prompt ahead of the abort budget.
9006
+ this.#disposeAdmissionClosed = this.#closeSessionAdmission({ waitForActive: false });
9007
+ this.#disposePostPromptDrain = this.#cancelPostPromptTasks();
9008
+ this.#settleDeliveredOwnedRegistrations(this.#pendingNextTurnMessages.map(entry => entry.message));
9009
+ this.#pendingNextTurnMessages = [];
9010
+ this.#scheduledHiddenNextTurnGeneration = undefined;
9011
+ this.#abortActiveMidRunBarriers();
9012
+ this.abortCompaction();
9013
+ this.abortRetry();
9014
+ this.#quarantineAgentRunResources();
9015
+ this.agent.abort();
9016
+ this.agent.setMainAttemptScopeObserver(undefined);
9017
+ this.#disconnectFromAgent();
9018
+ this.#disposeRunPromise = this.#dispose()
9019
+ .then(
9020
+ () => {
9021
+ this.#disposeCompleted = true;
9022
+ },
9023
+ error => {
9024
+ this.#disposeTerminalError = error;
9025
+ throw error;
9026
+ },
9027
+ )
9028
+ .finally(() => {
9029
+ if (this.#disposeDeadlineTimer) clearTimeout(this.#disposeDeadlineTimer);
9030
+ this.#disposeDeadlineTimer = undefined;
9031
+ });
9032
+ void this.#disposeRunPromise.catch(() => {});
9033
+ }
9034
+ const teardown = this.#disposeRunPromise;
9035
+ const deadlineExpired = this.#disposeDeadlineExpired;
9036
+ if (!deadlineExpired || Date.now() >= this.#disposeDeadline) {
9037
+ return Promise.reject(new SessionDisposalIncompleteError(this.#disposeActiveStepLabel));
9038
+ }
9039
+ const boundedTeardown = Promise.race([
9040
+ teardown,
9041
+ deadlineExpired.promise.then(() => {
9042
+ throw new SessionDisposalIncompleteError(this.#disposeActiveStepLabel);
9043
+ }),
9044
+ ]);
9045
+ this.#disposeCallerPromise = boundedTeardown;
9046
+ void boundedTeardown
9047
+ .finally(() => {
9048
+ if (this.#disposeCallerPromise === boundedTeardown) this.#disposeCallerPromise = undefined;
9049
+ })
9050
+ .catch(() => {});
9051
+ return boundedTeardown;
9052
+ }
9053
+
9054
+ /** Join the retained teardown owner without allocating another public deadline. */
9055
+ awaitDisposeCompletion(): Promise<void> {
9056
+ return this.#disposeRunPromise ?? this.dispose();
9057
+ }
9058
+
9059
+ setDisposeTimeoutForTests(timeoutMs: number): void {
9060
+ if (this.#disposeRunPromise) throw new Error("Cannot change the disposal timeout after disposal has started.");
9061
+ this.#disposeTimeoutMs = Math.max(0, timeoutMs);
9062
+ }
9063
+
9064
+ trackPostPromptTaskForTests(task: Promise<void>): void {
9065
+ this.#trackPostPromptTask(task, this.#selectionFenceGeneration);
9066
+ }
9067
+
9068
+ requestWorkerIntegrationForTests(): void {
9069
+ this.#requestWorkerIntegrationAttempt();
8735
9070
  }
8736
9071
 
8737
9072
  /** Cancel the active logical run domain, including managed continuations detached from Agent's active attempt. */
@@ -8741,9 +9076,33 @@ export class AgentSession {
8741
9076
  }
8742
9077
 
8743
9078
  async #dispose(): Promise<void> {
9079
+ const disposeFailures: Array<{ label: string; error: unknown; critical: boolean }> = [];
9080
+ const awaitDisposeStep = async <T>(
9081
+ label: string,
9082
+ operation: Promise<T>,
9083
+ critical = false,
9084
+ ): Promise<T | undefined> => {
9085
+ this.#disposeActiveStepLabel = label;
9086
+ try {
9087
+ return await operation;
9088
+ } catch (error) {
9089
+ disposeFailures.push({ label, error, critical });
9090
+ logger.warn("Session dispose step failed", { label, error: String(error) });
9091
+ return undefined;
9092
+ } finally {
9093
+ if (this.#disposeActiveStepLabel === label) this.#disposeActiveStepLabel = undefined;
9094
+ }
9095
+ };
9096
+ const drainCoordinatorEventHandlers = async (): Promise<void> => {
9097
+ while (this.#coordinatorEventHandlers.size > 0) {
9098
+ await Promise.allSettled([...this.#coordinatorEventHandlers]);
9099
+ }
9100
+ };
8744
9101
  const admissionClosed = this.#disposeAdmissionClosed ?? this.#closeSessionAdmission();
8745
- await admissionClosed;
8746
- await this.sessionManager.closeCwdMoveAdmission();
9102
+ await awaitDisposeStep("session admission close", admissionClosed);
9103
+ await awaitDisposeStep("cwd move admission close", this.sessionManager.closeCwdMoveAdmission());
9104
+ await awaitDisposeStep("admitted cwd transition", this.sessionManager.joinCwdTransition(), true);
9105
+ await awaitDisposeStep("active cwd readers", this.sessionManager.joinCwdReaders(), true);
8747
9106
  this.#isDisposed = true;
8748
9107
  // Reject new direct Python starts as soon as disposal begins (synchronously,
8749
9108
  // before any await) so callers cannot race a start against teardown.
@@ -8761,60 +9120,71 @@ export class AgentSession {
8761
9120
  // R2-5: join any in-flight mid-run maintenance invocation before teardown so the
8762
9121
  // abort-aware maintenance promise (already aborted above) settles and cannot touch
8763
9122
  // torn-down state afterward.
8764
- await this.#waitForActiveMidRunMaintenance();
9123
+ await awaitDisposeStep("mid-run maintenance", this.#waitForActiveMidRunMaintenance());
8765
9124
  // R2-5: give the aborted Agent run a bounded chance to settle, then force-
8766
9125
  // invalidate its run id so an abort-ignoring provider/tool cannot emit a late
8767
9126
  // message_end after teardown. Mirrors AgentSession.abort({ timeoutMs }).
9127
+ const idleBudgetMs = Math.max(0, Math.min(2_000, this.#disposeDeadline - Date.now()));
8768
9128
  const disposeIdleSettled = await Promise.race([
8769
9129
  this.agent.waitForIdle().then(
8770
9130
  () => true,
8771
9131
  () => true,
8772
9132
  ),
8773
- Bun.sleep(2_000).then(() => false),
9133
+ Bun.sleep(idleBudgetMs).then(() => false),
8774
9134
  ]);
8775
9135
  if (!disposeIdleSettled) {
8776
- try {
8777
- this.agent.forceAbort("Session disposed", this.#activeLogicalRunId);
8778
- } catch {
8779
- // AttemptScope handle may not be registered for sessions
8780
- // that don't participate in the facility (e.g. test mocks).
8781
- this.agent.forceAbort("Session disposed");
8782
- }
9136
+ this.#forceSessionRecovery();
8783
9137
  }
8784
- await this.#agentEndPublicationPromise;
8785
- await this.#queuedExtensionEvents;
8786
- await this.#agentEndHandlingPromise;
9138
+ await awaitDisposeStep("active compaction", this.#compactionCompletion ?? Promise.resolve(), true);
9139
+ await awaitDisposeStep(
9140
+ "active auto-compaction",
9141
+ Promise.allSettled([...this.#autoCompactionCompletions]).then(() => {}),
9142
+ true,
9143
+ );
9144
+ await awaitDisposeStep("agent end publication", this.#agentEndPublicationPromise);
9145
+ await awaitDisposeStep("queued extension events", this.#queuedExtensionEvents);
9146
+ await awaitDisposeStep("agent end handling", this.#agentEndHandlingPromise);
9147
+ await awaitDisposeStep("coordinator event handlers", drainCoordinatorEventHandlers(), true);
8787
9148
  // Drain the sidecar write order for the same reason the two queues above are
8788
9149
  // drained: each entry writes under the native identity-bound state-file lock, so a
8789
9150
  // a still-queued write would run after the session that owns it is gone — releasing
8790
9151
  // a lock whose owner no longer exists, and under `bun test --isolate` calling into
8791
9152
  // the addon after the runtime tore down the context it was scheduled in. The chain
8792
9153
  // is already failure-absorbing, so this only waits.
8793
- await this.#coordinatorPersistQueue;
9154
+ await awaitDisposeStep("coordinator persistence", this.#coordinatorPersistQueue, true);
8794
9155
  // Terminal publication records its reconciliation from a promise reaction, which
8795
9156
  // can append after the queue snapshot above was awaited. Drain the tracked writes
8796
9157
  // again so disposal cannot return while that late reconciliation still owns a lock.
8797
- await this.#drainUnbarrieredCoordinatorPersists();
9158
+ await awaitDisposeStep("unbarriered coordinator persistence", this.#drainUnbarrieredCoordinatorPersists(), true);
8798
9159
  this.#pendingBackgroundExchanges = [];
8799
9160
  this.#drainTerminalOwnedYieldEntries();
8800
9161
 
8801
9162
  this.agent.setOnBeforeYield(undefined);
8802
9163
  try {
8803
9164
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
8804
- await this.#extensionRunner.emit({ type: "session_shutdown" });
9165
+ await awaitDisposeStep(
9166
+ "session shutdown handlers",
9167
+ this.#extensionRunner.emit({ type: "session_shutdown" }),
9168
+ );
8805
9169
  }
8806
9170
  } catch (error) {
9171
+ if (isSessionDisposalIncompleteError(error)) throw error;
8807
9172
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
8808
9173
  }
8809
9174
  this.#workflowGateEmitter?.fence?.();
8810
9175
  this.#workflowGateEmitter = undefined;
8811
9176
  this.#notifyWorkflowGateEmitterChanged(this.sessionId, undefined);
8812
- await this.#flushWorkerIntegrationAttempt();
9177
+ await awaitDisposeStep("worker integration", this.#flushWorkerIntegrationAttempt(), true);
9178
+ await awaitDisposeStep(
9179
+ "worker integration ownership",
9180
+ this.#workerIntegrationScheduler?.joinOwnedRequests() ?? Promise.resolve(),
9181
+ true,
9182
+ );
8813
9183
  // Worker integration completion can enqueue terminal reconciliation while the
8814
9184
  // flush above is pending; drain that late producer before disposal resolves.
8815
- await this.#coordinatorPersistQueue;
8816
- await this.#drainUnbarrieredCoordinatorPersists();
8817
- await (this.#disposePostPromptDrain ?? this.#cancelPostPromptTasks());
9185
+ await awaitDisposeStep("post-worker coordinator persistence", this.#coordinatorPersistQueue, true);
9186
+ await awaitDisposeStep("post-worker unbarriered persistence", this.#drainUnbarrieredCoordinatorPersists(), true);
9187
+ await awaitDisposeStep("post-prompt tasks", this.#disposePostPromptDrain ?? this.#cancelPostPromptTasks(), true);
8818
9188
  // Cancel jobs this agent registered so a subagent's teardown doesn't
8819
9189
  // leak its background bash/task work into the parent's manager. Only
8820
9190
  // the session that owns the manager goes on to dispose it (which itself
@@ -8829,32 +9199,37 @@ export class AgentSession {
8829
9199
  // — a shared manager would otherwise keep the claim past this session's
8830
9200
  // disposal.
8831
9201
  this.#drainTerminalOwnedYieldEntries();
8832
- await Promise.allSettled(this.#deferredOwnerShutdownFinalizations);
9202
+ await awaitDisposeStep("deferred owner shutdown", Promise.allSettled(this.#deferredOwnerShutdownFinalizations));
8833
9203
  const ownedAsyncManager = this.#ownedAsyncJobManager;
8834
9204
  if (ownedAsyncManager && this.#disposeAsyncJobManager) {
8835
- const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
9205
+ const drained = await awaitDisposeStep("async job manager", ownedAsyncManager.dispose({ timeoutMs: 3_000 }));
8836
9206
  const deliveryState = ownedAsyncManager.getDeliveryState();
8837
9207
  if (drained === false && deliveryState) {
8838
9208
  logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
8839
9209
  }
8840
- if (AsyncJobManager.instance() === ownedAsyncManager) {
8841
- AsyncJobManager.setInstance(undefined);
9210
+ if (drained === false) {
9211
+ await awaitDisposeStep(
9212
+ "async job manager retained runners",
9213
+ ownedAsyncManager.awaitRetainedDisposalCompletion(),
9214
+ true,
9215
+ );
8842
9216
  }
9217
+ if (AsyncJobManager.instance() === ownedAsyncManager) AsyncJobManager.setInstance(undefined);
8843
9218
  }
8844
- await this.#runToolSessionTransitionCleanups();
8845
- await this.#runToolSessionCleanups();
9219
+ await awaitDisposeStep("tool session transition cleanups", this.#runToolSessionTransitionCleanups(), true);
9220
+ await awaitDisposeStep("tool session cleanups", this.#runToolSessionCleanups(), true);
8846
9221
  // Only disconnect the MCP manager THIS session owns (top-level sessions that
8847
9222
  // connected plugin-bundle MCP servers). Subagents and callers that merely
8848
9223
  // observe the process-global manager must never tear down a manager they do
8849
9224
  // not own. Mirrors the ownedAsyncJobManager rule above.
8850
9225
  const ownedMcpManager = this.#ownedMcpManager;
8851
9226
  if (ownedMcpManager) {
8852
- await ownedMcpManager.releaseLeases();
9227
+ await awaitDisposeStep("MCP lease release", ownedMcpManager.releaseLeases());
8853
9228
  if (MCPManager.instance() === ownedMcpManager) {
8854
9229
  MCPManager.setInstance(undefined);
8855
9230
  }
8856
9231
  }
8857
- await shutdownAllLspClients();
9232
+ await awaitDisposeStep("LSP shutdown", shutdownAllLspClients());
8858
9233
  // F13: release only THIS session's browser tabs on dispose (kill:false → remote
8859
9234
  // browsers disconnect, headless close gracefully). Scoped by the session id the
8860
9235
  // browser tool tagged tabs with, so other live sessions' tabs are untouched.
@@ -8873,29 +9248,40 @@ export class AgentSession {
8873
9248
  this.#unregisterMovePublicationListener = undefined;
8874
9249
  this.#unregisterAfterMoveListener?.();
8875
9250
  this.#unregisterAfterMoveListener = undefined;
8876
- await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
8877
- logger.warn("session dispose: releaseTabsForOwner failed", { error }),
9251
+ await awaitDisposeStep("browser tab release", releaseTabsForOwner(this.sessionManager.getSessionId()), true);
9252
+ const pythonExecutionsSettled = await awaitDisposeStep(
9253
+ "Python execution preparation",
9254
+ this.#prepareEvalExecutionsForDispose(),
8878
9255
  );
8879
- const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
8880
9256
  if (!pythonExecutionsSettled) {
8881
9257
  logger.warn(
8882
9258
  "Detaching retained Python kernel ownership during dispose while Python execution is still active",
8883
9259
  );
8884
9260
  }
8885
- await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
8886
- await disposeVmContextsByOwner(this.#evalKernelOwnerId);
9261
+ await awaitDisposeStep("Python kernel sessions", disposeKernelSessionsByOwner(this.#evalKernelOwnerId));
9262
+ await awaitDisposeStep("VM contexts", disposeVmContextsByOwner(this.#evalKernelOwnerId));
8887
9263
  this.#releasePowerAssertion();
8888
9264
  // Disconnect the agent event listener BEFORE closing session resources so a late
8889
9265
  // provider/tool message_end cannot append to the closing SessionManager.
8890
9266
  this.#disconnectFromAgent();
8891
- await this.memoryBackend.dispose();
8892
- if (this.#workspaceTreeService) await this.#workspaceTreeService.dispose();
8893
- if (this.#networkPrewarmService) await this.#networkPrewarmService.dispose();
9267
+ await awaitDisposeStep("memory backend", this.memoryBackend.dispose(), true);
9268
+ if (this.#workspaceTreeService)
9269
+ await awaitDisposeStep("workspace tree", this.#workspaceTreeService.dispose(), true);
9270
+ if (this.#networkPrewarmService)
9271
+ await awaitDisposeStep("network prewarm", this.#networkPrewarmService.dispose(), true);
8894
9272
  this.#modelRegistry.authStorage?.releaseCredentialScope(this.credentialSessionId);
8895
- await this.sessionManager.close();
9273
+ let criticalDisposeError: unknown;
9274
+ try {
9275
+ await this.#closeSessionManagerForDispose();
9276
+ } catch (error) {
9277
+ criticalDisposeError = error;
9278
+ logger.warn("Session manager close failed during dispose; continuing independent cleanup", {
9279
+ error: String(error),
9280
+ });
9281
+ }
8896
9282
  this.#closeAllProviderSessions("dispose");
8897
9283
  const hindsightState = this.getHindsightSessionState();
8898
- await hindsightState?.dispose();
9284
+ await awaitDisposeStep("hindsight state", hindsightState?.dispose() ?? Promise.resolve());
8899
9285
  this.setHindsightSessionState(undefined);
8900
9286
  if (this.#unsubscribeAppendOnly) {
8901
9287
  this.#unsubscribeAppendOnly();
@@ -8903,7 +9289,35 @@ export class AgentSession {
8903
9289
  }
8904
9290
  this.#eventListeners = [];
8905
9291
  this.#rebuildEventListenerSnapshot();
9292
+ if (criticalDisposeError !== undefined) {
9293
+ disposeFailures.push({ label: "session manager close", error: criticalDisposeError, critical: true });
9294
+ }
9295
+ if (disposeFailures.length > 0) {
9296
+ throw new AggregateError(
9297
+ disposeFailures.map(failure => failure.error),
9298
+ `Session disposal did not reach a terminal state: ${disposeFailures.map(failure => failure.label).join(", ")}`,
9299
+ );
9300
+ }
9301
+ }
9302
+
9303
+ async #closeSessionManagerForDispose(): Promise<void> {
9304
+ let retryDelayMs = 50;
9305
+ for (;;) {
9306
+ const outcome = await this.sessionManager.closeStrict();
9307
+ if (outcome.kind === "closed") {
9308
+ SessionManager.releaseProcessCwdOwnership(this.sessionManager);
9309
+ return;
9310
+ }
9311
+ if (outcome.kind === "close_unknown") {
9312
+ SessionManager.releaseProcessCwdOwnership(this.sessionManager);
9313
+ throw outcome.error;
9314
+ }
9315
+ logger.warn("Session manager close remains retryable during dispose", { error: outcome.error.message });
9316
+ await Bun.sleep(retryDelayMs);
9317
+ retryDelayMs = Math.min(retryDelayMs * 2, 1_000);
9318
+ }
8906
9319
  }
9320
+
8907
9321
  /**
8908
9322
  * Strict writer close for ACP session delete. On the first attempt it flushes
8909
9323
  * pending writes, then returns the certainty-aware close outcome so the caller
@@ -9079,6 +9493,13 @@ export class AgentSession {
9079
9493
  await this.sessionManager.flush();
9080
9494
  }
9081
9495
 
9496
+ /** Test seam: hold prompt admission while bypassing a pending selection fence. */
9497
+ runWithPromptAdmissionForTests(body: () => Promise<void>): Promise<void> {
9498
+ return this.#withSessionAdmission("prompt", async () => body(), undefined, undefined, {
9499
+ bypassSelectionFenceGeneration: this.#selectionFenceGeneration - 1,
9500
+ });
9501
+ }
9502
+
9082
9503
  queueCoordinatorRuntimeStatePersistForTests(event: AgentSessionEvent, gate: Promise<void>): Promise<void> {
9083
9504
  this.#agentEventAdmission.set(event, {
9084
9505
  persistGeneration: this.#coordinatorPersistGeneration,
@@ -9383,23 +9804,77 @@ export class AgentSession {
9383
9804
  this.#setGuardedAgentTools(activeTools);
9384
9805
  }
9385
9806
 
9807
+ /**
9808
+ * Subscribe to folds of this session's foreground waits. Backed by the
9809
+ * session-owned manager (or the process-global instance for legacy setups)
9810
+ * so the SDK host can publish one `bash_folded` frame per fold.
9811
+ */
9812
+ onJobFold(listener: (event: JobFoldEvent) => void): () => void {
9813
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
9814
+ if (!manager) return () => {};
9815
+ const ownerId = this.#agentId;
9816
+ return manager.onFold(event => {
9817
+ if (ownerId && manager.getJob(event.jobId)?.ownerId !== ownerId) return;
9818
+ listener(event);
9819
+ });
9820
+ }
9821
+
9386
9822
  /**
9387
9823
  * Ask the active managed foreground bash call to return as a background job.
9388
9824
  * Returns false when no supported foreground tool is currently backgroundable.
9825
+ * `reason` names the trigger (`chord` for the keybinding, `sdk_control` for
9826
+ * the `bash.background` control) and is recorded on the folded job.
9827
+ */
9828
+ async requestForegroundBashBackground(reason: FoldReason = "chord", adapter?: FoldAdapter): Promise<boolean> {
9829
+ return (await this.requestForegroundBashBackgroundOutcome(reason, adapter)).status === "folded";
9830
+ }
9831
+
9832
+ /**
9833
+ * Typed variant of {@link requestForegroundBashBackground} for the SDK
9834
+ * `bash.background` control (C52), which must distinguish "this fold moved
9835
+ * the wait" from "an earlier fold already moved it" from "nothing to fold".
9836
+ *
9837
+ * `already_backgrounded` is reported when no wait is foldable but this
9838
+ * session still has a running job that a fold (steer, chord, timer, or a
9839
+ * prior control call) moved out of the foreground: `metadata.foldReason` is
9840
+ * set only by folds, never by `async: true` starts, so it is the exact
9841
+ * evidence that the wait the client is asking about has already left.
9389
9842
  */
9390
- async requestForegroundBashBackground(): Promise<boolean> {
9391
- if (!this.#foldCoordinator.hasFoldableParticipant()) return false;
9843
+ async requestForegroundBashBackgroundOutcome(
9844
+ reason: FoldReason = "chord",
9845
+ adapter?: FoldAdapter,
9846
+ ): Promise<ForegroundFoldOutcome> {
9847
+ if (!this.#foldCoordinator.hasFoldableParticipant()) {
9848
+ return this.#hasRunningFoldedJob() ? { status: "already_backgrounded" } : { status: "no_active_bash" };
9849
+ }
9392
9850
  try {
9393
- const result = await this.#foldCoordinator.requestFold();
9394
- return result.status === "folded";
9851
+ const result = await this.#foldCoordinator.requestFold(adapter, reason);
9852
+ if (result.status === "folded") {
9853
+ if (reason === "steer") {
9854
+ this.emitNotice(
9855
+ "info",
9856
+ `Folded ${result.receipt.label} into background job ${result.receipt.jobId} so your message can be handled now.`,
9857
+ "fold",
9858
+ );
9859
+ }
9860
+ return { status: "folded", jobId: result.receipt.jobId };
9861
+ }
9862
+ if (result.status === "unavailable" && this.#hasRunningFoldedJob()) return { status: "already_backgrounded" };
9863
+ return { status: "not_foldable", reason: result.reason };
9395
9864
  } catch (error) {
9396
9865
  logger.warn("Foreground fold request failed", {
9397
9866
  error: error instanceof Error ? error.message : String(error),
9398
9867
  });
9399
- return false;
9868
+ return { status: "not_foldable", reason: error instanceof Error ? error.message : String(error) };
9400
9869
  }
9401
9870
  }
9402
9871
 
9872
+ #hasRunningFoldedJob(): boolean {
9873
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
9874
+ if (!manager) return false;
9875
+ const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
9876
+ return manager.getRunningJobs(ownerFilter).some(job => job.metadata?.foldReason !== undefined);
9877
+ }
9403
9878
  /**
9404
9879
  * Get all configured tool names (built-in via --tools or default, plus custom tools).
9405
9880
  */
@@ -9690,7 +10165,10 @@ export class AgentSession {
9690
10165
  // The lease keeps writers out for the whole execution, so this can
9691
10166
  // only trip if a caller bypassed the lease; surface it rather than
9692
10167
  // returning a result computed against a retired cwd.
9693
- if (this.sessionManager.getCwdGeneration() !== admittedGeneration) {
10168
+ if (
10169
+ this.sessionManager.getCwdGeneration() !== admittedGeneration &&
10170
+ !this.sessionManager.consumeNestedCwdMove(admittedGeneration)
10171
+ ) {
9694
10172
  throw new Error(
9695
10173
  "Session working directory changed while this tool executed; retry against the new cwd.",
9696
10174
  );
@@ -10490,8 +10968,8 @@ export class AgentSession {
10490
10968
  }
10491
10969
 
10492
10970
  /** Current interrupt mode */
10493
- get interruptMode(): "immediate" | "wait" {
10494
- return this.agent.getInterruptMode();
10971
+ get toolInterruptPolicy(): "abort_tools" | "finish_tools" {
10972
+ return this.agent.getToolInterruptPolicy();
10495
10973
  }
10496
10974
 
10497
10975
  /** Current session file path, or undefined if sessions are disabled */
@@ -10578,7 +11056,7 @@ export class AgentSession {
10578
11056
  thinking: this.#thinkingLevel ?? "off",
10579
11057
  steeringMode: this.steeringMode,
10580
11058
  followUpMode: this.followUpMode,
10581
- interruptMode: this.interruptMode,
11059
+ toolInterruptPolicy: this.toolInterruptPolicy,
10582
11060
  };
10583
11061
  }
10584
11062
 
@@ -11363,7 +11841,12 @@ export class AgentSession {
11363
11841
  if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
11364
11842
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
11365
11843
 
11366
- if (expandPromptTemplates && text.startsWith("/skill:") && !options?.images?.length) {
11844
+ if (
11845
+ expandPromptTemplates &&
11846
+ text.startsWith("/") &&
11847
+ isNamespacedSkillSlashCommandName(text.slice(1)) &&
11848
+ !options?.images?.length
11849
+ ) {
11367
11850
  const skillCommands = new Map(this.skills.map(skill => [getSkillSlashCommandName(skill), skill]));
11368
11851
  const invocations = parseSkillInvocations(text, skillCommands);
11369
11852
  if (invocations.length === 1) {
@@ -11449,7 +11932,15 @@ export class AgentSession {
11449
11932
 
11450
11933
  // If streaming, queue via steer() or followUp() based on option.
11451
11934
  // Abort unwind is awaited above so a successor is not busy against leftover in-flight.
11452
- if (this.#isLiveTurnBusy() && !waitedForAbortUnwind) {
11935
+ // An explicit streamingBehavior submitted while another prompt still holds
11936
+ // or awaits session admission (agent idle, session not yet live) is queued
11937
+ // as well: entering admission behind that prompt would only get this
11938
+ // message cancelled by the predecessor's preflight reset and lost.
11939
+ const promptAdmissionContended =
11940
+ options?.streamingBehavior !== undefined &&
11941
+ (this.#activeSessionAdmission?.kind === "prompt" ||
11942
+ this.#sessionAdmissionQueue.some(entry => entry.kind === "prompt"));
11943
+ if ((this.#isLiveTurnBusy() || promptAdmissionContended) && !waitedForAbortUnwind) {
11453
11944
  if (!options?.streamingBehavior) {
11454
11945
  throw new AgentBusyError();
11455
11946
  }
@@ -11459,7 +11950,10 @@ export class AgentSession {
11459
11950
  claimsGenuineUserIntent,
11460
11951
  });
11461
11952
  } else {
11462
- await this.#queueSteer(expandedText, options?.images, { claimsGenuineUserIntent });
11953
+ await this.#queueSteer(expandedText, options?.images, {
11954
+ claimsGenuineUserIntent,
11955
+ forceOneAtATime: options.steerQueuePolicy === "sequential",
11956
+ });
11463
11957
  }
11464
11958
  if (workflowIntentDiff) {
11465
11959
  this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
@@ -11472,6 +11966,35 @@ export class AgentSession {
11472
11966
  await this.#withSessionAdmission(
11473
11967
  "prompt",
11474
11968
  async admission => {
11969
+ // A prompt that carries an explicit streamingBehavior and was admitted
11970
+ // behind another prompt (that prompt reset the preflight generation
11971
+ // while this one waited in the admission queue) is a busy submission:
11972
+ // queue it against the now-live turn instead of cancelling it.
11973
+ if (
11974
+ options?.streamingBehavior !== undefined &&
11975
+ this.#isPromptPreflightCancelled(admissionGeneration, admissionSignal) &&
11976
+ options.preflightSignal?.aborted !== true &&
11977
+ !this.#abortUnwind
11978
+ ) {
11979
+ admission.release();
11980
+ if (options.streamingBehavior === "followUp") {
11981
+ await this.#queueFollowUp(expandedText, options?.images, {
11982
+ forceOneAtATime: options.followUpQueuePolicy === "sequential",
11983
+ claimsGenuineUserIntent,
11984
+ });
11985
+ } else {
11986
+ await this.#queueSteer(expandedText, options?.images, {
11987
+ claimsGenuineUserIntent,
11988
+ forceOneAtATime: options.steerQueuePolicy === "sequential",
11989
+ });
11990
+ }
11991
+ if (workflowIntentDiff) {
11992
+ this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
11993
+ }
11994
+ if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
11995
+ else options?.onPreflightAccepted?.();
11996
+ return;
11997
+ }
11475
11998
  this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
11476
11999
  if (workflowIntentDiff) {
11477
12000
  this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
@@ -11646,6 +12169,7 @@ export class AgentSession {
11646
12169
  | "streamingBehavior"
11647
12170
  | "toolChoice"
11648
12171
  | "followUpQueuePolicy"
12172
+ | "steerQueuePolicy"
11649
12173
  | "onPreflightAccepted"
11650
12174
  | "onPreflightAcceptCommit"
11651
12175
  | "preflightSignal"
@@ -11681,6 +12205,7 @@ export class AgentSession {
11681
12205
  await this.sendCustomMessage(message, {
11682
12206
  deliverAs: options.streamingBehavior,
11683
12207
  followUpQueuePolicy: options.followUpQueuePolicy,
12208
+ steerQueuePolicy: options.steerQueuePolicy,
11684
12209
  });
11685
12210
  return;
11686
12211
  }
@@ -11761,6 +12286,7 @@ export class AgentSession {
11761
12286
  skipPostPromptRecoveryWait?: boolean;
11762
12287
  predecessorAgentEndHold?: symbol;
11763
12288
  admissionLease?: SessionAdmissionLease;
12289
+ skipInitialSteeringPoll?: boolean;
11764
12290
  onRunAccepted?: (handle: AttemptRunHandle) => void;
11765
12291
  onFinalPreflight?: (context: { hasPendingNextTurnMessages: boolean }) => Promise<boolean>;
11766
12292
  resetRetryReplaySafety?: boolean;
@@ -12091,6 +12617,7 @@ export class AgentSession {
12091
12617
 
12092
12618
  const agentPromptOptions = {
12093
12619
  ...(options?.toolChoice ? { toolChoice: options.toolChoice } : undefined),
12620
+ ...(options?.skipInitialSteeringPoll ? { skipInitialSteeringPoll: true } : {}),
12094
12621
  ...this.#managedFallbackPromptOptions(),
12095
12622
  ...(options?.sdkRunToken ? { sdkRunToken: options.sdkRunToken } : {}),
12096
12623
  onRunAccepted: (handle: AttemptRunHandle) => {
@@ -12261,8 +12788,8 @@ export class AgentSession {
12261
12788
  this.setFollowUpMode(mode);
12262
12789
  return true;
12263
12790
  }
12264
- if (kind === "interrupt" && (mode === "immediate" || mode === "wait")) {
12265
- this.setInterruptMode(mode);
12791
+ if (kind === "tool_interrupt" && (mode === "abort_tools" || mode === "finish_tools")) {
12792
+ this.setToolInterruptPolicy(mode);
12266
12793
  return true;
12267
12794
  }
12268
12795
  return false;
@@ -12438,6 +12965,7 @@ export class AgentSession {
12438
12965
  onPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
12439
12966
  external?: boolean;
12440
12967
  sdkRunToken?: string;
12968
+ forceOneAtATime?: boolean;
12441
12969
  },
12442
12970
  ): Promise<void> {
12443
12971
  this.#assertNoHandoffTransition();
@@ -12446,6 +12974,26 @@ export class AgentSession {
12446
12974
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
12447
12975
  if (images && images.length > 0) content.push(...images);
12448
12976
  const message = { role: "user" as const, content, attribution: "user" as const, timestamp: Date.now() };
12977
+ // Enqueue-time admission: the Agent only accepts a steer into a live,
12978
+ // non-aborted run. Anything else (idle, unwinding after agent_end, another
12979
+ // prompt still in preflight, aborting) has no run to steer, so it is routed
12980
+ // as a sequential follow-up owned by the next turn instead of being parked
12981
+ // in a queue nobody owns.
12982
+ const admission = this.agent.steer(message, options?.forceOneAtATime ? { forceOneAtATime: true } : undefined);
12983
+ if (!admission.admitted) {
12984
+ await this.#queueFollowUp(text, images, {
12985
+ forceOneAtATime: options?.forceOneAtATime,
12986
+ claimsGenuineUserIntent: options?.claimsGenuineUserIntent,
12987
+ onPromoted: options?.onPromoted,
12988
+ sdkRunToken: options?.sdkRunToken,
12989
+ onQueued: message => {
12990
+ if (this.#abortUnwind && !options?.forceOneAtATime) this.#abortUnwindSteerFallbacks.push(message);
12991
+ },
12992
+ });
12993
+ this.#scheduleNonAdmittedSteerContinuation();
12994
+ return;
12995
+ }
12996
+ if (options?.forceOneAtATime) this.#sequentialSteerMessages.add(message);
12449
12997
  this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText, undefined, message));
12450
12998
  if (options?.external) {
12451
12999
  this.#externalSteerMessages.add(message);
@@ -12457,22 +13005,42 @@ export class AgentSession {
12457
13005
  const epoch = this.#claimDeepInterviewUserIntent();
12458
13006
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
12459
13007
  }
12460
- this.agent.steer(message);
12461
- // A live agent loop polls the steering queue at every tool/turn boundary
12462
- // and consumes this message on its own. But when a steer is queued while no
12463
- // loop is actively running — e.g. the session still reports busy only
12464
- // because a finished prompt is unwinding (deferred agent_end / post-prompt
12465
- // work) — nothing delivers it until the next explicit prompt or a
12466
- // user-interrupt abort, so it stalls until the user presses Esc. Schedule a
12467
- // continue so the steer is delivered promptly. A live loop (or an
12468
- // already-drained queue) makes the scheduled continue a no-op.
12469
- if (!this.#cancelAndSubmitInProgress && this.#canAutoContinueForSteer()) {
12470
- this.#scheduleAgentContinue({
12471
- shouldContinue: () => this.#canAutoContinueForSteer() && this.agent.hasQueuedSteering(),
12472
- rescheduleOnBusy: true,
12473
- continueQueuedOnly: true,
12474
- });
12475
- }
13008
+ }
13009
+
13010
+ /**
13011
+ * A steer that was not admitted into a live run was requeued as a follow-up.
13012
+ * `#queueFollowUp` already schedules delivery when the session is idle; this
13013
+ * covers the unwind window where the session still reports busy only because
13014
+ * a finished prompt is unwinding (deferred agent_end / post-prompt work), so
13015
+ * the follow-up gate refuses while no loop actually owns the queue. A live
13016
+ * loop, a prompt still in preflight, or a drained queue makes the scheduled
13017
+ * continue a no-op.
13018
+ */
13019
+ #scheduleNonAdmittedSteerContinuation(): void {
13020
+ // `#queueFollowUp` already schedules delivery when the session is idle; this
13021
+ // only covers the window where that gate refuses (a finished prompt is
13022
+ // unwinding) while no loop owns the queue. The resumable-tail requirement
13023
+ // is kept here on purpose: a message queued against a session that never
13024
+ // ran a turn waits for the next explicit prompt, as it always has.
13025
+ if (this.#cancelAndSubmitInProgress || !this.#canAutoContinueForSteer()) return;
13026
+ if (this.#canAutoContinueForFollowUp()) return;
13027
+ this.#scheduleQueuedDelivery();
13028
+ }
13029
+
13030
+ /**
13031
+ * Schedule delivery of whatever is queued. Used by every path that enqueues
13032
+ * without going through `#queueFollowUp` (custom-message steer/follow-up
13033
+ * routing, disown re-routing, held-steering fallback, a run's terminal): the
13034
+ * run that would have polled the queue is gone, so nothing else owns it.
13035
+ */
13036
+ #scheduleQueuedDelivery(delayMs?: number): void {
13037
+ if (this.#cancelAndSubmitInProgress || !this.#canDeliverQueuedMessages()) return;
13038
+ this.#scheduleAgentContinue({
13039
+ ...(delayMs === undefined ? {} : { delayMs }),
13040
+ shouldContinue: () => this.#canDeliverQueuedMessages() && this.agent.hasQueuedMessages(),
13041
+ rescheduleOnBusy: true,
13042
+ continueQueuedOnly: true,
13043
+ });
12476
13044
  }
12477
13045
 
12478
13046
  /**
@@ -12486,6 +13054,7 @@ export class AgentSession {
12486
13054
  claimsGenuineUserIntent?: boolean;
12487
13055
  onPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
12488
13056
  sdkRunToken?: string;
13057
+ onQueued?: (message: AgentMessage) => void;
12489
13058
  },
12490
13059
  ): Promise<QueuedFollowUpOwner> {
12491
13060
  this.#assertNoHandoffTransition();
@@ -12495,6 +13064,7 @@ export class AgentSession {
12495
13064
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
12496
13065
  if (images && images.length > 0) content.push(...images);
12497
13066
  const message = { role: "user" as const, content, attribution: "user" as const, timestamp: Date.now() };
13067
+ options?.onQueued?.(message);
12498
13068
  // Display entry carries the message identity so positional editing can never
12499
13069
  // misaddress a deferred SDK follow-up held outside the Agent live queue.
12500
13070
  const displayEntry = this.#createQueuedDisplayEntry(displayText, undefined, message);
@@ -12578,6 +13148,7 @@ export class AgentSession {
12578
13148
  * Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
12579
13149
  */
12580
13150
  #canAutoContinueForFollowUp(): boolean {
13151
+ if (this.#abortUnwind) return false;
12581
13152
  if (this.isStreaming) return false;
12582
13153
  if (this.isCompacting) return false;
12583
13154
  if (this.isBashRunning) return false;
@@ -12603,6 +13174,24 @@ export class AgentSession {
12603
13174
  }
12604
13175
  }
12605
13176
 
13177
+ /**
13178
+ * Gate for delivering queued messages through `continueQueuedMessages()`.
13179
+ * Unlike the steer/follow-up auto-continue gates this does NOT require an
13180
+ * assistant tail: a queued-only continuation delivers the queued turn without
13181
+ * replaying the tail, so a tool-result tail (paused fold, aborted turn) is
13182
+ * still deliverable. A live agent loop, a retry, or an in-flight
13183
+ * compaction/bash/eval owns the session and defers delivery.
13184
+ */
13185
+ #canDeliverQueuedMessages(): boolean {
13186
+ if (this.#abortUnwind) return false;
13187
+ if (this.agent.state.isStreaming) return false;
13188
+ if (this.isRetrying) return false;
13189
+ if (this.isCompacting) return false;
13190
+ if (this.isBashRunning) return false;
13191
+ if (this.isEvalRunning) return false;
13192
+ return true;
13193
+ }
13194
+
12606
13195
  /**
12607
13196
  * Gate for idle / winding-down steer auto-continue. Unlike the follow-up gate
12608
13197
  * this checks `agent.state.isStreaming` (a live agent loop) rather than the
@@ -12611,6 +13200,7 @@ export class AgentSession {
12611
13200
  * false here because it polls the steering queue itself.
12612
13201
  */
12613
13202
  #canAutoContinueForSteer(): boolean {
13203
+ if (this.#abortUnwind) return false;
12614
13204
  if (this.agent.state.isStreaming) return false;
12615
13205
  if (this.isRetrying) return false;
12616
13206
  const messages = this.agent.state.messages;
@@ -12618,6 +13208,13 @@ export class AgentSession {
12618
13208
  return last?.role === "assistant" || last?.role === "bashExecution" || last?.role === "pythonExecution";
12619
13209
  }
12620
13210
 
13211
+ #finalizeAbortUnwindSteerBatch(): void {
13212
+ if (this.agent.getSteeringMode() === "all" && this.#abortUnwindSteerFallbacks.length > 1) {
13213
+ this.agent.markFollowUpBatch(this.#abortUnwindSteerFallbacks);
13214
+ }
13215
+ this.#abortUnwindSteerFallbacks = [];
13216
+ }
13217
+
12621
13218
  queueDeferredMessage(message: CustomMessage): void {
12622
13219
  this.#queueHiddenNextTurnMessage(message, true, "turn");
12623
13220
  }
@@ -12906,6 +13503,7 @@ export class AgentSession {
12906
13503
  triggerTurn?: boolean;
12907
13504
  deliverAs?: "steer" | "followUp" | "nextTurn";
12908
13505
  followUpQueuePolicy?: "respect-mode" | "sequential";
13506
+ steerQueuePolicy?: "respect-mode" | "sequential";
12909
13507
  /** INTERNAL trusted option: origin of a hidden next-turn message —
12910
13508
  * "turn" (produced by the current turn's continuation machinery,
12911
13509
  * purged by a terminal abort) or "external" (background producers,
@@ -12931,6 +13529,53 @@ export class AgentSession {
12931
13529
  const epoch = preclaimedUserIntentEpoch ?? this.#claimDeepInterviewUserIntent();
12932
13530
  this.#deepInterviewGenuineUserMessageEpochs.set(appMessage, epoch);
12933
13531
  }
13532
+ // An explicit steer request routes the same way at every session phase: the
13533
+ // Agent admits it only into a live run, and anything else becomes a
13534
+ // sequential follow-up the next turn delivers. Routing this on the session's
13535
+ // streaming flag instead would strand it during the post-prompt unwind (no
13536
+ // loop owns the queue) and append it without a turn while idle.
13537
+ if (options?.deliverAs === "steer") {
13538
+ // A handoff transition owns the session; a background/custom trigger
13539
+ // (cron, monitor, skill) must not steer against the outgoing turn.
13540
+ this.#assertNoHandoffTransition();
13541
+ const sequential = options.steerQueuePolicy === "sequential" ? { forceOneAtATime: true } : undefined;
13542
+ if (this.agent.steer(appMessage, sequential).admitted) {
13543
+ if (sequential) this.#sequentialSteerMessages.add(appMessage);
13544
+ this.#bindCustomDisplayEntry(appMessage, "steer");
13545
+ } else {
13546
+ // No live run to steer, so this becomes next-turn work. Provenance —
13547
+ // never the caller's `triggerTurn` delivery preference — decides what a
13548
+ // terminal abort's retained fence does with it: genuine user input and
13549
+ // an independent external producer are root requests of their own and
13550
+ // survive; a continuation produced by the aborted turn's own machinery
13551
+ // (preview/plan reminder) is dropped with its removal disposition
13552
+ // rather than leaking into an unrelated later turn.
13553
+ const independentOfTurn = appMessage.attribution === "user" || options.origin === "external";
13554
+ if (this.#isTurnContinuationBlocked() && !independentOfTurn) {
13555
+ const displayTag = readPendingDisplayTag(appMessage.details);
13556
+ if (displayTag !== undefined) {
13557
+ this.#steeringMessages = this.#steeringMessages.filter(entry => entry.tag !== displayTag);
13558
+ this.#followUpMessages = this.#followUpMessages.filter(entry => entry.tag !== displayTag);
13559
+ }
13560
+ this.#settleDeliveredOwnedRegistrations([appMessage]);
13561
+ return;
13562
+ }
13563
+ this.agent.followUp(appMessage, sequential);
13564
+ if (this.#abortUnwind && !sequential) this.#abortUnwindSteerFallbacks.push(appMessage);
13565
+ // The chip now describes follow-up work: keep its mode and identity
13566
+ // aligned with where the executable message actually landed.
13567
+ this.#bindCustomDisplayEntry(appMessage, "followUp");
13568
+ if (independentOfTurn) {
13569
+ this.#externalFollowUps.add(appMessage);
13570
+ // Past a retained terminal fence this message is a NEW root: record
13571
+ // the requirement on the message itself so it survives a deferred
13572
+ // delivery and disappears with the message if it is removed.
13573
+ if (this.#isTurnContinuationBlocked()) this.#freshRootLineageRequests.add(appMessage);
13574
+ }
13575
+ this.#scheduleQueuedDelivery();
13576
+ }
13577
+ return;
13578
+ }
12934
13579
  if (this.isStreaming) {
12935
13580
  // A handoff transition owns the session; a background/custom trigger (cron,
12936
13581
  // monitor, skill) must not steer/follow-up/queue against the outgoing turn
@@ -12941,14 +13586,15 @@ export class AgentSession {
12941
13586
  return;
12942
13587
  }
12943
13588
 
12944
- if (options?.deliverAs === "followUp") {
12945
- this.agent.followUp(
12946
- appMessage,
12947
- options.followUpQueuePolicy === "sequential" ? { forceOneAtATime: true } : undefined,
12948
- );
12949
- } else {
12950
- this.agent.steer(appMessage);
12951
- }
13589
+ this.agent.followUp(
13590
+ appMessage,
13591
+ options?.followUpQueuePolicy === "sequential" ? { forceOneAtATime: true } : undefined,
13592
+ );
13593
+ this.#bindCustomDisplayEntry(appMessage, "followUp");
13594
+ // The session can report streaming while no agent loop owns the queue
13595
+ // (post-prompt unwind): without a scheduled delivery a cron/monitor
13596
+ // notification would sit queued until unrelated activity.
13597
+ if (!this.agent.state.isStreaming) this.#scheduleQueuedDelivery();
12952
13598
  return;
12953
13599
  }
12954
13600
 
@@ -13469,8 +14115,37 @@ export class AgentSession {
13469
14115
  const fromIndex = queue.findIndex(entry => entry.sequence === sequence);
13470
14116
  if (fromIndex === -1) return false;
13471
14117
  const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1;
14118
+ if (toIndex < 0 || toIndex >= queue.length) return false;
14119
+ // Identity-based reorder, for the same reason removal is: a display index is
14120
+ // NOT an Agent queue index. Hidden `display:false` custom messages occupy
14121
+ // executable slots without a chip, so mapping the visible position straight
14122
+ // onto the Agent queue reorders an unrelated message while the UI moves the
14123
+ // chip. Resolve both endpoints from the bound messages instead.
14124
+ const fromMessage = queue[fromIndex]?.message;
14125
+ const toMessage = queue[toIndex]?.message;
14126
+ const executable = mode === "steer" ? this.agent.snapshotSteering() : this.agent.snapshotFollowUp();
14127
+ const fromDeferred = fromMessage !== undefined && this.#deferredSdkFollowUps.includes(fromMessage);
14128
+ const toDeferred = toMessage !== undefined && this.#deferredSdkFollowUps.includes(toMessage);
14129
+ // A deferred SDK follow-up is held OUTSIDE the Agent queue, so its display
14130
+ // row cannot be reordered against live messages: the two rows live in
14131
+ // different backing stores and no single index describes both. Reorder
14132
+ // within the deferred store when both rows are deferred; refuse a
14133
+ // cross-store move rather than silently moving an unrelated message.
14134
+ if (fromDeferred || toDeferred) {
14135
+ if (!fromDeferred || !toDeferred || fromMessage === undefined || toMessage === undefined) return false;
14136
+ const deferredFrom = this.#deferredSdkFollowUps.indexOf(fromMessage);
14137
+ const deferredTo = this.#deferredSdkFollowUps.indexOf(toMessage);
14138
+ if (deferredFrom === -1 || deferredTo === -1) return false;
14139
+ const [moved] = this.#deferredSdkFollowUps.splice(deferredFrom, 1);
14140
+ if (moved === undefined) return false;
14141
+ this.#deferredSdkFollowUps.splice(deferredTo, 0, moved);
14142
+ return this.#moveQueuedDisplayEntry(queue, fromIndex, toIndex);
14143
+ }
14144
+ const agentFrom = fromMessage === undefined ? fromIndex : executable.indexOf(fromMessage);
14145
+ const agentTo = toMessage === undefined ? toIndex : executable.indexOf(toMessage);
14146
+ if (agentFrom === -1 || agentTo === -1) return false;
13472
14147
  const agentMoved =
13473
- mode === "steer" ? this.agent.moveSteer(fromIndex, toIndex) : this.agent.moveFollowUp(fromIndex, toIndex);
14148
+ mode === "steer" ? this.agent.moveSteer(agentFrom, agentTo) : this.agent.moveFollowUp(agentFrom, agentTo);
13474
14149
  if (!agentMoved) return false;
13475
14150
  return this.#moveQueuedDisplayEntry(queue, fromIndex, toIndex);
13476
14151
  }
@@ -13830,6 +14505,7 @@ export class AgentSession {
13830
14505
  | "internal";
13831
14506
  silent?: boolean;
13832
14507
  sdkRunToken?: string;
14508
+ preserveCompaction?: boolean;
13833
14509
  }): void {
13834
14510
  const abortGoalState = this.getGoalModeState();
13835
14511
  this.#suppressNextGoalReminderAfterAbortGoalId =
@@ -13846,7 +14522,8 @@ export class AgentSession {
13846
14522
  this.#promptPreflightAbortController.abort();
13847
14523
  this.#promptPreflightAbortController = new AbortController();
13848
14524
  this.#scheduledHiddenNextTurnGeneration = undefined;
13849
- this.abortCompaction();
14525
+ if (options?.preserveCompaction) this.#autoCompactionAbortController?.abort();
14526
+ else this.abortCompaction();
13850
14527
  this.abortHandoff();
13851
14528
  this.abortBash();
13852
14529
  this.abortEval();
@@ -13902,6 +14579,7 @@ export class AgentSession {
13902
14579
  | "tool_abort"
13903
14580
  | "internal";
13904
14581
  silent?: boolean;
14582
+ preserveCompaction?: boolean;
13905
14583
  }): Promise<AbortOutcome> {
13906
14584
  // Advance the admission epoch SYNCHRONOUSLY, before any await and before the
13907
14585
  // shared-unwind branch. A second abort that piggybacks on the first unwind
@@ -13919,6 +14597,7 @@ export class AgentSession {
13919
14597
  this.#promptPreflightAbortController.abort();
13920
14598
  this.#promptPreflightAbortController = new AbortController();
13921
14599
  this.#drainTerminalOwnedYieldEntries();
14600
+ if (options?.preserveCompaction !== true) this.abortCompaction();
13922
14601
  const overlappingPostPromptDrain = this.#cancelPostPromptTasks();
13923
14602
  // Abort visibility is per-request: a later real abort must not inherit an
13924
14603
  // earlier silent abort's suppression and swallow the user-visible notice.
@@ -13956,6 +14635,16 @@ export class AgentSession {
13956
14635
  this.#abortUnwind = unwind.promise;
13957
14636
  try {
13958
14637
  this.#abortOptions(options);
14638
+ // Decide what the run's unconsumed steering becomes BEFORE it ends: a
14639
+ // user interrupt (Esc) hands it to a fresh turn; every other cause
14640
+ // (teardown, session switch, compaction, handoff, tool abort) drops it.
14641
+ // A cancel-and-submit owns its snapshot and re-queues explicitly.
14642
+ if (this.#disownedSteeringDisposition === undefined) {
14643
+ this.#disownedSteeringDisposition =
14644
+ (options?.cause ?? "internal") === "user_interrupt" && !this.#cancelAndSubmitInProgress
14645
+ ? { kind: "rearm" }
14646
+ : { kind: "drop" };
14647
+ }
13959
14648
  this.#drainTerminalOwnedYieldEntries();
13960
14649
  const postPromptDrain = this.#cancelPostPromptTasks();
13961
14650
  const managedLogicalRunId =
@@ -13984,15 +14673,18 @@ export class AgentSession {
13984
14673
  this.agent.requestRunTerminal(managedLogicalRunId, { stopReason: "cancelled" });
13985
14674
  this.#flushPendingBackgroundExchanges();
13986
14675
  await this.#awaitAbortedTurnTerminal();
14676
+ this.#finalizeAbortUnwindSteerBatch();
14677
+ // Steering disowned by the aborted run was re-routed as follow-ups
14678
+ // on its agent_end; a user interrupt resumes into them promptly.
13987
14679
  if (
13988
14680
  !this.#cancelAndSubmitInProgress &&
13989
14681
  (options?.cause ?? "internal") === "user_interrupt" &&
13990
- this.agent.hasQueuedSteering()
14682
+ this.agent.hasQueuedMessages()
13991
14683
  ) {
13992
14684
  this.#scheduleAgentContinue({
13993
14685
  delayMs: 1,
13994
14686
  generation: this.#promptGeneration,
13995
- shouldContinue: () => this.agent.hasQueuedSteering(),
14687
+ shouldContinue: () => this.agent.hasQueuedMessages(),
13996
14688
  rescheduleOnBusy: true,
13997
14689
  continueQueuedOnly: true,
13998
14690
  });
@@ -14005,6 +14697,10 @@ export class AgentSession {
14005
14697
  if (this.#toolChoiceQueue.hasInFlight) this.#toolChoiceQueue.reject("aborted");
14006
14698
  }
14007
14699
  } finally {
14700
+ // No run was live to end (or its terminal already settled): the
14701
+ // disposition must not leak into an unrelated later run's exit.
14702
+ this.#disownedSteeringDisposition = undefined;
14703
+ this.#abortUnwindSteerFallbacks = [];
14008
14704
  this.#abortUnwind = undefined;
14009
14705
  this.#abortForceRecoveryStarted = false;
14010
14706
  unwind.resolve();
@@ -14024,6 +14720,7 @@ export class AgentSession {
14024
14720
  | "tool_abort"
14025
14721
  | "internal";
14026
14722
  silent?: boolean;
14723
+ preserveCompaction?: boolean;
14027
14724
  }): Promise<void> {
14028
14725
  const outcome = await this.#abortWithOutcome(options);
14029
14726
  if (outcome.kind === "error") throw outcome.cause;
@@ -14144,10 +14841,6 @@ export class AgentSession {
14144
14841
  }
14145
14842
  > {
14146
14843
  let registeredScope: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string } | undefined;
14147
- // The purge snapshot is consumed per-admission inside the terminal
14148
- // block; the rearm below (reachable only when a scope was registered,
14149
- // i.e. only after the purge ran) reads the same value.
14150
- let abortSteeringSnapshot: number | undefined;
14151
14844
  if (options.terminal) {
14152
14845
  // The terminal scope must be registered for the ABORTED turn's
14153
14846
  // lineage, never a successor's. A new root turn advances the attempt
@@ -14202,10 +14895,10 @@ export class AgentSession {
14202
14895
  };
14203
14896
  }
14204
14897
  // Terminal abort blocks STEERING continuations of the aborted turn:
14205
- // purge steering queued just before the abort won (the loop can
14206
- // exit on the abort signal without polling it) so it cannot alter
14207
- // the next user turn (review thread P2). The follow-up queue is
14208
- // preserved — owned-completion resumes must still deliver.
14898
+ // only a client steer admitted AFTER this abort's admission snapshot
14899
+ // survives; everything else was a continuation of the attempt being
14900
+ // terminated (review thread P2). The follow-up queue is preserved —
14901
+ // owned-completion resumes must still deliver.
14209
14902
  const turnSnapshotKey = `${lineageIdHash}:${this.#promptGeneration}`;
14210
14903
  const queuedTurnSnapshots = this.#terminalAbortSteeringSnapshots.get(turnSnapshotKey);
14211
14904
  const snapshotIndex =
@@ -14215,39 +14908,31 @@ export class AgentSession {
14215
14908
  const consumedSnapshot =
14216
14909
  queuedTurnSnapshots && snapshotIndex >= 0 ? queuedTurnSnapshots.splice(snapshotIndex, 1)[0] : undefined;
14217
14910
  if (consumedSnapshot) this.#terminalAbortSteeringSnapshotKeys.delete(consumedSnapshot.token);
14218
- abortSteeringSnapshot = consumedSnapshot?.seq ?? this.#steeringAdmissionSeq;
14911
+ const snapshotSeq: number = consumedSnapshot?.seq ?? this.#steeringAdmissionSeq;
14219
14912
  if (queuedTurnSnapshots?.length === 0) {
14220
14913
  this.#terminalAbortSteeringSnapshots.delete(turnSnapshotKey);
14221
14914
  }
14222
- const admittedAfterSnapshot = (message: AgentMessage): boolean =>
14223
- (this.#externalSteerAdmissionSeq.get(message) ?? 0) > (abortSteeringSnapshot ?? -1);
14224
- // Steering purge, STEERING queue only: the agent-wide removal
14225
- // would also wipe the follow-up queue, which owned-completion
14226
- // resumes must still deliver (review thread P1).
14227
- const steeringBeforePurge = this.agent.snapshotSteering();
14228
- const purgedSteering = steeringBeforePurge.filter(
14229
- message => !(this.#externalSteerMessages.has(message) && admittedAfterSnapshot(message)),
14230
- );
14231
- this.agent.removeQueuedMessages(
14232
- message => !(this.#externalSteerMessages.has(message) && admittedAfterSnapshot(message)),
14233
- "steering",
14234
- );
14235
- // Purged steering leaves without consumption: terminalize its accepted
14236
- // SDK submissions boundedly (#4668 review P1).
14237
- this.#fireQueuedRemovalHooks(purgedSteering);
14238
- // Keep the display list aligned with the ACTUAL purge decisions:
14239
- // preserved post-snapshot external steers stay visible and
14240
- // purged internal steers disappear — a stale entry would let the
14241
- // positional editing APIs remove a different preserved steer
14242
- // (review thread P2).
14243
- const steeringAfterPurge = new Set(this.agent.snapshotSteering());
14244
- const keptSteeringDisplays: QueuedDisplayEntry[] = [];
14245
- for (let displayIndex = 0; displayIndex < steeringBeforePurge.length; displayIndex++) {
14246
- if (steeringAfterPurge.has(steeringBeforePurge[displayIndex]!)) {
14247
- keptSteeringDisplays.push(this.#steeringMessages[displayIndex]!);
14248
- }
14249
- }
14250
- this.#steeringMessages = keptSteeringDisplays;
14915
+ const preserveSteering = (message: AgentMessage): boolean =>
14916
+ this.#externalSteerMessages.has(message) &&
14917
+ (this.#externalSteerAdmissionSeq.get(message) ?? 0) > snapshotSeq;
14918
+ // The live run disowns its steering on agent_end; the disposition
14919
+ // installed here is what settles it (drop pre-snapshot, re-route
14920
+ // post-snapshot external steers as a fresh turn). Nothing is
14921
+ // removed from the Agent queue by the session itself.
14922
+ // Overlapping terminal aborts of the same run may settle out of order:
14923
+ // the disposition only ever NARROWS. The highest admission snapshot
14924
+ // wins, and a preserve decision must satisfy every terminal abort that
14925
+ // registered, so a later stricter snapshot cannot be widened by an
14926
+ // older one settling afterwards.
14927
+ const previous = this.#disownedSteeringDisposition;
14928
+ this.#disownedSteeringDisposition =
14929
+ previous?.kind === "terminal"
14930
+ ? {
14931
+ kind: "terminal",
14932
+ snapshotSeq: Math.max(previous.snapshotSeq, snapshotSeq),
14933
+ preserve: (message: AgentMessage) => previous.preserve(message) && preserveSteering(message),
14934
+ }
14935
+ : { kind: "terminal", preserve: preserveSteering, snapshotSeq };
14251
14936
 
14252
14937
  // Remove ORDINARY follow-ups queued for the aborted turn: the
14253
14938
  // aborted loop exits without polling them, so a later unrelated
@@ -14302,6 +14987,7 @@ export class AgentSession {
14302
14987
  }
14303
14988
  const aborted = this.#runCancellationDomains.abort(handle);
14304
14989
  if (!aborted.ok) {
14990
+ this.#disownedSteeringDisposition = undefined;
14305
14991
  if (aborted.reason === "quarantined") {
14306
14992
  return {
14307
14993
  ...(await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: 0 })),
@@ -14318,6 +15004,9 @@ export class AgentSession {
14318
15004
  if (handle === this.agent.activeResourceRunId) this.agent.abort();
14319
15005
  const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs });
14320
15006
  if (proof.status === "unfenced") this.agent.resourceLedger.quarantine(handle);
15007
+ // The run's agent_end (if any) consumed the disposition; a settled or
15008
+ // already-ended run must not leave it for an unrelated later exit.
15009
+ this.#disownedSteeringDisposition = undefined;
14321
15010
  // Rearm surviving owned-completion follow-ups once the abort has
14322
15011
  // settled: the aborted loop exits before polling the follow-up queue,
14323
15012
  // so preserving the envelopes alone leaves them stranded until an
@@ -14357,19 +15046,10 @@ export class AgentSession {
14357
15046
  message =>
14358
15047
  ownedCompletionResumeAction(message as never) === "fresh" || this.#externalFollowUps.has(message),
14359
15048
  );
14360
- // A preserved post-snapshot client steer is also an independent
14361
- // root-turn request: the abort exits the loop and never polls the
14362
- // steering queue, so without a rearm the accepted steer stays
14363
- // stranded until unrelated activity (review thread P1).
14364
- const hasPreservedSteering = (): boolean =>
14365
- this.agent
14366
- .snapshotQueues()
14367
- .steering.some(
14368
- message =>
14369
- this.#externalSteerMessages.has(message) &&
14370
- (this.#externalSteerAdmissionSeq.get(message) ?? 0) > (abortSteeringSnapshot ?? -1),
14371
- );
14372
- if (hasPreservedFollowUp() || hasPreservedSteering()) {
15049
+ // A post-snapshot client steer preserved by the run's disown settlement
15050
+ // is already an external follow-up here (an independent root-turn
15051
+ // request), so one predicate covers both (review thread P1).
15052
+ if (hasPreservedFollowUp()) {
14373
15053
  // ANY preserved follow-up or steer — an owned-completion resume,
14374
15054
  // an independently requested external follow-up, or a
14375
15055
  // post-snapshot client steer — needs a fresh lineage before its
@@ -14384,7 +15064,7 @@ export class AgentSession {
14384
15064
  this.#scheduleAgentContinue({
14385
15065
  delayMs: 1,
14386
15066
  generation: this.#promptGeneration,
14387
- shouldContinue: () => hasPreservedFollowUp() || hasPreservedSteering(),
15067
+ shouldContinue: () => hasPreservedFollowUp(),
14388
15068
  // The rearmed continuation must consume the queued steer inside
14389
15069
  // the run acceptance (continueQueuedMessages) rather than
14390
15070
  // dequeue it later via getSteeringMessages: a terminal abort
@@ -14472,9 +15152,14 @@ export class AgentSession {
14472
15152
  ];
14473
15153
  };
14474
15154
  try {
15155
+ // The aborted run disowns its steering on agent_end; hold it here
15156
+ // (rather than re-routing) because this call owns the snapshot and
15157
+ // re-queues it against the NEW turn below.
15158
+ this.#disownedSteeringDisposition = { kind: "held" };
14475
15159
  const outcome = this.#cancelAndSubmitAbortOutcomeProviderForTests
14476
15160
  ? await this.#cancelAndSubmitAbortOutcomeProviderForTests()
14477
15161
  : await this.#abortWithOutcome({ cause: "user_interrupt", timeoutMs: 5_000 });
15162
+ this.#disownedSteeringDisposition = undefined;
14478
15163
  if (outcome.kind !== "settled") {
14479
15164
  restore();
14480
15165
  if (outcome.kind === "error") {
@@ -14516,18 +15201,29 @@ export class AgentSession {
14516
15201
  followUp: [...heldFollowUp, ...current.followUp],
14517
15202
  });
14518
15203
  };
15204
+ // Steers of the aborted turn stay STEERS of the new turn: they are
15205
+ // restored once the new run is accepted (below), so the Agent
15206
+ // admits them into a live run instead of re-labelling them as
15207
+ // follow-ups drained turn after turn. Follow-ups keep their queue.
15208
+ const heldSteering = selected ? [] : queueSnapshot.steering;
15209
+ const heldSteeringDisplays = selected ? [] : steeringDisplaySnapshot;
14519
15210
  this.agent.restoreQueues({
14520
15211
  steering: [...queuedDuringWindow.steering],
14521
15212
  followUp: selected
14522
15213
  ? [...queuedDuringWindow.followUp]
14523
- : [...queueSnapshot.steering, ...queueSnapshot.followUp, ...queuedDuringWindow.followUp],
15214
+ : [...queueSnapshot.followUp, ...queuedDuringWindow.followUp],
14524
15215
  });
14525
15216
  this.#steeringMessages = steeringDisplaysDuringWindow;
14526
- this.#followUpMessages = [
14527
- ...steeringDisplaySnapshot,
14528
- ...followUpDisplaySnapshot,
14529
- ...followUpDisplaysDuringWindow,
14530
- ];
15217
+ // With a selected entry the held (unselected) messages are re-queued
15218
+ // as follow-ups by restoreHeldQueue once the new run is accepted;
15219
+ // mirror them in the display in the same order.
15220
+ this.#followUpMessages = selected
15221
+ ? [
15222
+ ...steeringDisplaySnapshot.filter(entry => entry !== selected.display),
15223
+ ...followUpDisplaySnapshot.filter(entry => entry !== selected.display),
15224
+ ...followUpDisplaysDuringWindow,
15225
+ ]
15226
+ : [...followUpDisplaySnapshot, ...followUpDisplaysDuringWindow];
14531
15227
  const message = selectedMessage ?? {
14532
15228
  role: "user" as const,
14533
15229
  content: [{ type: "text" as const, text }],
@@ -14556,8 +15252,19 @@ export class AgentSession {
14556
15252
  admissionLease: admission,
14557
15253
  resetRetryReplaySafety: true,
14558
15254
  sdkRunToken: selectedSdkRunToken,
15255
+ skipInitialSteeringPoll: heldSteering.length > 0,
14559
15256
  onRunAccepted: () => {
14560
15257
  runAccepted = true;
15258
+ if (heldSteering.length > 0) {
15259
+ // Re-admit the aborted turn's steers into the replacement run the
15260
+ // moment it is accepted, with the run's INITIAL poll skipped: the
15261
+ // replacement prompt is answered by the opening model call and the
15262
+ // steers are consumed together at its first turn boundary. Doing
15263
+ // this synchronously at acceptance (rather than from an event
15264
+ // listener) keeps the ordering deterministic.
15265
+ this.agent.restoreSteering(heldSteering);
15266
+ this.#steeringMessages = [...heldSteeringDisplays, ...this.#steeringMessages];
15267
+ }
14561
15268
  if (selected) this.#fireQueuedPromotionHooks([message], { startsOwnRun: true });
14562
15269
  if (selected) {
14563
15270
  this.#steeringMessages = this.#steeringMessages.filter(entry => entry !== selected.display);
@@ -14586,6 +15293,10 @@ export class AgentSession {
14586
15293
  } finally {
14587
15294
  this.#cancelAndSubmitInProgress = false;
14588
15295
  this.#cancelAndSubmitPendingNextTurnDrained = false;
15296
+ // Queue work that arrived (or was re-routed) during the atomic window was
15297
+ // deliberately not scheduled while the flag was set; now that it is
15298
+ // cleared, nothing else owns delivery.
15299
+ if (this.agent.hasQueuedMessages()) this.#scheduleQueuedDelivery(1);
14589
15300
  }
14590
15301
  }
14591
15302
 
@@ -15745,6 +16456,10 @@ export class AgentSession {
15745
16456
  onBeforeMutation?: () => void;
15746
16457
  /** Run inside the selection admission after the durable mutation. */
15747
16458
  onAfterMutation?: () => void;
16459
+ /** Test seam: pause after idle drain but before mutation admission. */
16460
+ onBeforeMutationAdmissionForTests?: () => Promise<void>;
16461
+ /** Test seam: pause after mutation admission activates, before its closed-fence recheck. */
16462
+ onAfterMutationAdmissionReadyForTests?: () => Promise<void>;
15748
16463
  },
15749
16464
  ): Promise<DefaultModelSelectionResult> {
15750
16465
  // Reserve the causal selection fence synchronously, before credential
@@ -15760,6 +16475,7 @@ export class AgentSession {
15760
16475
  throw new Error("Default model selection cannot inherit a thinking level");
15761
16476
  }
15762
16477
  const expectedSessionId = this.sessionId;
16478
+ const selectionTransaction = Symbol("default-model-selection");
15763
16479
  const priorSelectionFence = this.#selectionFenceTail;
15764
16480
  const selectionFence = Promise.withResolvers<void>();
15765
16481
  this.#selectionFenceGeneration += 1;
@@ -15795,9 +16511,11 @@ export class AgentSession {
15795
16511
  },
15796
16512
  undefined,
15797
16513
  undefined,
15798
- { allowDuringClosing: true },
16514
+ { allowDuringClosing: true, selectionTransaction },
15799
16515
  );
16516
+ this.#selectionAwaitingMutationTransaction = selectionTransaction;
15800
16517
  await this.waitForIdle(selectionFenceGeneration);
16518
+ await options?.onBeforeMutationAdmissionForTests?.();
15801
16519
  return await this.#withSessionAdmission(
15802
16520
  "selection",
15803
16521
  async () => {
@@ -15879,9 +16597,16 @@ export class AgentSession {
15879
16597
  },
15880
16598
  undefined,
15881
16599
  undefined,
15882
- { allowDuringClosing: true },
16600
+ {
16601
+ allowDuringClosing: true,
16602
+ closedSelectionTransaction: selectionTransaction,
16603
+ selectionTransaction,
16604
+ onAfterReadyForTests: options?.onAfterMutationAdmissionReadyForTests,
16605
+ },
15883
16606
  );
15884
16607
  } finally {
16608
+ if (this.#selectionAwaitingMutationTransaction === selectionTransaction)
16609
+ this.#selectionAwaitingMutationTransaction = undefined;
15885
16610
  selectionFence.resolve();
15886
16611
  this.#pendingSelectionFences -= 1;
15887
16612
  if (this.#oldestPendingSelectionFenceGeneration === selectionFenceGeneration) {
@@ -16547,13 +17272,13 @@ export class AgentSession {
16547
17272
  }
16548
17273
 
16549
17274
  /**
16550
- * Set interrupt mode.
17275
+ * Set the tool interrupt policy (whether a steer aborts in-flight tools).
16551
17276
  * Saves to settings.
16552
17277
  */
16553
- setInterruptMode(mode: "immediate" | "wait"): void {
17278
+ setToolInterruptPolicy(policy: "abort_tools" | "finish_tools"): void {
16554
17279
  this.#assertDurableSettingsWritable();
16555
- this.agent.setInterruptMode(mode);
16556
- this.settings.set("interruptMode", mode);
17280
+ this.agent.setToolInterruptPolicy(policy);
17281
+ this.settings.set("toolInterruptPolicy", policy);
16557
17282
  }
16558
17283
 
16559
17284
  // =========================================================================
@@ -16940,22 +17665,35 @@ export class AgentSession {
16940
17665
  * @param options Optional callbacks for completion/error handling
16941
17666
  */
16942
17667
  async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
17668
+ this.#assertSessionAdmissionOpen();
16943
17669
  // Serialize with every other session-identity transition via the shared lease
16944
17670
  // (bidirectional mutual exclusion with handoff/new/switch/branch/clear/fork/
16945
17671
  // navigateTree). Released in the outer finally below.
16946
17672
  this.#beginSessionTransition("compact");
17673
+ const completion = Promise.withResolvers<void>();
17674
+ this.#compactionCompletion = completion.promise;
16947
17675
  try {
16948
17676
  if (this.#compactionAbortController) {
16949
17677
  throw new Error("Compaction already in progress");
16950
17678
  }
16951
- this.#disconnectFromAgent();
16952
- await this.abort();
16953
17679
  const compactionAbortController = new AbortController();
16954
17680
  this.#compactionAbortController = compactionAbortController;
16955
- // Take this invocation's state snapshot for the summarizer context.
16956
- const compactionStateSnapshot = await this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true });
16957
-
17681
+ this.#disconnectFromAgent();
16958
17682
  try {
17683
+ await this.abort({ cause: "compaction", preserveCompaction: true });
17684
+ } catch (error) {
17685
+ this.#compactionAbortController = undefined;
17686
+ throw error;
17687
+ }
17688
+ await this.#waitForAutoCompactionCompletions();
17689
+ try {
17690
+ if (compactionAbortController.signal.aborted) {
17691
+ throw new CompactionCancelledError();
17692
+ }
17693
+ // Take this invocation's state snapshot for the summarizer context.
17694
+ const compactionStateSnapshot = await this.#compactionStateSnapshot({
17695
+ trackWorkflowRecoveryProgress: true,
17696
+ });
16959
17697
  if (!this.model) {
16960
17698
  throw new Error("No model selected");
16961
17699
  }
@@ -17092,9 +17830,11 @@ export class AgentSession {
17092
17830
  if (this.#compactionAbortController === compactionAbortController) {
17093
17831
  this.#compactionAbortController = undefined;
17094
17832
  }
17095
- this.#reconnectToAgent();
17833
+ if (!this.#isDisposed && !this.#sessionAdmissionClosing) this.#reconnectToAgent();
17096
17834
  }
17097
17835
  } finally {
17836
+ completion.resolve();
17837
+ if (this.#compactionCompletion === completion.promise) this.#compactionCompletion = undefined;
17098
17838
  this.#endSessionTransition();
17099
17839
  }
17100
17840
  }
@@ -17156,9 +17896,21 @@ export class AgentSession {
17156
17896
  }
17157
17897
  }
17158
17898
 
17899
+ /**
17900
+ * Wait for every automatic compaction producer currently unwinding. An auto
17901
+ * compaction owns the session history through its post-append rewrite and
17902
+ * `session_compact` delivery, so cancelling its signal is not sufficient to
17903
+ * make that work safe to overlap with a manual compaction.
17904
+ */
17905
+ async #waitForAutoCompactionCompletions(): Promise<void> {
17906
+ while (this.#autoCompactionCompletions.size > 0) {
17907
+ await Promise.allSettled([...this.#autoCompactionCompletions]);
17908
+ }
17909
+ }
17910
+
17159
17911
  /** Trigger idle compaction through the auto-compaction flow (with UI events). */
17160
17912
  async runIdleCompaction(): Promise<void> {
17161
- if (this.isStreaming || this.isCompacting) return;
17913
+ if (this.#isDisposed || this.#sessionAdmissionClosing || this.isStreaming || this.isCompacting) return;
17162
17914
  // Do not start idle compaction while a handoff transition owns the session.
17163
17915
  if (this.isGeneratingHandoff || this.#handoffTransitionActive) return;
17164
17916
  await this.#runAutoCompaction("idle", false, true);
@@ -19006,7 +19758,27 @@ export class AgentSession {
19006
19758
  /**
19007
19759
  * Internal: Run auto-compaction with events.
19008
19760
  */
19009
- async #runAutoCompaction(
19761
+ #runAutoCompaction(
19762
+ reason: "overflow" | "threshold" | "idle",
19763
+ willRetry: boolean,
19764
+ deferred = false,
19765
+ options?: {
19766
+ continueAfterMaintenance?: boolean;
19767
+ deferHandoffMaintenance?: boolean;
19768
+ force?: boolean;
19769
+ signal?: AbortSignal;
19770
+ beforeTerminalOverflowNoop?: () => void;
19771
+ resourceRunId?: string;
19772
+ },
19773
+ ): Promise<AutoCompactionTerminalStatus> {
19774
+ if (this.#isDisposed || this.#sessionAdmissionClosing) return Promise.resolve({ kind: "skipped" });
19775
+ const completion = this.#runAutoCompactionImpl(reason, willRetry, deferred, options);
19776
+ this.#autoCompactionCompletions.add(completion);
19777
+ void completion.finally(() => this.#autoCompactionCompletions.delete(completion)).catch(() => {});
19778
+ return completion;
19779
+ }
19780
+
19781
+ async #runAutoCompactionImpl(
19010
19782
  reason: "overflow" | "threshold" | "idle",
19011
19783
  willRetry: boolean,
19012
19784
  deferred = false,
@@ -21224,6 +21996,7 @@ export class AgentSession {
21224
21996
  toolChoice?: ToolChoice;
21225
21997
  sdkRunToken?: string;
21226
21998
  fallbackManaged?: boolean;
21999
+ skipInitialSteeringPoll?: boolean;
21227
22000
  onRunAccepted?: (handle: AttemptRunHandle) => void;
21228
22001
  },
21229
22002
  predecessorAgentEndHold?: symbol,