@gajae-code/coding-agent 0.15.6 → 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 (270) hide show
  1. package/CHANGELOG.md +99 -0
  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/args.d.ts +2 -0
  5. package/dist/types/cli/root-flags.d.ts +14 -0
  6. package/dist/types/cli/update-cli.d.ts +14 -0
  7. package/dist/types/cli.d.ts +7 -0
  8. package/dist/types/commands/launch.d.ts +10 -0
  9. package/dist/types/commands/sdk.d.ts +2 -0
  10. package/dist/types/config/autorouting-tier-map.d.ts +24 -0
  11. package/dist/types/config/billing-path.d.ts +25 -0
  12. package/dist/types/config/config-schema-version.d.ts +13 -1
  13. package/dist/types/config/file-lock.d.ts +5 -0
  14. package/dist/types/config/model-registry.d.ts +22 -0
  15. package/dist/types/config/settings-schema.d.ts +27 -8
  16. package/dist/types/config/settings.d.ts +2 -0
  17. package/dist/types/coordinator-mcp/projection-scan.d.ts +8 -0
  18. package/dist/types/crash/record-loader.d.ts +15 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  20. package/dist/types/gjc-runtime/launch-worktree-reservation.d.ts +21 -0
  21. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  22. package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +2 -0
  23. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  24. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +66 -1
  25. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +1 -0
  26. package/dist/types/gjc-runtime/tmux-sessions.d.ts +27 -0
  27. package/dist/types/main.d.ts +1 -0
  28. package/dist/types/master-mode/context.d.ts +21 -0
  29. package/dist/types/master-mode/first-request.d.ts +34 -0
  30. package/dist/types/master-mode/snapshot.d.ts +32 -0
  31. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  32. package/dist/types/modes/components/frictionless-onboarding-selector.d.ts +10 -0
  33. package/dist/types/modes/components/model-selector.d.ts +4 -0
  34. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  35. package/dist/types/modes/components/session-selector.d.ts +1 -1
  36. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  37. package/dist/types/modes/components/status-line/model-name.d.ts +18 -0
  38. package/dist/types/modes/components/status-line/priority-row.d.ts +34 -0
  39. package/dist/types/modes/components/status-line/segments.d.ts +10 -0
  40. package/dist/types/modes/interactive-mode.d.ts +16 -2
  41. package/dist/types/modes/print-mode.d.ts +17 -0
  42. package/dist/types/modes/theme/theme.d.ts +2 -1
  43. package/dist/types/modes/types.d.ts +2 -0
  44. package/dist/types/modes/ui-language.d.ts +7 -1
  45. package/dist/types/sdk/broker/broker.d.ts +79 -2
  46. package/dist/types/sdk/broker/endpoint-authority.d.ts +24 -0
  47. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  48. package/dist/types/sdk/broker/index.d.ts +3 -0
  49. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  50. package/dist/types/sdk/broker/lifecycle.d.ts +40 -0
  51. package/dist/types/sdk/broker/master-capability.d.ts +10 -0
  52. package/dist/types/sdk/broker/session-index.d.ts +40 -11
  53. package/dist/types/sdk/broker/session-scope.d.ts +72 -0
  54. package/dist/types/sdk/broker/spawn-authority.d.ts +201 -0
  55. package/dist/types/sdk/broker/spawn-substrate.d.ts +30 -0
  56. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  57. package/dist/types/sdk/broker/state-version.d.ts +21 -1
  58. package/dist/types/sdk/broker/worktree-occupancy.d.ts +28 -0
  59. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -0
  60. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  61. package/dist/types/sdk/bus/index.d.ts +9 -1
  62. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  63. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  64. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  65. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  66. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  67. package/dist/types/sdk/cli/index.d.ts +1 -0
  68. package/dist/types/sdk/cli/master-cli.d.ts +63 -0
  69. package/dist/types/sdk/cli/rows.d.ts +12 -5
  70. package/dist/types/sdk/cli/session-cli.d.ts +20 -3
  71. package/dist/types/sdk/client/client.d.ts +3 -2
  72. package/dist/types/sdk/host/control/dispatch.d.ts +10 -0
  73. package/dist/types/sdk/host/control/index.d.ts +1 -1
  74. package/dist/types/sdk/host/control/operations.d.ts +2 -0
  75. package/dist/types/sdk/host/control/runtime-gate.d.ts +21 -7
  76. package/dist/types/sdk/host/events.d.ts +1 -0
  77. package/dist/types/sdk/host/host.d.ts +10 -0
  78. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  79. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  80. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +70 -17
  82. package/dist/types/sdk/lifecycle/broker-client.d.ts +6 -0
  83. package/dist/types/sdk/lifecycle/service.d.ts +15 -3
  84. package/dist/types/sdk/lifecycle-session.d.ts +3 -1
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  86. package/dist/types/sdk/receipt-state.d.ts +2 -0
  87. package/dist/types/sdk/router/session-router.d.ts +6 -0
  88. package/dist/types/sdk/session.d.ts +16 -0
  89. package/dist/types/sdk/startup-capability.d.ts +2 -1
  90. package/dist/types/sdk/turn-result.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +52 -7
  92. package/dist/types/session/credential-store-errors.d.ts +1 -0
  93. package/dist/types/session/fold-coordinator.d.ts +29 -2
  94. package/dist/types/session/session-manager.d.ts +79 -2
  95. package/dist/types/session/yield-queue.d.ts +5 -4
  96. package/dist/types/setup/frictionless-onboarding.d.ts +1 -0
  97. package/dist/types/tools/ask.d.ts +1 -0
  98. package/dist/types/tools/bash.d.ts +16 -0
  99. package/dist/types/tools/browser/launch.d.ts +10 -0
  100. package/dist/types/tools/browser/registry.d.ts +8 -0
  101. package/dist/types/tools/gh.d.ts +8 -0
  102. package/dist/types/tools/index.d.ts +10 -10
  103. package/dist/types/tools/job.d.ts +3 -1
  104. package/dist/types/utils/external-editor.d.ts +7 -0
  105. package/examples/extensions/README.md +1 -1
  106. package/examples/extensions/pirate.ts +7 -4
  107. package/package.json +7 -7
  108. package/scripts/generate-sdk-operation-inventory.ts +13 -1
  109. package/scripts/verify-gjc-sdk-canonicalization.ts +35 -1
  110. package/src/async/job-manager.ts +107 -9
  111. package/src/browser-backend.ts +29 -0
  112. package/src/cli/accounts-cli.ts +9 -5
  113. package/src/cli/args.ts +17 -0
  114. package/src/cli/fast-help.ts +1 -0
  115. package/src/cli/plugin-cli.ts +29 -0
  116. package/src/cli/root-flags.ts +5 -0
  117. package/src/cli/update-cli.ts +140 -23
  118. package/src/commands/launch.ts +86 -18
  119. package/src/commands/sdk.ts +135 -28
  120. package/src/config/autorouting-tier-map.ts +12 -0
  121. package/src/config/billing-path.ts +42 -0
  122. package/src/config/config-schema-version.ts +18 -1
  123. package/src/config/file-lock.ts +158 -83
  124. package/src/config/model-preset-registry.ts +178 -23
  125. package/src/config/model-profiles.ts +5 -5
  126. package/src/config/model-registry.ts +291 -42
  127. package/src/config/model-resolver.ts +5 -0
  128. package/src/config/settings-schema.ts +27 -8
  129. package/src/config/settings.ts +51 -15
  130. package/src/coordinator-mcp/projection-scan.ts +27 -6
  131. package/src/coordinator-mcp/question-state.ts +9 -11
  132. package/src/coordinator-mcp/server.ts +115 -21
  133. package/src/crash/index-store.ts +2 -0
  134. package/src/crash/record-loader.ts +10 -0
  135. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  136. package/src/extensibility/extensions/runner.ts +27 -0
  137. package/src/extensibility/extensions/types.ts +8 -3
  138. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  139. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  140. package/src/extensibility/skills.ts +22 -12
  141. package/src/gjc-runtime/launch-worktree-reservation.ts +242 -0
  142. package/src/gjc-runtime/launch-worktree.ts +285 -4
  143. package/src/gjc-runtime/managed-owner-supervisor.ts +63 -51
  144. package/src/gjc-runtime/session-state-lock.ts +213 -14
  145. package/src/gjc-runtime/session-state-sidecar.ts +938 -39
  146. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +4 -7
  147. package/src/gjc-runtime/tmux-owner-isolation.ts +18 -3
  148. package/src/gjc-runtime/tmux-sessions.ts +252 -13
  149. package/src/hooks/skill-state.ts +6 -0
  150. package/src/internal-urls/docs-index.generated.ts +15 -15
  151. package/src/internal-urls/issue-pr-protocol.ts +4 -2
  152. package/src/main.ts +61 -3
  153. package/src/master-mode/context.ts +50 -0
  154. package/src/master-mode/first-request.ts +65 -0
  155. package/src/master-mode/snapshot.ts +205 -0
  156. package/src/modes/acp/acp-agent.ts +610 -161
  157. package/src/modes/components/footer.ts +4 -2
  158. package/src/modes/components/frictionless-onboarding-selector.ts +48 -2
  159. package/src/modes/components/model-selector.ts +76 -8
  160. package/src/modes/components/oauth-selector.ts +6 -1
  161. package/src/modes/components/session-selector.ts +23 -15
  162. package/src/modes/components/sessions-dashboard.ts +8 -5
  163. package/src/modes/components/settings-selector.ts +1 -0
  164. package/src/modes/components/status-line/model-name.ts +53 -0
  165. package/src/modes/components/status-line/priority-row.ts +107 -0
  166. package/src/modes/components/status-line/segments.ts +23 -9
  167. package/src/modes/components/tool-status-header.ts +151 -15
  168. package/src/modes/controllers/command-controller.ts +1 -1
  169. package/src/modes/controllers/event-controller.ts +30 -4
  170. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  171. package/src/modes/controllers/input-controller.ts +30 -2
  172. package/src/modes/controllers/selector-controller.ts +200 -98
  173. package/src/modes/interactive-mode.ts +36 -12
  174. package/src/modes/print-mode.ts +64 -0
  175. package/src/modes/prompt-action-autocomplete.ts +30 -4
  176. package/src/modes/runtime-init.ts +12 -9
  177. package/src/modes/theme/theme.ts +5 -0
  178. package/src/modes/types.ts +2 -0
  179. package/src/modes/ui-language.ts +49 -2
  180. package/src/modes/utils/ui-helpers.ts +22 -3
  181. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  182. package/src/prompts/system/master-mode.md +28 -0
  183. package/src/prompts/tools/ask.md +1 -1
  184. package/src/prompts/tools/bash.md +1 -0
  185. package/src/prompts/tools/job.md +1 -1
  186. package/src/sdk/acp/adapter.ts +5 -0
  187. package/src/sdk/broker/broker.ts +1646 -27
  188. package/src/sdk/broker/endpoint-authority.ts +86 -0
  189. package/src/sdk/broker/ensure.ts +81 -3
  190. package/src/sdk/broker/index.ts +3 -0
  191. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  192. package/src/sdk/broker/lifecycle.ts +440 -75
  193. package/src/sdk/broker/master-capability.ts +232 -0
  194. package/src/sdk/broker/session-index.ts +121 -17
  195. package/src/sdk/broker/session-scope.ts +272 -0
  196. package/src/sdk/broker/spawn-authority.ts +1031 -0
  197. package/src/sdk/broker/spawn-substrate.ts +591 -0
  198. package/src/sdk/broker/startup-budget.ts +144 -2
  199. package/src/sdk/broker/state-version.ts +43 -14
  200. package/src/sdk/broker/transport.ts +1 -0
  201. package/src/sdk/broker/worktree-occupancy.ts +90 -0
  202. package/src/sdk/bus/chat-daemon-control.ts +5 -2
  203. package/src/sdk/bus/chat-daemon-runtime.ts +136 -18
  204. package/src/sdk/bus/discord-daemon.ts +50 -10
  205. package/src/sdk/bus/index.ts +84 -7
  206. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  207. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  208. package/src/sdk/bus/notification-service.ts +22 -5
  209. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  210. package/src/sdk/bus/reconciliation-store.ts +55 -31
  211. package/src/sdk/bus/slack-conversation.ts +1 -1
  212. package/src/sdk/bus/slack-daemon.ts +72 -38
  213. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  214. package/src/sdk/cli/index.ts +1 -0
  215. package/src/sdk/cli/master-cli.ts +259 -0
  216. package/src/sdk/cli/rows.ts +41 -15
  217. package/src/sdk/cli/session-cli.ts +297 -26
  218. package/src/sdk/client/client.ts +14 -8
  219. package/src/sdk/host/control/dispatch.ts +101 -40
  220. package/src/sdk/host/control/index.ts +2 -0
  221. package/src/sdk/host/control/operations.ts +2 -0
  222. package/src/sdk/host/control/runtime-gate.ts +203 -11
  223. package/src/sdk/host/events.ts +1 -0
  224. package/src/sdk/host/host.ts +29 -2
  225. package/src/sdk/host/query/cursor.ts +52 -7
  226. package/src/sdk/host/query/handlers.ts +33 -12
  227. package/src/sdk/host/query/revision-store.ts +12 -0
  228. package/src/sdk/host/session-runtime.test.ts +673 -14
  229. package/src/sdk/host/session-runtime.ts +674 -103
  230. package/src/sdk/host/surface-policy.ts +3 -3
  231. package/src/sdk/lifecycle/broker-client.ts +28 -0
  232. package/src/sdk/lifecycle/service.ts +279 -20
  233. package/src/sdk/lifecycle-session.ts +17 -4
  234. package/src/sdk/prompt-deadline-manager.ts +2 -1
  235. package/src/sdk/protocol/operation-inventory.generated.json +105 -3
  236. package/src/sdk/protocol/operation-registry.ts +14 -4
  237. package/src/sdk/receipt-state.ts +10 -0
  238. package/src/sdk/router/session-router.ts +219 -118
  239. package/src/sdk/session.ts +416 -195
  240. package/src/sdk/startup-capability.ts +1 -0
  241. package/src/sdk/turn-result.ts +22 -0
  242. package/src/session/agent-session.ts +1628 -318
  243. package/src/session/blob-store.ts +15 -5
  244. package/src/session/credential-store-errors.ts +2 -0
  245. package/src/session/fold-coordinator.ts +40 -3
  246. package/src/session/internal/managed-session-storage.ts +153 -78
  247. package/src/session/session-manager.ts +850 -97
  248. package/src/session/yield-queue.ts +24 -10
  249. package/src/setup/frictionless-onboarding.ts +2 -0
  250. package/src/slash-commands/builtin-registry.ts +41 -0
  251. package/src/task/executor.ts +9 -4
  252. package/src/telemetry/events.ts +1138 -12
  253. package/src/tools/ask-contract.test.ts +64 -0
  254. package/src/tools/ask-contract.ts +36 -1
  255. package/src/tools/ask.ts +10 -8
  256. package/src/tools/bash-interactive.ts +11 -2
  257. package/src/tools/bash.ts +412 -75
  258. package/src/tools/browser/launch.ts +37 -0
  259. package/src/tools/browser/registry.ts +99 -9
  260. package/src/tools/cron.ts +6 -4
  261. package/src/tools/descriptors.test.ts +15 -0
  262. package/src/tools/descriptors.ts +5 -1
  263. package/src/tools/gh.ts +104 -48
  264. package/src/tools/github-cache.ts +2 -2
  265. package/src/tools/index.ts +10 -6
  266. package/src/tools/job.ts +4 -1
  267. package/src/tools/monitor.ts +12 -9
  268. package/src/tools/resolve.ts +30 -10
  269. package/src/tools/tool-catalog.generated.ts +2 -2
  270. 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,14 +314,23 @@ 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,
323
+ clearCoordinatorRuntimeStateRescope,
324
+ GJC_COORDINATOR_SESSION_STATE_FILE_ENV,
325
+ hasCoordinatorRuntimeStateRescopeJournal,
326
+ markCoordinatorRuntimeStateRescopePublishing,
318
327
  ownerTerminalContextFromEnvironment,
319
328
  persistCoordinatorRuntimeStateFromEvent,
320
329
  persistCoordinatorWorkerIntegrationOutcome,
330
+ prepareCoordinatorRuntimeStateRescope,
331
+ recoverCoordinatorRuntimeStateRescope,
321
332
  registerCoordinatorRuntimeStateFinalizer,
333
+ relocateCoordinatorRuntimeStateForRescope,
322
334
  UNPROVEN_TOOL_LABEL,
323
335
  } from "../gjc-runtime/session-state-sidecar";
324
336
  import {
@@ -447,7 +459,13 @@ import {
447
459
  } from "./contribution-prep";
448
460
  import { canonicalCoordinatorToolLabel } from "./coordinator-tool-label";
449
461
  import { pruneStaleFileMentions } from "./file-mention-pruning";
450
- 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";
451
469
  import type { MemoryGuardRestoreResult } from "./memory-guard-checkpoint-participant";
452
470
  import {
453
471
  type BashExecutionMessage,
@@ -1056,6 +1074,8 @@ export interface PromptOptions {
1056
1074
  streamingBehavior?: "steer" | "followUp";
1057
1075
  /** When set to "sequential", this follow-up is delivered one prompt at a time even if followUpMode is "all". */
1058
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";
1059
1079
  /** Optional tool choice override for the next LLM call. */
1060
1080
  toolChoice?: ToolChoice;
1061
1081
  /** Send as developer/system message instead of user. Providers that support it use the developer role; others fall back to user. */
@@ -1089,6 +1109,7 @@ type InternalCustomMessageOptions = Pick<
1089
1109
  | "streamingBehavior"
1090
1110
  | "toolChoice"
1091
1111
  | "followUpQueuePolicy"
1112
+ | "steerQueuePolicy"
1092
1113
  | "onPreflightAccepted"
1093
1114
  | "onPreflightAcceptCommit"
1094
1115
  | "preflightSignal"
@@ -1556,6 +1577,21 @@ interface EphemeralTurnResult {
1556
1577
  */
1557
1578
  const SIGNAL_TEARDOWN_TIMEOUT_MS = 5_000;
1558
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
+
1559
1595
  const AGENT_BASH_ARTIFACT_SAVE_DIAGNOSTIC_MAX_BYTES = 256;
1560
1596
 
1561
1597
  function boundAgentBashArtifactSaveDiagnostic(error: unknown): string {
@@ -2006,6 +2042,7 @@ function sanitizePostPublicationError(error: string | undefined): string | undef
2006
2042
 
2007
2043
  export class WorkerIntegrationRequestScheduler {
2008
2044
  #inFlight: Promise<WorkerIntegrationOutcome> | undefined = undefined;
2045
+ #ownedRequests = new Set<Promise<void>>();
2009
2046
  #pending = false;
2010
2047
 
2011
2048
  constructor(
@@ -2033,6 +2070,12 @@ export class WorkerIntegrationRequestScheduler {
2033
2070
  return outcome;
2034
2071
  }
2035
2072
 
2073
+ async joinOwnedRequests(): Promise<void> {
2074
+ while (this.#ownedRequests.size > 0) {
2075
+ await Promise.allSettled([...this.#ownedRequests]);
2076
+ }
2077
+ }
2078
+
2036
2079
  #start(): void {
2037
2080
  this.#pending = false;
2038
2081
  const controller = new AbortController();
@@ -2042,6 +2085,8 @@ export class WorkerIntegrationRequestScheduler {
2042
2085
  } catch {
2043
2086
  request = Promise.reject(new Error("Worker integration request failed before dispatch."));
2044
2087
  }
2088
+ this.#ownedRequests.add(request);
2089
+ void request.finally(() => this.#ownedRequests.delete(request)).catch(() => {});
2045
2090
  let timeout: ReturnType<typeof setTimeout> | undefined;
2046
2091
  const deadline = new Promise<WorkerIntegrationOutcome>(resolve => {
2047
2092
  timeout = setTimeout(() => {
@@ -2277,6 +2322,7 @@ type SessionAdmissionEntry = {
2277
2322
  settled: PromiseWithResolvers<void>;
2278
2323
  released: boolean;
2279
2324
  selectionFenceGeneration: number;
2325
+ selectionTransaction?: symbol;
2280
2326
  continuationCapability?: symbol;
2281
2327
  };
2282
2328
 
@@ -2346,6 +2392,12 @@ interface CanonicalMessageAdmission {
2346
2392
  release: () => void;
2347
2393
  }
2348
2394
 
2395
+ type CoordinatorRuntimeStatePersistContext = {
2396
+ sessionId: string;
2397
+ cwd: string;
2398
+ sessionFile: string | undefined;
2399
+ stateFile: string | null;
2400
+ };
2349
2401
  export class AgentSession {
2350
2402
  #provisionalStreamingToolCallIds = new Set<string>();
2351
2403
  readonly agent: Agent;
@@ -2386,12 +2438,15 @@ export class AgentSession {
2386
2438
  #preProfileModel: Model | undefined;
2387
2439
  #sessionAdmissionQueue: SessionAdmissionEntry[] = [];
2388
2440
  #activeSessionAdmission: SessionAdmissionEntry | undefined;
2441
+ #startupPromptWaiters = new Set<Promise<void>>();
2389
2442
  #sessionAdmissionClosing = false;
2390
2443
  #sessionAdmissionClosed = false;
2391
2444
  #sessionAdmissionContext = new AsyncLocalStorage<SessionAdmissionEntry>();
2392
2445
  #selectionFenceGenerationContext = new AsyncLocalStorage<number>();
2393
2446
  #selectionFenceTail: Promise<void> = Promise.resolve();
2394
2447
  #pendingSelectionFences = 0;
2448
+ #selectionAwaitingMutationTransaction: symbol | undefined;
2449
+ #authorizedClosedSelectionTransaction: symbol | undefined;
2395
2450
  #selectionFenceDeferredContinuations = new Map<number, number>();
2396
2451
  #scopedSettlementWaiters = new Set<() => void>();
2397
2452
  #oldestPendingSelectionFenceGeneration = 0;
@@ -2491,6 +2546,10 @@ export class AgentSession {
2491
2546
  // continuation of the aborted attempt that the contract requires to be
2492
2547
  // blocked (review thread P1).
2493
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[] = [];
2494
2553
  /** Per-message SDK requester-ownership correlation for queued client steers:
2495
2554
  * fired exactly once when the steer's run accepts it — via the idle
2496
2555
  * auto-continue or the terminal-abort rearm (review thread P1). */
@@ -2536,6 +2595,157 @@ export class AgentSession {
2536
2595
  this.#deepInterviewGenuineUserMessageEpochs.delete(message);
2537
2596
  }
2538
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
+ }
2539
2749
  #fireQueuedPromotionHooks(messages: readonly AgentMessage[], promotion?: { startsOwnRun?: boolean }): void {
2540
2750
  for (const message of messages) {
2541
2751
  const steerHook = this.#steerPromotionHooks.get(message);
@@ -2626,6 +2836,8 @@ export class AgentSession {
2626
2836
 
2627
2837
  // Compaction state
2628
2838
  #compactionAbortController: AbortController | undefined = undefined;
2839
+ #compactionCompletion: Promise<void> | undefined;
2840
+ #autoCompactionCompletions = new Set<Promise<AutoCompactionTerminalStatus>>();
2629
2841
  #autoCompactionAbortController: AbortController | undefined = undefined;
2630
2842
 
2631
2843
  /** Invocation-scoped EventStream drain barriers owned by active maintenance calls. */
@@ -2814,6 +3026,27 @@ export class AgentSession {
2814
3026
  /** Idempotent unregister handle for this session's resource-GC registration. */
2815
3027
  #unregisterResourceGc?: () => void;
2816
3028
  #unregisterRuntimeStateFinalizer?: () => void;
3029
+ #unregisterBeforeMoveListener?: () => void;
3030
+ #unregisterMoveAbortListener?: () => void;
3031
+ #unregisterMovePublicationListener?: () => void;
3032
+ #unregisterAfterMoveListener?: () => void;
3033
+ /**
3034
+ * (Re)register the postmortem finalizer bound to the CURRENT cwd/session file. Called at
3035
+ * construction and after every committed rescope, so an unexpected exit writes terminal
3036
+ * state to the session's live location rather than the abandoned launch root.
3037
+ */
3038
+ #registerRuntimeStateFinalizer(): void {
3039
+ this.#unregisterRuntimeStateFinalizer?.();
3040
+ const currentContext = () => ({
3041
+ sessionId: this.sessionId,
3042
+ cwd: this.sessionManager.getCwd(),
3043
+ sessionFile: this.sessionManager.getSessionFile(),
3044
+ });
3045
+ this.#unregisterRuntimeStateFinalizer = registerCoordinatorRuntimeStateFinalizer(
3046
+ currentContext(),
3047
+ currentContext,
3048
+ );
3049
+ }
2817
3050
  #unregisterSessionMemorySettings?: () => void;
2818
3051
  /**
2819
3052
  * AsyncJobManager owned by this session (top-level only). Subagents leave
@@ -2823,6 +3056,7 @@ export class AgentSession {
2823
3056
  readonly #disposeAsyncJobManager: boolean;
2824
3057
  #ownedMcpManager: MCPManager | undefined;
2825
3058
  #startupTurnBarrier: Promise<void> | undefined;
3059
+ #startupTurnBarrierPending = false;
2826
3060
  #pendingPythonMessages: Array<{
2827
3061
  message: PythonExecutionMessage;
2828
3062
  onPersisted?: () => void;
@@ -2847,7 +3081,18 @@ export class AgentSession {
2847
3081
  #providerCacheSessionId: string | undefined;
2848
3082
  readonly #asyncJobProviderSessionId: string | undefined;
2849
3083
  #isDisposed = false;
2850
- #disposePromise: Promise<void> | undefined;
3084
+ #disposeRunPromise: Promise<void> | undefined;
3085
+ #disposeCallerPromise: Promise<void> | undefined;
3086
+ #disposeCompleted = false;
3087
+ #disposeTerminalError: unknown;
3088
+ readonly #disposeAbortController = new AbortController();
3089
+ #disposeAdmissionClosed: Promise<void> | undefined;
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;
2851
3096
  readonly #toolSessionCleanups = new Set<() => Promise<void> | void>();
2852
3097
  readonly #toolSessionTransitionCleanups = new Set<() => Promise<void> | void>();
2853
3098
  readonly #deferredOwnerShutdownFinalizations = new Set<Promise<void>>();
@@ -3019,6 +3264,7 @@ export class AgentSession {
3019
3264
  #cancelAndSubmitAbortOutcomeProviderForTests: (() => Promise<AbortOutcome>) | undefined = undefined;
3020
3265
  #postPromptTasks = new Set<Promise<void>>();
3021
3266
  #postPromptTaskSelectionFenceGenerations = new Map<Promise<void>, number>();
3267
+ #postPromptTaskRecoveryExcluded = new Set<Promise<void>>();
3022
3268
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
3023
3269
  #postPromptTasksResolve: (() => void) | undefined = undefined;
3024
3270
  #postPromptTasksAbortController = new AbortController();
@@ -3206,6 +3452,65 @@ export class AgentSession {
3206
3452
  */
3207
3453
  #sessionTransitionKind: string | undefined;
3208
3454
  #coordinatorPersistGeneration = 0;
3455
+ #coordinatorRescopeBarrier: Promise<void> | undefined;
3456
+ #releaseCoordinatorRescopeBarrier: (() => void) | undefined;
3457
+ #coordinatorRescopeMoveId: string | undefined;
3458
+ #coordinatorEventHandlers = new Set<Promise<void>>();
3459
+ #coordinatorUnbarrieredPersists = new Set<Promise<void>>();
3460
+ #coordinatorReleasedBarrierPersists = new Set<Promise<void>>();
3461
+
3462
+ #beginCoordinatorRescopeBarrier(): {
3463
+ eventHandlers: Promise<void>[];
3464
+ releasedBarrierPersists: Promise<void>[];
3465
+ } {
3466
+ if (this.#coordinatorRescopeBarrier) throw new Error("Coordinator rescope barrier is already active.");
3467
+ const releasedBarrierPersists = [...this.#coordinatorReleasedBarrierPersists];
3468
+ const barrier = Promise.withResolvers<void>();
3469
+ this.#coordinatorRescopeBarrier = barrier.promise;
3470
+ this.#releaseCoordinatorRescopeBarrier = barrier.resolve;
3471
+ this.#bindPendingAgentEndToRescopeBarrier();
3472
+ return { eventHandlers: [...this.#coordinatorEventHandlers], releasedBarrierPersists };
3473
+ }
3474
+
3475
+ #trackReleasedBarrierPersist(persist: Promise<void>): void {
3476
+ this.#coordinatorReleasedBarrierPersists.add(persist);
3477
+ void persist.then(
3478
+ () => this.#coordinatorReleasedBarrierPersists.delete(persist),
3479
+ () => this.#coordinatorReleasedBarrierPersists.delete(persist),
3480
+ );
3481
+ }
3482
+
3483
+ #bindPendingAgentEndToRescopeBarrier(): void {
3484
+ if (!this.#coordinatorRescopeBarrier || !this.#pendingAgentEndEmit) return;
3485
+ const admission = this.#agentEventAdmission.get(this.#pendingAgentEndEmit);
3486
+ if (admission) admission.persistBarrier = this.#coordinatorRescopeBarrier;
3487
+ }
3488
+
3489
+ #appendCoordinatorPersist(run: () => Promise<void>): Promise<void> {
3490
+ const queued = this.#coordinatorPersistQueue.then(run, run);
3491
+ this.#coordinatorPersistQueue = queued.catch(() => {});
3492
+ return queued;
3493
+ }
3494
+
3495
+ #trackUnbarrieredCoordinatorPersist(persist: Promise<void>): void {
3496
+ this.#coordinatorUnbarrieredPersists.add(persist);
3497
+ void persist.then(
3498
+ () => this.#coordinatorUnbarrieredPersists.delete(persist),
3499
+ () => this.#coordinatorUnbarrieredPersists.delete(persist),
3500
+ );
3501
+ }
3502
+
3503
+ async #drainUnbarrieredCoordinatorPersists(): Promise<void> {
3504
+ while (this.#coordinatorUnbarrieredPersists.size > 0) {
3505
+ await Promise.allSettled([...this.#coordinatorUnbarrieredPersists]);
3506
+ }
3507
+ }
3508
+
3509
+ #endCoordinatorRescopeBarrier(): void {
3510
+ this.#releaseCoordinatorRescopeBarrier?.();
3511
+ this.#releaseCoordinatorRescopeBarrier = undefined;
3512
+ this.#coordinatorRescopeBarrier = undefined;
3513
+ }
3209
3514
 
3210
3515
  #beginSessionTransition(kind: string): void {
3211
3516
  if (this.#sessionTransitionKind !== undefined) {
@@ -3223,8 +3528,17 @@ export class AgentSession {
3223
3528
  }
3224
3529
 
3225
3530
  #activateNextSessionAdmission(): void {
3226
- if (this.#activeSessionAdmission || this.#sessionAdmissionClosed) return;
3227
- 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
+ }
3228
3542
  if (!next) return;
3229
3543
  this.#activeSessionAdmission = next;
3230
3544
  next.ready.resolve();
@@ -3242,16 +3556,61 @@ export class AgentSession {
3242
3556
  return { entry, capability: entry.continuationCapability };
3243
3557
  }
3244
3558
 
3245
- async #awaitStartupTurnBarrier(): Promise<void> {
3246
- const barrier = this.#startupTurnBarrier;
3559
+ #setStartupTurnBarrier(barrier: Promise<void> | undefined): void {
3560
+ this.#startupTurnBarrier = barrier;
3561
+ this.#startupTurnBarrierPending = barrier !== undefined;
3247
3562
  if (!barrier) return;
3248
- await barrier;
3249
- if (this.#startupTurnBarrier === barrier) this.#startupTurnBarrier = undefined;
3563
+ void barrier.then(
3564
+ () => {
3565
+ if (this.#startupTurnBarrier === barrier) this.#startupTurnBarrierPending = false;
3566
+ },
3567
+ () => {
3568
+ if (this.#startupTurnBarrier === barrier) this.#startupTurnBarrierPending = false;
3569
+ },
3570
+ );
3571
+ }
3572
+ async #awaitStartupTurnBarrier(signal?: AbortSignal): Promise<void> {
3573
+ while (true) {
3574
+ const barrier = this.#startupTurnBarrier;
3575
+ if (!barrier) return;
3576
+ await awaitPromptInvocationPreflight(barrier, signal);
3577
+ // Cross one task boundary before clearing readiness. Promise reaction
3578
+ // chains fully drain before timers, so every extension published by the
3579
+ // captured barrier's settlement becomes visible without an arbitrary
3580
+ // microtask-depth cap.
3581
+ const publicationTurn = Promise.withResolvers<void>();
3582
+ const timer = setTimeout(publicationTurn.resolve, 0);
3583
+ try {
3584
+ await awaitPromptInvocationPreflight(publicationTurn.promise, signal);
3585
+ } finally {
3586
+ clearTimeout(timer);
3587
+ }
3588
+ if (this.#startupTurnBarrier !== barrier) continue;
3589
+ this.#setStartupTurnBarrier(undefined);
3590
+ return;
3591
+ }
3592
+ }
3593
+ async #awaitStartupPromptWaiters(signal?: AbortSignal): Promise<void> {
3594
+ while (this.#startupPromptWaiters.size > 0) {
3595
+ await awaitPromptInvocationPreflight(Promise.all([...this.#startupPromptWaiters]), signal);
3596
+ await Promise.resolve();
3597
+ }
3598
+ }
3599
+ #reserveStartupPromptWaiter(): () => void {
3600
+ const waiter = Promise.withResolvers<void>();
3601
+ this.#startupPromptWaiters.add(waiter.promise);
3602
+ let released = false;
3603
+ return () => {
3604
+ if (released) return;
3605
+ released = true;
3606
+ this.#startupPromptWaiters.delete(waiter.promise);
3607
+ waiter.resolve();
3608
+ };
3250
3609
  }
3251
3610
  extendStartupTurnBarrier(barrier: Promise<void>): void {
3252
3611
  const current = this.#startupTurnBarrier;
3253
- this.#startupTurnBarrier = current ? Promise.all([current, barrier]).then(() => {}) : barrier;
3254
- void this.#startupTurnBarrier.catch(() => {});
3612
+ this.#setStartupTurnBarrier(current ? Promise.all([current, barrier]).then(() => {}) : barrier);
3613
+ void this.#startupTurnBarrier?.catch(() => {});
3255
3614
  }
3256
3615
 
3257
3616
  async #withSessionAdmission<T>(
@@ -3261,8 +3620,13 @@ export class AgentSession {
3261
3620
  continuationAdmission?: ScheduledContinuationAdmission,
3262
3621
  options?: {
3263
3622
  allowDuringClosing?: boolean;
3623
+ closedSelectionTransaction?: symbol;
3624
+ selectionTransaction?: symbol;
3625
+ onAfterReadyForTests?: () => Promise<void>;
3264
3626
  bypassSelectionFenceGeneration?: number;
3265
3627
  allowPromptContinuationReentry?: boolean;
3628
+ idleDelivery?: boolean;
3629
+ startupPromptWaiterRelease?: () => void;
3266
3630
  },
3267
3631
  ): Promise<T> {
3268
3632
  const owner = this.#sessionAdmissionContext.getStore();
@@ -3277,7 +3641,21 @@ export class AgentSession {
3277
3641
  }
3278
3642
  throw this.#sessionAdmissionBusyError();
3279
3643
  }
3280
- if (kind === "prompt") await awaitPromptInvocationPreflight(this.#awaitStartupTurnBarrier(), signal);
3644
+ const releaseStartupPromptWaiter =
3645
+ kind === "prompt" && options?.idleDelivery !== true
3646
+ ? (options?.startupPromptWaiterRelease ?? this.#reserveStartupPromptWaiter())
3647
+ : undefined;
3648
+ if (kind === "prompt") {
3649
+ const startupSignal = signal
3650
+ ? AbortSignal.any([signal, this.#disposeAbortController.signal])
3651
+ : this.#disposeAbortController.signal;
3652
+ try {
3653
+ await awaitPromptInvocationPreflight(this.#awaitStartupTurnBarrier(startupSignal), startupSignal);
3654
+ } finally {
3655
+ releaseStartupPromptWaiter?.();
3656
+ }
3657
+ if (options?.idleDelivery === true) await this.#awaitStartupPromptWaiters(startupSignal);
3658
+ }
3281
3659
  const bypassesSelectionFence =
3282
3660
  options?.bypassSelectionFenceGeneration !== undefined &&
3283
3661
  options.bypassSelectionFenceGeneration < this.#selectionFenceGeneration;
@@ -3289,8 +3667,11 @@ export class AgentSession {
3289
3667
  ) {
3290
3668
  await awaitPromptInvocationPreflight(this.#selectionFenceTail, signal);
3291
3669
  }
3670
+ const closedSelectionAuthorized = (): boolean =>
3671
+ options?.closedSelectionTransaction !== undefined &&
3672
+ options.closedSelectionTransaction === this.#authorizedClosedSelectionTransaction;
3292
3673
  if (
3293
- this.#sessionAdmissionClosed ||
3674
+ (this.#sessionAdmissionClosed && !closedSelectionAuthorized()) ||
3294
3675
  ((this.#sessionAdmissionClosing || this.#isDisposed) && options?.allowDuringClosing !== true)
3295
3676
  )
3296
3677
  throw this.#sessionAdmissionBusyError();
@@ -3311,6 +3692,7 @@ export class AgentSession {
3311
3692
  settled: Promise.withResolvers<void>(),
3312
3693
  released: false,
3313
3694
  selectionFenceGeneration: this.#selectionFenceGeneration,
3695
+ ...(options?.selectionTransaction ? { selectionTransaction: options.selectionTransaction } : {}),
3314
3696
  ...(kind === "prompt" ? { continuationCapability: Symbol("scheduled-continuation") } : {}),
3315
3697
  };
3316
3698
  const releaseEntry = () => {
@@ -3330,39 +3712,33 @@ export class AgentSession {
3330
3712
  releaseEntry();
3331
3713
  throw error;
3332
3714
  }
3333
- if (
3334
- this.#sessionAdmissionClosed ||
3335
- ((this.#sessionAdmissionClosing || this.#isDisposed) && options?.allowDuringClosing !== true)
3336
- ) {
3337
- entry.released = true;
3338
- entry.settled.resolve();
3339
- if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
3340
- this.#activateNextSessionAdmission();
3341
- throw this.#sessionAdmissionBusyError();
3342
- }
3343
- // Re-check the handoff fence after activation: a prompt queued before the
3344
- // transition began must not start once the fence is up.
3345
- if (kind === "prompt" && this.#handoffTransitionActive) {
3346
- entry.released = true;
3347
- entry.settled.resolve();
3348
- if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
3349
- this.#activateNextSessionAdmission();
3350
- throw Object.assign(new AgentBusyError("Cannot start a turn while a handoff is in progress."), {
3351
- code: "busy",
3352
- });
3353
- }
3354
-
3355
- const release = () => {
3356
- releaseEntry();
3357
- };
3358
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
+ };
3359
3735
  return await this.#sessionAdmissionContext.run(entry, () => body({ release }));
3360
3736
  } finally {
3361
- release();
3737
+ releaseEntry();
3362
3738
  }
3363
3739
  }
3364
3740
 
3365
- async #closeSessionAdmission(): Promise<void> {
3741
+ async #closeSessionAdmission(options?: { waitForActive?: boolean }): Promise<void> {
3366
3742
  this.#sessionAdmissionClosing = true;
3367
3743
  const active = this.#activeSessionAdmission;
3368
3744
  if (active?.kind === "prompt") {
@@ -3370,6 +3746,29 @@ export class AgentSession {
3370
3746
  this.#promptPreflightCancellationGeneration++;
3371
3747
  this.#promptPreflightAbortController.abort();
3372
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
+ }
3373
3772
  if (active) await active.settled.promise;
3374
3773
  await this.#selectionFenceTail;
3375
3774
  this.#sessionAdmissionClosed = true;
@@ -3874,11 +4273,8 @@ export class AgentSession {
3874
4273
  lease: RunResourceProducerLease | undefined,
3875
4274
  workerIntegrationSettled: boolean,
3876
4275
  ): Promise<void> {
3877
- const publicationContext = {
3878
- sessionId: this.sessionId,
3879
- cwd: this.sessionManager.getCwd(),
3880
- sessionFile: this.sessionManager.getSessionFile(),
3881
- };
4276
+ const publicationContext = this.#captureCoordinatorRuntimeStatePersistContext();
4277
+
3882
4278
  const publicationScope = (pending as AgentSessionEvent & { scope?: AttemptScopeRef }).scope as
3883
4279
  | AttemptScope
3884
4280
  | undefined;
@@ -3949,7 +4345,6 @@ export class AgentSession {
3949
4345
  error: String(error),
3950
4346
  },
3951
4347
  );
3952
- logger.warn("Failed to persist terminal coordinator runtime state", { error: String(error) });
3953
4348
  },
3954
4349
  );
3955
4350
  extensionDelivery = this.#queueExtensionEvent(
@@ -3969,9 +4364,7 @@ export class AgentSession {
3969
4364
  ),
3970
4365
  );
3971
4366
  await extensionDelivery;
3972
- void terminalPersistence.catch(error =>
3973
- logger.warn("Terminal persistence continued after SDK publication", { error }),
3974
- );
4367
+ void terminalPersistence;
3975
4368
  } else {
3976
4369
  // Non-SDK/public publication has already awaited worker integration above.
3977
4370
  }
@@ -4039,10 +4432,96 @@ export class AgentSession {
4039
4432
  cwd: () => this.sessionManager.getCwd(),
4040
4433
  });
4041
4434
  }
4042
- this.#unregisterRuntimeStateFinalizer = registerCoordinatorRuntimeStateFinalizer({
4043
- sessionId: this.sessionId,
4044
- cwd: this.sessionManager.getCwd(),
4045
- sessionFile: this.sessionManager.getSessionFile(),
4435
+ this.#registerRuntimeStateFinalizer();
4436
+ this.#unregisterBeforeMoveListener = this.sessionManager.registerBeforeMoveListener(async move => {
4437
+ const admittedBeforeBarrier = this.#beginCoordinatorRescopeBarrier();
4438
+ await Promise.allSettled([
4439
+ ...admittedBeforeBarrier.eventHandlers,
4440
+ ...admittedBeforeBarrier.releasedBarrierPersists,
4441
+ ]);
4442
+ this.#bindPendingAgentEndToRescopeBarrier();
4443
+ await this.#drainUnbarrieredCoordinatorPersists();
4444
+ this.#coordinatorPersistGeneration += 1;
4445
+ const moveId = crypto.randomUUID();
4446
+ this.#coordinatorRescopeMoveId = moveId;
4447
+ await prepareCoordinatorRuntimeStateRescope({
4448
+ moveId,
4449
+ sessionId: this.sessionId,
4450
+ previousCwd: move.previousCwd,
4451
+ newCwd: move.newCwd,
4452
+ previousCwdIdentity: move.previousCwdIdentity,
4453
+ newCwdIdentity: move.newCwdIdentity,
4454
+ previousSessionFile: move.previousSessionFile ?? null,
4455
+ newSessionFile: move.newSessionFile ?? null,
4456
+ });
4457
+ });
4458
+ this.#unregisterMoveAbortListener = this.sessionManager.registerMoveAbortListener(async move => {
4459
+ const moveId = this.#coordinatorRescopeMoveId;
4460
+ try {
4461
+ if (moveId && !move.preserveRecoveryJournal) {
4462
+ await clearCoordinatorRuntimeStateRescope(
4463
+ {
4464
+ sessionId: this.sessionId,
4465
+ cwd: move.newCwd,
4466
+ sessionFile: move.newSessionFile ?? null,
4467
+ },
4468
+ moveId,
4469
+ move.previousCwd,
4470
+ );
4471
+ }
4472
+ } finally {
4473
+ this.#coordinatorRescopeMoveId = undefined;
4474
+ this.#endCoordinatorRescopeBarrier();
4475
+ }
4476
+ });
4477
+ this.#unregisterMovePublicationListener = this.sessionManager.registerMovePublicationListener(async move => {
4478
+ const moveId = this.#coordinatorRescopeMoveId;
4479
+ if (!moveId) throw new Error("Coordinator rescope publication has no prepared move identity.");
4480
+ await markCoordinatorRuntimeStateRescopePublishing(
4481
+ {
4482
+ sessionId: this.sessionId,
4483
+ cwd: move.newCwd,
4484
+ sessionFile: move.newSessionFile ?? null,
4485
+ },
4486
+ move.previousCwd,
4487
+ moveId,
4488
+ );
4489
+ });
4490
+ // Every committed rescope (`move_session`, `/move`, SDK/ACP `session.cwd.move`) funnels
4491
+ // through SessionManager.moveTo, so one after-move listener covers all surfaces: it
4492
+ // relocates the coordinator-shared runtime state to the new cwd (otherwise the identity
4493
+ // fence refuses every later persist) and rebinds the postmortem finalizer, which
4494
+ // otherwise keeps writing terminal state to the launch root's cwd/session file.
4495
+ this.#unregisterAfterMoveListener = this.sessionManager.registerAfterMoveListener(async move => {
4496
+ let completed = false;
4497
+ try {
4498
+ const relocated = await relocateCoordinatorRuntimeStateForRescope(
4499
+ {
4500
+ sessionId: this.sessionId,
4501
+ cwd: move.newCwd,
4502
+ sessionFile: this.sessionManager.getSessionFile() ?? null,
4503
+ previousSessionFile: move.previousSessionFile ?? null,
4504
+ },
4505
+ move.previousCwd,
4506
+ );
4507
+ if (!relocated) throw new Error("Coordinator runtime state rescope was refused.");
4508
+ await clearCoordinatorRuntimeStateRescope(
4509
+ {
4510
+ sessionId: this.sessionId,
4511
+ cwd: move.newCwd,
4512
+ sessionFile: this.sessionManager.getSessionFile() ?? null,
4513
+ },
4514
+ this.#coordinatorRescopeMoveId,
4515
+ move.previousCwd,
4516
+ );
4517
+ completed = true;
4518
+ } finally {
4519
+ this.#registerRuntimeStateFinalizer();
4520
+ if (completed) {
4521
+ this.#coordinatorRescopeMoveId = undefined;
4522
+ this.#endCoordinatorRescopeBarrier();
4523
+ }
4524
+ }
4046
4525
  });
4047
4526
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
4048
4527
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
@@ -4051,7 +4530,19 @@ export class AgentSession {
4051
4530
  this.#disposeAsyncJobManager = config.disposeAsyncJobManager ?? true;
4052
4531
  this.#retainedMemorySampler = config.retainedMemorySampler;
4053
4532
  this.#ownedMcpManager = config.ownedMcpManager;
4054
- this.#startupTurnBarrier = config.startupTurnBarrier;
4533
+ this.#setStartupTurnBarrier(config.startupTurnBarrier);
4534
+ // Only arm the recovery barrier when a rescope journal is actually pending. Arming it
4535
+ // unconditionally flips #startupTurnBarrier from absent to a (resolved) promise for
4536
+ // every session, which makes barrier-gated post-turn work (e.g. the hidden-next-turn
4537
+ // drain) take an extra scheduler hop and reorder against terminal-abort requeue.
4538
+ const rescopeRecoveryContext = {
4539
+ sessionId: this.sessionId,
4540
+ cwd: this.sessionManager.getCwd(),
4541
+ sessionFile: this.sessionManager.getSessionFile() ?? null,
4542
+ };
4543
+ if (hasCoordinatorRuntimeStateRescopeJournal(rescopeRecoveryContext)) {
4544
+ this.extendStartupTurnBarrier(recoverCoordinatorRuntimeStateRescope(rescopeRecoveryContext));
4545
+ }
4055
4546
  this.#scopedModels = config.scopedModels ?? [];
4056
4547
  this.#thinkingLevel = config.thinkingLevel;
4057
4548
  this.#promptTemplates = config.promptTemplates ?? [];
@@ -4139,7 +4630,7 @@ export class AgentSession {
4139
4630
  // agent.prompt directly, allocates right before admission.
4140
4631
  this.agent.followUp(message);
4141
4632
  },
4142
- injectIdle: async messages => {
4633
+ injectIdle: async (messages, signal) => {
4143
4634
  // Mandated boundary comment (corrected turn semantics): same origin
4144
4635
  // split as the streaming injector — an allowed owned-completion
4145
4636
  // delivery starts a fresh turn attempt/lineage and is not a
@@ -4153,37 +4644,43 @@ export class AgentSession {
4153
4644
  if (dropped.length > 0) this.#settleDeliveredOwnedRegistrations(dropped);
4154
4645
  const first = survivors[0];
4155
4646
  if (!first) return;
4156
- await this.#awaitStartupTurnBarrier();
4157
- if (this.#isDisposed) return;
4158
- // A user prompt may have started during the barrier/scheduling
4159
- // delay: if the session is now streaming, mutating the epoch and
4160
- // lineage here would corrupt the ACTIVE user turn (and
4161
- // agent.prompt would then reject as busy, losing the drained
4162
- // completion). Route the survivors through followUp — the
4163
- // streaming injector's path — which allocates the fresh resume
4164
- // lineage at actual admission (review thread P1).
4165
- if (this.isStreaming) {
4166
- for (const message of survivors) this.agent.followUp(message);
4167
- return;
4168
- }
4169
- if (survivors.some(message => ownedCompletionResumeAction(message) === "fresh"))
4170
- this.#resumeFromOwnedCompletion();
4647
+ const settleIfDisposing = (): boolean => {
4648
+ if (!this.#isDisposed && !this.#sessionAdmissionClosing && !this.#disposeAbortController.signal.aborted)
4649
+ return false;
4650
+ this.#settleDeliveredOwnedRegistrations(survivors);
4651
+ return true;
4652
+ };
4171
4653
  try {
4172
- if (survivors.length === 1) {
4173
- await this.agent.prompt(first, {
4174
- ...this.#managedFallbackPromptOptions(),
4175
- onRunAccepted: (handle: AttemptRunHandle) => {
4176
- if (handle) this.#acceptSdkAttemptRun(handle);
4177
- },
4178
- });
4179
- } else {
4180
- await this.agent.prompt(survivors, {
4181
- ...this.#managedFallbackPromptOptions(),
4182
- onRunAccepted: (handle: AttemptRunHandle) => {
4183
- if (handle) this.#acceptSdkAttemptRun(handle);
4184
- },
4185
- });
4186
- }
4654
+ await this.#withSessionAdmission(
4655
+ "prompt",
4656
+ async () => {
4657
+ if (settleIfDisposing()) return;
4658
+ if (this.isStreaming) {
4659
+ await awaitPromptInvocationPreflight(this.agent.waitForIdle(), signal);
4660
+ if (settleIfDisposing()) return;
4661
+ }
4662
+ if (survivors.some(message => ownedCompletionResumeAction(message) === "fresh"))
4663
+ this.#resumeFromOwnedCompletion();
4664
+ if (survivors.length === 1) {
4665
+ await this.agent.prompt(first, {
4666
+ ...this.#managedFallbackPromptOptions(),
4667
+ onRunAccepted: (handle: AttemptRunHandle) => {
4668
+ if (handle) this.#acceptSdkAttemptRun(handle);
4669
+ },
4670
+ });
4671
+ } else {
4672
+ await this.agent.prompt(survivors, {
4673
+ ...this.#managedFallbackPromptOptions(),
4674
+ onRunAccepted: (handle: AttemptRunHandle) => {
4675
+ if (handle) this.#acceptSdkAttemptRun(handle);
4676
+ },
4677
+ });
4678
+ }
4679
+ },
4680
+ signal,
4681
+ undefined,
4682
+ { idleDelivery: true },
4683
+ );
4187
4684
  } finally {
4188
4685
  // The owned completions were delivered OR the prompt attempt
4189
4686
  // failed (e.g. provider rejection): either way the yield
@@ -4195,16 +4692,19 @@ export class AgentSession {
4195
4692
  this.#settleDeliveredOwnedRegistrations(survivors);
4196
4693
  }
4197
4694
  },
4198
- scheduleIdleFlush: run => {
4695
+ scheduleIdleFlush: (run, onSkip) => {
4696
+ // The startup barrier already gates injectIdle, so begin waiting on a
4697
+ // pending barrier immediately. Once readiness has settled, ordinary
4698
+ // idle wakes retain the fixed merge window.
4699
+ const delayMs = this.#startupTurnBarrierPending ? 0 : FOLD_WAKE_MERGE_WINDOW_MS;
4199
4700
  this.#schedulePostPromptTask(
4200
- async () => {
4201
- await run();
4701
+ async signal => {
4702
+ await run(signal);
4202
4703
  },
4203
- // One merge window, so staggered completions share a single wake
4204
- // turn instead of each buying its own.
4205
- { delayMs: FOLD_WAKE_MERGE_WINDOW_MS },
4704
+ { delayMs, onSkip, excludeFromPostPromptRecovery: true },
4206
4705
  );
4207
4706
  },
4707
+ getIdleFlushSignal: () => this.#postPromptTasksAbortController.signal,
4208
4708
  });
4209
4709
  this.agent.setOnBeforeYield(() => this.yieldQueue.flush("streaming"));
4210
4710
  // Stop-after-result, never abort: a fold arms this once and the loop ends the
@@ -5116,21 +5616,26 @@ export class AgentSession {
5116
5616
 
5117
5617
  async #runToolSessionTransitionCleanups(): Promise<void> {
5118
5618
  const cleanups = Array.from(this.#toolSessionTransitionCleanups);
5119
- this.#toolSessionTransitionCleanups.clear();
5120
5619
  const results = await Promise.allSettled(cleanups.map(async cleanup => await cleanup()));
5121
- for (const result of results) {
5122
- if (result.status === "rejected")
5123
- 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);
5124
5625
  }
5626
+ if (failures.length > 0) throw new AggregateError(failures, "Tool session transition cleanup failed.");
5125
5627
  }
5126
5628
 
5127
5629
  async #runToolSessionCleanups(): Promise<void> {
5128
5630
  const cleanups = Array.from(this.#toolSessionCleanups);
5129
- this.#toolSessionCleanups.clear();
5130
5631
  const results = await Promise.allSettled(cleanups.map(async cleanup => await cleanup()));
5131
- for (const result of results) {
5132
- 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);
5133
5637
  }
5638
+ if (failures.length > 0) throw new AggregateError(failures, "Tool session cleanup failed.");
5134
5639
  }
5135
5640
 
5136
5641
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
@@ -5337,7 +5842,7 @@ export class AgentSession {
5337
5842
  #coordinatorToolObservations = new WeakMap<object, CoordinatorToolObservation>();
5338
5843
  #agentEventAdmission = new WeakMap<
5339
5844
  object,
5340
- { scope?: AttemptScope; sdkRunToken?: string; persistGeneration: number }
5845
+ { scope?: AttemptScope; sdkRunToken?: string; persistGeneration: number; persistBarrier?: Promise<void> }
5341
5846
  >();
5342
5847
 
5343
5848
  /**
@@ -5398,10 +5903,15 @@ export class AgentSession {
5398
5903
  scope: this.#activeAttemptScope,
5399
5904
  sdkRunToken: this.#activeSdkRunToken,
5400
5905
  persistGeneration: this.#coordinatorPersistGeneration,
5906
+ persistBarrier: this.#coordinatorRescopeBarrier,
5401
5907
  });
5402
5908
  // First statement of the listener: the observation must precede every claim,
5403
5909
  // reservation, and async hop this handler performs.
5404
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);
5405
5915
  // Reserve canonical message order synchronously. Agent listeners are not
5406
5916
  // awaited, so a tool-result spill may yield while a later continuation
5407
5917
  // otherwise overtakes it in persisted/display context.
@@ -5507,6 +6017,11 @@ export class AgentSession {
5507
6017
  this.#resolveSessionSettlement();
5508
6018
  }
5509
6019
  })();
6020
+ this.#coordinatorEventHandlers.add(handler);
6021
+ void handler.then(
6022
+ () => this.#coordinatorEventHandlers.delete(handler),
6023
+ () => this.#coordinatorEventHandlers.delete(handler),
6024
+ );
5510
6025
  if (eventLease) eventLease.track("post_prompt", "agent-session-event", handler);
5511
6026
  return handler;
5512
6027
  };
@@ -5515,7 +6030,7 @@ export class AgentSession {
5515
6030
  #coordinatorPersistQueue: Promise<void> = Promise.resolve();
5516
6031
 
5517
6032
  #recordPostPublicationOutcome(
5518
- context: { sessionId: string; cwd: string; sessionFile: string | undefined },
6033
+ context: CoordinatorRuntimeStatePersistContext,
5519
6034
  correlationId: string | undefined,
5520
6035
  kind: "worker_integration" | "terminal_persistence",
5521
6036
  outcome: { status: "completed" | "failed" | "timed_out"; error?: string },
@@ -5530,20 +6045,57 @@ export class AgentSession {
5530
6045
  message: `${kind} ${outcome.status} for ${correlation}${safeError ? `: ${safeError}` : ""}`,
5531
6046
  });
5532
6047
  }
5533
- const persist = () =>
5534
- persistCoordinatorWorkerIntegrationOutcome(context, {
5535
- ...outcome,
5536
- kind,
5537
- correlationId,
5538
- error: sanitizePostPublicationError(outcome.error),
5539
- });
5540
- const queued = this.#coordinatorPersistQueue.then(persist, persist);
5541
- this.#coordinatorPersistQueue = queued.catch(() => {});
6048
+ const generation = this.#coordinatorPersistGeneration;
6049
+ const barrier = this.#coordinatorRescopeBarrier;
6050
+ const persist = async () => {
6051
+ if (barrier) {
6052
+ await barrier;
6053
+ return await persistCoordinatorWorkerIntegrationOutcome(context, {
6054
+ ...outcome,
6055
+ kind,
6056
+ correlationId,
6057
+ error: sanitizePostPublicationError(outcome.error),
6058
+ });
6059
+ }
6060
+ return generation === this.#coordinatorPersistGeneration
6061
+ ? await persistCoordinatorWorkerIntegrationOutcome(context, {
6062
+ ...outcome,
6063
+ kind,
6064
+ correlationId,
6065
+ error: sanitizePostPublicationError(outcome.error),
6066
+ })
6067
+ : undefined;
6068
+ };
6069
+ const queued = barrier
6070
+ ? barrier.then(() => this.#appendCoordinatorPersist(persist))
6071
+ : this.#appendCoordinatorPersist(persist);
6072
+ if (barrier) this.#trackReleasedBarrierPersist(queued);
6073
+ else this.#trackUnbarrieredCoordinatorPersist(queued);
5542
6074
  void queued.catch(error =>
5543
- 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
+ ),
5544
6081
  );
5545
6082
  }
5546
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
+
5547
6099
  /**
5548
6100
  * Reserve this event's place in the sidecar write order.
5549
6101
  *
@@ -5559,35 +6111,66 @@ export class AgentSession {
5559
6111
  #queueCoordinatorRuntimeStatePersist(event: AgentSessionEvent, propagateFailure = false): Promise<void> {
5560
6112
  if (isNonDispatchedToolEvent(event)) return Promise.resolve();
5561
6113
  const observation = this.#coordinatorToolObservations.get(event);
5562
- const context = {
5563
- sessionId: this.sessionId,
5564
- cwd: this.sessionManager.getCwd(),
5565
- sessionFile: this.sessionManager.getSessionFile(),
5566
- };
5567
- const generation = this.#agentEventAdmission.get(event)?.persistGeneration ?? this.#coordinatorPersistGeneration;
6114
+ const admission = this.#agentEventAdmission.get(event);
6115
+ const barrier = admission?.persistBarrier;
6116
+ if (barrier) {
6117
+ const run = async () => {
6118
+ await barrier;
6119
+ const context = this.#captureCoordinatorRuntimeStatePersistContext();
6120
+ await this.#persistRuntimeStateInBackground(event, context, observation, propagateFailure);
6121
+ };
6122
+ const queued = barrier.then(() => this.#appendCoordinatorPersist(run));
6123
+ this.#trackReleasedBarrierPersist(queued);
6124
+ return queued;
6125
+ }
6126
+ const context = this.#captureCoordinatorRuntimeStatePersistContext();
6127
+ const generation = admission?.persistGeneration ?? this.#coordinatorPersistGeneration;
5568
6128
  const run = () =>
5569
6129
  generation === this.#coordinatorPersistGeneration
5570
6130
  ? this.#persistRuntimeStateInBackground(event, context, observation, propagateFailure)
5571
6131
  : Promise.resolve();
5572
- const queued = this.#coordinatorPersistQueue.then(run, run);
5573
- this.#coordinatorPersistQueue = queued.catch(() => {});
6132
+ const queued = this.#appendCoordinatorPersist(run);
6133
+ this.#trackUnbarrieredCoordinatorPersist(queued);
5574
6134
  return queued;
5575
6135
  }
5576
6136
 
5577
6137
  async #persistRuntimeStateInBackground(
5578
6138
  event: AgentSessionEvent,
5579
- context: { sessionId: string; cwd: string; sessionFile: string | undefined },
6139
+ context: CoordinatorRuntimeStatePersistContext,
5580
6140
  observation: CoordinatorToolObservation | undefined,
5581
6141
  propagateFailure: boolean,
5582
6142
  ): Promise<void> {
5583
6143
  try {
5584
6144
  await persistCoordinatorRuntimeStateFromEvent(event, context, observation);
5585
6145
  } catch (error) {
5586
- 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
+ );
5587
6153
  if (propagateFailure) throw error;
5588
6154
  }
5589
6155
  }
5590
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
+
5591
6174
  async #emitSessionEvent(event: AgentSessionEvent, eventLease?: RunResourceProducerLease): Promise<void> {
5592
6175
  const attemptScope = (event as AgentSessionEvent & { scope?: AttemptScope }).scope;
5593
6176
  if (event.type === "turn_start") {
@@ -5645,9 +6228,7 @@ export class AgentSession {
5645
6228
  // Do not let a lock-hostile sidecar suppress the terminal event itself.
5646
6229
  const terminalPersistence = this.#queueCoordinatorRuntimeStatePersist(event);
5647
6230
  this.#emit(event);
5648
- void terminalPersistence.catch(error => {
5649
- logger.warn("Failed to persist terminal coordinator runtime state", { error: String(error) });
5650
- });
6231
+ void terminalPersistence;
5651
6232
  await this.#emitExtensionEvent(event);
5652
6233
  return;
5653
6234
  }
@@ -6530,9 +7111,11 @@ export class AgentSession {
6530
7111
  });
6531
7112
  }
6532
7113
  this.#resolveRetry();
7114
+ if (this.#isDisposed || this.#sessionAdmissionClosing) return;
6533
7115
 
6534
7116
  const compactionTask = this.#schedulePostPromptTask(
6535
7117
  async () => {
7118
+ if (this.#isDisposed || this.#sessionAdmissionClosing) return;
6536
7119
  await this.#checkCompaction(msg, true, undefined, activePromptHandle);
6537
7120
  },
6538
7121
  { resourceRunId: activePromptHandle },
@@ -6605,9 +7188,11 @@ export class AgentSession {
6605
7188
  selectionFenceGeneration: number,
6606
7189
  lease?: RunResourceProducerLease,
6607
7190
  leaseTask: Promise<void> = task,
7191
+ excludeFromRecovery = false,
6608
7192
  ): void {
6609
7193
  this.#postPromptTasks.add(task);
6610
7194
  this.#postPromptTaskSelectionFenceGenerations.set(task, selectionFenceGeneration);
7195
+ if (excludeFromRecovery) this.#postPromptTaskRecoveryExcluded.add(task);
6611
7196
  this.#ensurePostPromptTasksPromise();
6612
7197
  if (lease) {
6613
7198
  lease.track("post_prompt", "agent-session", leaseTask);
@@ -6618,6 +7203,7 @@ export class AgentSession {
6618
7203
  .finally(() => {
6619
7204
  this.#postPromptTasks.delete(task);
6620
7205
  this.#postPromptTaskSelectionFenceGenerations.delete(task);
7206
+ this.#postPromptTaskRecoveryExcluded.delete(task);
6621
7207
  if (this.#postPromptTasks.size === 0) this.#resolvePostPromptTasks();
6622
7208
  });
6623
7209
  }
@@ -6631,6 +7217,7 @@ export class AgentSession {
6631
7217
  resourceRunId?: string;
6632
7218
  leaseTask?: Promise<void>;
6633
7219
  selectionFenceGeneration?: number;
7220
+ excludeFromPostPromptRecovery?: boolean;
6634
7221
  },
6635
7222
  ): Promise<void> {
6636
7223
  const selectionFenceGeneration =
@@ -6657,7 +7244,11 @@ export class AgentSession {
6657
7244
  options?.onSkip?.();
6658
7245
  return Promise.resolve();
6659
7246
  }
6660
- const signal = reservation?.ok ? reservation.lease.signal : this.#postPromptTasksAbortController.signal;
7247
+ const taskSignal = reservation?.ok ? reservation.lease.signal : this.#postPromptTasksAbortController.signal;
7248
+ // Disposal is a session-wide terminal fence. Compose it with the selected
7249
+ // logical-resource signal so stale leases from earlier runs cannot keep a
7250
+ // post-prompt task alive after the current run was quarantined.
7251
+ const signal = AbortSignal.any([taskSignal, this.#disposeAbortController.signal]);
6661
7252
  const runScheduled = async () => {
6662
7253
  if (delayMs > 0) {
6663
7254
  try {
@@ -6685,6 +7276,7 @@ export class AgentSession {
6685
7276
  selectionFenceGeneration,
6686
7277
  reservation?.ok ? reservation.lease : undefined,
6687
7278
  options?.leaseTask,
7279
+ options?.excludeFromPostPromptRecovery,
6688
7280
  );
6689
7281
  return scheduled;
6690
7282
  }
@@ -6818,7 +7410,7 @@ export class AgentSession {
6818
7410
  settleLease();
6819
7411
  return;
6820
7412
  }
6821
- await this.#awaitStartupTurnBarrier();
7413
+ await this.#awaitStartupTurnBarrier(scheduledSignal);
6822
7414
  if (!canContinue()) {
6823
7415
  settleLease();
6824
7416
  return;
@@ -6840,10 +7432,14 @@ export class AgentSession {
6840
7432
  skip("queue_drained");
6841
7433
  return;
6842
7434
  }
6843
- // A scheduled same-turn continuation of a terminally aborted turn is
6844
- // denied at the final synchronous boundary before agent.continue
6845
- // entry; no await intervenes between this check and method entry.
6846
- // 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();
6847
7443
  if (this.#isTurnContinuationBlocked()) {
6848
7444
  skip("terminal_turn");
6849
7445
  return;
@@ -7222,6 +7818,7 @@ export class AgentSession {
7222
7818
  this.#postPromptTasksAbortController = new AbortController();
7223
7819
  this.#postPromptTasks.clear();
7224
7820
  this.#postPromptTaskSelectionFenceGenerations.clear();
7821
+ this.#postPromptTaskRecoveryExcluded.clear();
7225
7822
  this.#releaseDeferredAgentEndContinuations();
7226
7823
  this.#resolveTtsrResume();
7227
7824
  this.#resolvePostPromptTasks();
@@ -7243,8 +7840,11 @@ export class AgentSession {
7243
7840
  await this.#ttsrResumePromise;
7244
7841
  continue;
7245
7842
  }
7246
- if (this.#postPromptTasksPromise) {
7247
- await this.#postPromptTasksPromise;
7843
+ const recoveryTasks = [...this.#postPromptTasks].filter(
7844
+ task => !this.#postPromptTaskRecoveryExcluded.has(task),
7845
+ );
7846
+ if (recoveryTasks.length > 0) {
7847
+ await Promise.allSettled(recoveryTasks);
7248
7848
  continue;
7249
7849
  }
7250
7850
  // Tracked post-prompt tasks cover deferred continuations scheduled from
@@ -7266,7 +7866,9 @@ export class AgentSession {
7266
7866
  */
7267
7867
  async #waitForPostPromptTasksBeforeSelectionFence(selectionFenceGeneration: number): Promise<void> {
7268
7868
  const precedingTasks = [...this.#postPromptTasks].filter(
7269
- task => (this.#postPromptTaskSelectionFenceGenerations.get(task) ?? 0) < selectionFenceGeneration,
7869
+ task =>
7870
+ !this.#postPromptTaskRecoveryExcluded.has(task) &&
7871
+ (this.#postPromptTaskSelectionFenceGenerations.get(task) ?? 0) < selectionFenceGeneration,
7270
7872
  );
7271
7873
  if (precedingTasks.length > 0) await Promise.allSettled(precedingTasks);
7272
7874
  }
@@ -8385,16 +8987,122 @@ export class AgentSession {
8385
8987
  */
8386
8988
  dispose(): Promise<void> {
8387
8989
  this.#evalExecutionDisposing = true;
8388
- if (this.#disposePromise) return this.#disposePromise;
8389
- const { promise, resolve, reject } = Promise.withResolvers<void>();
8390
- this.#disposePromise = promise;
8391
- void this.#dispose().then(resolve, reject);
8392
- 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();
9070
+ }
9071
+
9072
+ /** Cancel the active logical run domain, including managed continuations detached from Agent's active attempt. */
9073
+ #quarantineAgentRunResources(): void {
9074
+ const resourceRunId = this.agent.currentManagedLogicalRunId ?? this.agent.activeResourceRunId;
9075
+ if (resourceRunId !== undefined) this.agent.resourceLedger.quarantine(String(resourceRunId));
8393
9076
  }
8394
9077
 
8395
9078
  async #dispose(): Promise<void> {
8396
- await this.sessionManager.joinCwdTransition();
8397
- const admissionClosed = this.#closeSessionAdmission();
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
+ };
9101
+ const admissionClosed = this.#disposeAdmissionClosed ?? this.#closeSessionAdmission();
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);
8398
9106
  this.#isDisposed = true;
8399
9107
  // Reject new direct Python starts as soon as disposal begins (synchronously,
8400
9108
  // before any await) so callers cannot race a start against teardown.
@@ -8412,83 +9120,116 @@ export class AgentSession {
8412
9120
  // R2-5: join any in-flight mid-run maintenance invocation before teardown so the
8413
9121
  // abort-aware maintenance promise (already aborted above) settles and cannot touch
8414
9122
  // torn-down state afterward.
8415
- await this.#waitForActiveMidRunMaintenance();
9123
+ await awaitDisposeStep("mid-run maintenance", this.#waitForActiveMidRunMaintenance());
8416
9124
  // R2-5: give the aborted Agent run a bounded chance to settle, then force-
8417
9125
  // invalidate its run id so an abort-ignoring provider/tool cannot emit a late
8418
9126
  // message_end after teardown. Mirrors AgentSession.abort({ timeoutMs }).
9127
+ const idleBudgetMs = Math.max(0, Math.min(2_000, this.#disposeDeadline - Date.now()));
8419
9128
  const disposeIdleSettled = await Promise.race([
8420
9129
  this.agent.waitForIdle().then(
8421
9130
  () => true,
8422
9131
  () => true,
8423
9132
  ),
8424
- Bun.sleep(2_000).then(() => false),
9133
+ Bun.sleep(idleBudgetMs).then(() => false),
8425
9134
  ]);
8426
9135
  if (!disposeIdleSettled) {
8427
- try {
8428
- this.agent.forceAbort("Session disposed", this.#activeLogicalRunId);
8429
- } catch {
8430
- // AttemptScope handle may not be registered for sessions
8431
- // that don't participate in the facility (e.g. test mocks).
8432
- this.agent.forceAbort("Session disposed");
8433
- }
9136
+ this.#forceSessionRecovery();
8434
9137
  }
8435
- await admissionClosed;
8436
- await this.#agentEndPublicationPromise;
8437
- await this.#queuedExtensionEvents;
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);
8438
9148
  // Drain the sidecar write order for the same reason the two queues above are
8439
9149
  // drained: each entry writes under the native identity-bound state-file lock, so a
8440
- // still-queued write would run after the session that owns it is gone — releasing
9150
+ // a still-queued write would run after the session that owns it is gone — releasing
8441
9151
  // a lock whose owner no longer exists, and under `bun test --isolate` calling into
8442
9152
  // the addon after the runtime tore down the context it was scheduled in. The chain
8443
9153
  // is already failure-absorbing, so this only waits.
8444
- await this.#coordinatorPersistQueue;
9154
+ await awaitDisposeStep("coordinator persistence", this.#coordinatorPersistQueue, true);
9155
+ // Terminal publication records its reconciliation from a promise reaction, which
9156
+ // can append after the queue snapshot above was awaited. Drain the tracked writes
9157
+ // again so disposal cannot return while that late reconciliation still owns a lock.
9158
+ await awaitDisposeStep("unbarriered coordinator persistence", this.#drainUnbarrieredCoordinatorPersists(), true);
8445
9159
  this.#pendingBackgroundExchanges = [];
8446
- this.yieldQueue.clear();
9160
+ this.#drainTerminalOwnedYieldEntries();
8447
9161
 
8448
9162
  this.agent.setOnBeforeYield(undefined);
8449
9163
  try {
8450
9164
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
8451
- await this.#extensionRunner.emit({ type: "session_shutdown" });
9165
+ await awaitDisposeStep(
9166
+ "session shutdown handlers",
9167
+ this.#extensionRunner.emit({ type: "session_shutdown" }),
9168
+ );
8452
9169
  }
8453
9170
  } catch (error) {
9171
+ if (isSessionDisposalIncompleteError(error)) throw error;
8454
9172
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
8455
9173
  }
8456
9174
  this.#workflowGateEmitter?.fence?.();
8457
9175
  this.#workflowGateEmitter = undefined;
8458
9176
  this.#notifyWorkflowGateEmitterChanged(this.sessionId, undefined);
8459
- await this.#flushWorkerIntegrationAttempt();
8460
- await this.#cancelPostPromptTasks();
9177
+ await awaitDisposeStep("worker integration", this.#flushWorkerIntegrationAttempt(), true);
9178
+ await awaitDisposeStep(
9179
+ "worker integration ownership",
9180
+ this.#workerIntegrationScheduler?.joinOwnedRequests() ?? Promise.resolve(),
9181
+ true,
9182
+ );
9183
+ // Worker integration completion can enqueue terminal reconciliation while the
9184
+ // flush above is pending; drain that late producer before disposal resolves.
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);
8461
9188
  // Cancel jobs this agent registered so a subagent's teardown doesn't
8462
9189
  // leak its background bash/task work into the parent's manager. Only
8463
9190
  // the session that owns the manager goes on to dispose it (which itself
8464
9191
  // nukes any leftover jobs and pending deliveries).
8465
9192
  this.#cancelOwnAsyncJobs();
8466
- await Promise.allSettled(this.#deferredOwnerShutdownFinalizations);
9193
+ // Final drain (Codex review P2 on #5088): an async-job completion that
9194
+ // enqueued during the awaited session_shutdown window above survived the
9195
+ // first drain with a retained delivery claim, and the already-aborted
9196
+ // disposal signal makes its idle-flush task exit before YieldQueue.flush
9197
+ // can run. Producers are now cancelled, so drain once more to settle any
9198
+ // straggler envelope and release its claim before owned-manager teardown
9199
+ // — a shared manager would otherwise keep the claim past this session's
9200
+ // disposal.
9201
+ this.#drainTerminalOwnedYieldEntries();
9202
+ await awaitDisposeStep("deferred owner shutdown", Promise.allSettled(this.#deferredOwnerShutdownFinalizations));
8467
9203
  const ownedAsyncManager = this.#ownedAsyncJobManager;
8468
9204
  if (ownedAsyncManager && this.#disposeAsyncJobManager) {
8469
- const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
9205
+ const drained = await awaitDisposeStep("async job manager", ownedAsyncManager.dispose({ timeoutMs: 3_000 }));
8470
9206
  const deliveryState = ownedAsyncManager.getDeliveryState();
8471
9207
  if (drained === false && deliveryState) {
8472
9208
  logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
8473
9209
  }
8474
- if (AsyncJobManager.instance() === ownedAsyncManager) {
8475
- AsyncJobManager.setInstance(undefined);
9210
+ if (drained === false) {
9211
+ await awaitDisposeStep(
9212
+ "async job manager retained runners",
9213
+ ownedAsyncManager.awaitRetainedDisposalCompletion(),
9214
+ true,
9215
+ );
8476
9216
  }
9217
+ if (AsyncJobManager.instance() === ownedAsyncManager) AsyncJobManager.setInstance(undefined);
8477
9218
  }
8478
- await this.#runToolSessionTransitionCleanups();
8479
- await this.#runToolSessionCleanups();
9219
+ await awaitDisposeStep("tool session transition cleanups", this.#runToolSessionTransitionCleanups(), true);
9220
+ await awaitDisposeStep("tool session cleanups", this.#runToolSessionCleanups(), true);
8480
9221
  // Only disconnect the MCP manager THIS session owns (top-level sessions that
8481
9222
  // connected plugin-bundle MCP servers). Subagents and callers that merely
8482
9223
  // observe the process-global manager must never tear down a manager they do
8483
9224
  // not own. Mirrors the ownedAsyncJobManager rule above.
8484
9225
  const ownedMcpManager = this.#ownedMcpManager;
8485
9226
  if (ownedMcpManager) {
8486
- await ownedMcpManager.releaseLeases();
9227
+ await awaitDisposeStep("MCP lease release", ownedMcpManager.releaseLeases());
8487
9228
  if (MCPManager.instance() === ownedMcpManager) {
8488
9229
  MCPManager.setInstance(undefined);
8489
9230
  }
8490
9231
  }
8491
- await shutdownAllLspClients();
9232
+ await awaitDisposeStep("LSP shutdown", shutdownAllLspClients());
8492
9233
  // F13: release only THIS session's browser tabs on dispose (kill:false → remote
8493
9234
  // browsers disconnect, headless close gracefully). Scoped by the session id the
8494
9235
  // browser tool tagged tabs with, so other live sessions' tabs are untouched.
@@ -8499,29 +9240,48 @@ export class AgentSession {
8499
9240
  this.#unregisterSessionMemorySettings = undefined;
8500
9241
  if (ownerTerminalContextFromEnvironment() === null) this.#unregisterRuntimeStateFinalizer?.();
8501
9242
  this.#unregisterRuntimeStateFinalizer = undefined;
8502
- await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
8503
- logger.warn("session dispose: releaseTabsForOwner failed", { error }),
9243
+ this.#unregisterBeforeMoveListener?.();
9244
+ this.#unregisterBeforeMoveListener = undefined;
9245
+ this.#unregisterMoveAbortListener?.();
9246
+ this.#unregisterMoveAbortListener = undefined;
9247
+ this.#unregisterMovePublicationListener?.();
9248
+ this.#unregisterMovePublicationListener = undefined;
9249
+ this.#unregisterAfterMoveListener?.();
9250
+ this.#unregisterAfterMoveListener = undefined;
9251
+ await awaitDisposeStep("browser tab release", releaseTabsForOwner(this.sessionManager.getSessionId()), true);
9252
+ const pythonExecutionsSettled = await awaitDisposeStep(
9253
+ "Python execution preparation",
9254
+ this.#prepareEvalExecutionsForDispose(),
8504
9255
  );
8505
- const pythonExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
8506
9256
  if (!pythonExecutionsSettled) {
8507
9257
  logger.warn(
8508
9258
  "Detaching retained Python kernel ownership during dispose while Python execution is still active",
8509
9259
  );
8510
9260
  }
8511
- await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
8512
- await disposeVmContextsByOwner(this.#evalKernelOwnerId);
9261
+ await awaitDisposeStep("Python kernel sessions", disposeKernelSessionsByOwner(this.#evalKernelOwnerId));
9262
+ await awaitDisposeStep("VM contexts", disposeVmContextsByOwner(this.#evalKernelOwnerId));
8513
9263
  this.#releasePowerAssertion();
8514
9264
  // Disconnect the agent event listener BEFORE closing session resources so a late
8515
9265
  // provider/tool message_end cannot append to the closing SessionManager.
8516
9266
  this.#disconnectFromAgent();
8517
- await this.memoryBackend.dispose();
8518
- if (this.#workspaceTreeService) await this.#workspaceTreeService.dispose();
8519
- 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);
8520
9272
  this.#modelRegistry.authStorage?.releaseCredentialScope(this.credentialSessionId);
8521
- 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
+ }
8522
9282
  this.#closeAllProviderSessions("dispose");
8523
9283
  const hindsightState = this.getHindsightSessionState();
8524
- await hindsightState?.dispose();
9284
+ await awaitDisposeStep("hindsight state", hindsightState?.dispose() ?? Promise.resolve());
8525
9285
  this.setHindsightSessionState(undefined);
8526
9286
  if (this.#unsubscribeAppendOnly) {
8527
9287
  this.#unsubscribeAppendOnly();
@@ -8529,7 +9289,35 @@ export class AgentSession {
8529
9289
  }
8530
9290
  this.#eventListeners = [];
8531
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
+ }
8532
9319
  }
9320
+
8533
9321
  /**
8534
9322
  * Strict writer close for ACP session delete. On the first attempt it flushes
8535
9323
  * pending writes, then returns the certainty-aware close outcome so the caller
@@ -8663,14 +9451,24 @@ export class AgentSession {
8663
9451
  await this.#waitForPostPromptTasksBeforeSelectionFence(ignoreSelectionFenceGeneration);
8664
9452
  }
8665
9453
  await this.#waitForSessionSettlement(ignoreSelectionFenceGeneration);
9454
+ const hasBlockingPostPromptTasks =
9455
+ ignoreSelectionFenceGeneration === undefined
9456
+ ? this.#postPromptTasks.size > 0
9457
+ : [...this.#postPromptTasks].some(
9458
+ task =>
9459
+ !this.#postPromptTaskRecoveryExcluded.has(task) &&
9460
+ (this.#postPromptTaskSelectionFenceGenerations.get(task) ?? 0) < ignoreSelectionFenceGeneration,
9461
+ );
8666
9462
  if (
8667
9463
  !this.agent.state.isStreaming &&
8668
9464
  !this.#retryPromise &&
8669
9465
  !this.#ttsrResumePromise &&
8670
- !this.#postPromptTasksPromise &&
9466
+ !hasBlockingPostPromptTasks &&
8671
9467
  !this.#isSessionSettlementPending(ignoreSelectionFenceGeneration)
8672
9468
  )
8673
9469
  return;
9470
+ if (ignoreSelectionFenceGeneration === undefined && hasBlockingPostPromptTasks && this.#postPromptTasksPromise)
9471
+ await this.#postPromptTasksPromise;
8674
9472
  }
8675
9473
  }
8676
9474
 
@@ -8695,6 +9493,42 @@ export class AgentSession {
8695
9493
  await this.sessionManager.flush();
8696
9494
  }
8697
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
+
9503
+ queueCoordinatorRuntimeStatePersistForTests(event: AgentSessionEvent, gate: Promise<void>): Promise<void> {
9504
+ this.#agentEventAdmission.set(event, {
9505
+ persistGeneration: this.#coordinatorPersistGeneration,
9506
+ persistBarrier: this.#coordinatorRescopeBarrier,
9507
+ });
9508
+ const task = (async () => {
9509
+ await gate;
9510
+ await this.#queueCoordinatorRuntimeStatePersist(event, true);
9511
+ })();
9512
+ this.#coordinatorEventHandlers.add(task);
9513
+ void task.then(
9514
+ () => this.#coordinatorEventHandlers.delete(task),
9515
+ () => this.#coordinatorEventHandlers.delete(task),
9516
+ );
9517
+ return task;
9518
+ }
9519
+
9520
+ parkAgentEndForCoordinatorPersistForTests(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
9521
+ this.#agentEventAdmission.set(event, {
9522
+ persistGeneration: this.#coordinatorPersistGeneration,
9523
+ persistBarrier: this.#coordinatorRescopeBarrier,
9524
+ });
9525
+ this.#pendingAgentEndEmit = event;
9526
+ }
9527
+
9528
+ flushParkedAgentEndForCoordinatorPersistForTests(): void {
9529
+ this.#flushPendingAgentEnd();
9530
+ }
9531
+
8698
9532
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
8699
9533
  const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
8700
9534
  if (!manager) return false;
@@ -8970,23 +9804,77 @@ export class AgentSession {
8970
9804
  this.#setGuardedAgentTools(activeTools);
8971
9805
  }
8972
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
+
8973
9822
  /**
8974
9823
  * Ask the active managed foreground bash call to return as a background job.
8975
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.
8976
9842
  */
8977
- async requestForegroundBashBackground(): Promise<boolean> {
8978
- 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
+ }
8979
9850
  try {
8980
- const result = await this.#foldCoordinator.requestFold();
8981
- 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 };
8982
9864
  } catch (error) {
8983
9865
  logger.warn("Foreground fold request failed", {
8984
9866
  error: error instanceof Error ? error.message : String(error),
8985
9867
  });
8986
- return false;
9868
+ return { status: "not_foldable", reason: error instanceof Error ? error.message : String(error) };
8987
9869
  }
8988
9870
  }
8989
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
+ }
8990
9878
  /**
8991
9879
  * Get all configured tool names (built-in via --tools or default, plus custom tools).
8992
9880
  */
@@ -9277,7 +10165,10 @@ export class AgentSession {
9277
10165
  // The lease keeps writers out for the whole execution, so this can
9278
10166
  // only trip if a caller bypassed the lease; surface it rather than
9279
10167
  // returning a result computed against a retired cwd.
9280
- if (this.sessionManager.getCwdGeneration() !== admittedGeneration) {
10168
+ if (
10169
+ this.sessionManager.getCwdGeneration() !== admittedGeneration &&
10170
+ !this.sessionManager.consumeNestedCwdMove(admittedGeneration)
10171
+ ) {
9281
10172
  throw new Error(
9282
10173
  "Session working directory changed while this tool executed; retry against the new cwd.",
9283
10174
  );
@@ -10077,8 +10968,8 @@ export class AgentSession {
10077
10968
  }
10078
10969
 
10079
10970
  /** Current interrupt mode */
10080
- get interruptMode(): "immediate" | "wait" {
10081
- return this.agent.getInterruptMode();
10971
+ get toolInterruptPolicy(): "abort_tools" | "finish_tools" {
10972
+ return this.agent.getToolInterruptPolicy();
10082
10973
  }
10083
10974
 
10084
10975
  /** Current session file path, or undefined if sessions are disabled */
@@ -10165,7 +11056,7 @@ export class AgentSession {
10165
11056
  thinking: this.#thinkingLevel ?? "off",
10166
11057
  steeringMode: this.steeringMode,
10167
11058
  followUpMode: this.followUpMode,
10168
- interruptMode: this.interruptMode,
11059
+ toolInterruptPolicy: this.toolInterruptPolicy,
10169
11060
  };
10170
11061
  }
10171
11062
 
@@ -10924,6 +11815,19 @@ export class AgentSession {
10924
11815
  * @throws Error if no model selected or no API key available (when not streaming)
10925
11816
  */
10926
11817
  async prompt(text: string, options?: PromptOptions): Promise<void> {
11818
+ const releaseStartupPromptWaiter = this.#reserveStartupPromptWaiter();
11819
+ try {
11820
+ await this.#promptInternal(text, options, releaseStartupPromptWaiter);
11821
+ } finally {
11822
+ releaseStartupPromptWaiter();
11823
+ }
11824
+ }
11825
+
11826
+ async #promptInternal(
11827
+ text: string,
11828
+ options: PromptOptions | undefined,
11829
+ releaseStartupPromptWaiter: () => void,
11830
+ ): Promise<void> {
10927
11831
  const hasUsableImage =
10928
11832
  options?.images?.some(image => typeof image?.data === "string" && image.data.trim().length > 0) === true;
10929
11833
  if (typeof text !== "string" || (text.trim().length === 0 && !hasUsableImage))
@@ -10937,7 +11841,12 @@ export class AgentSession {
10937
11841
  if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
10938
11842
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
10939
11843
 
10940
- 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
+ ) {
10941
11850
  const skillCommands = new Map(this.skills.map(skill => [getSkillSlashCommandName(skill), skill]));
10942
11851
  const invocations = parseSkillInvocations(text, skillCommands);
10943
11852
  if (invocations.length === 1) {
@@ -11023,7 +11932,15 @@ export class AgentSession {
11023
11932
 
11024
11933
  // If streaming, queue via steer() or followUp() based on option.
11025
11934
  // Abort unwind is awaited above so a successor is not busy against leftover in-flight.
11026
- 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) {
11027
11944
  if (!options?.streamingBehavior) {
11028
11945
  throw new AgentBusyError();
11029
11946
  }
@@ -11033,7 +11950,10 @@ export class AgentSession {
11033
11950
  claimsGenuineUserIntent,
11034
11951
  });
11035
11952
  } else {
11036
- await this.#queueSteer(expandedText, options?.images, { claimsGenuineUserIntent });
11953
+ await this.#queueSteer(expandedText, options?.images, {
11954
+ claimsGenuineUserIntent,
11955
+ forceOneAtATime: options.steerQueuePolicy === "sequential",
11956
+ });
11037
11957
  }
11038
11958
  if (workflowIntentDiff) {
11039
11959
  this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
@@ -11046,6 +11966,35 @@ export class AgentSession {
11046
11966
  await this.#withSessionAdmission(
11047
11967
  "prompt",
11048
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
+ }
11049
11998
  this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
11050
11999
  if (workflowIntentDiff) {
11051
12000
  this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
@@ -11069,7 +12018,12 @@ export class AgentSession {
11069
12018
  attribution: promptAttribution,
11070
12019
  timestamp: Date.now(),
11071
12020
  }
11072
- : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
12021
+ : {
12022
+ role: "user" as const,
12023
+ content: userContent,
12024
+ attribution: promptAttribution,
12025
+ timestamp: Date.now(),
12026
+ };
11073
12027
  if (deepInterviewUserIntentEpoch !== undefined)
11074
12028
  this.#deepInterviewGenuineUserMessageEpochs.set(message, deepInterviewUserIntentEpoch);
11075
12029
  await this.refreshGjcSubskillTools();
@@ -11097,6 +12051,8 @@ export class AgentSession {
11097
12051
  }
11098
12052
  },
11099
12053
  options?.preflightSignal,
12054
+ undefined,
12055
+ { startupPromptWaiterRelease: releaseStartupPromptWaiter },
11100
12056
  );
11101
12057
  }
11102
12058
 
@@ -11213,6 +12169,7 @@ export class AgentSession {
11213
12169
  | "streamingBehavior"
11214
12170
  | "toolChoice"
11215
12171
  | "followUpQueuePolicy"
12172
+ | "steerQueuePolicy"
11216
12173
  | "onPreflightAccepted"
11217
12174
  | "onPreflightAcceptCommit"
11218
12175
  | "preflightSignal"
@@ -11248,6 +12205,7 @@ export class AgentSession {
11248
12205
  await this.sendCustomMessage(message, {
11249
12206
  deliverAs: options.streamingBehavior,
11250
12207
  followUpQueuePolicy: options.followUpQueuePolicy,
12208
+ steerQueuePolicy: options.steerQueuePolicy,
11251
12209
  });
11252
12210
  return;
11253
12211
  }
@@ -11328,6 +12286,7 @@ export class AgentSession {
11328
12286
  skipPostPromptRecoveryWait?: boolean;
11329
12287
  predecessorAgentEndHold?: symbol;
11330
12288
  admissionLease?: SessionAdmissionLease;
12289
+ skipInitialSteeringPoll?: boolean;
11331
12290
  onRunAccepted?: (handle: AttemptRunHandle) => void;
11332
12291
  onFinalPreflight?: (context: { hasPendingNextTurnMessages: boolean }) => Promise<boolean>;
11333
12292
  resetRetryReplaySafety?: boolean;
@@ -11658,6 +12617,7 @@ export class AgentSession {
11658
12617
 
11659
12618
  const agentPromptOptions = {
11660
12619
  ...(options?.toolChoice ? { toolChoice: options.toolChoice } : undefined),
12620
+ ...(options?.skipInitialSteeringPoll ? { skipInitialSteeringPoll: true } : {}),
11661
12621
  ...this.#managedFallbackPromptOptions(),
11662
12622
  ...(options?.sdkRunToken ? { sdkRunToken: options.sdkRunToken } : {}),
11663
12623
  onRunAccepted: (handle: AttemptRunHandle) => {
@@ -11828,8 +12788,8 @@ export class AgentSession {
11828
12788
  this.setFollowUpMode(mode);
11829
12789
  return true;
11830
12790
  }
11831
- if (kind === "interrupt" && (mode === "immediate" || mode === "wait")) {
11832
- this.setInterruptMode(mode);
12791
+ if (kind === "tool_interrupt" && (mode === "abort_tools" || mode === "finish_tools")) {
12792
+ this.setToolInterruptPolicy(mode);
11833
12793
  return true;
11834
12794
  }
11835
12795
  return false;
@@ -12005,6 +12965,7 @@ export class AgentSession {
12005
12965
  onPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
12006
12966
  external?: boolean;
12007
12967
  sdkRunToken?: string;
12968
+ forceOneAtATime?: boolean;
12008
12969
  },
12009
12970
  ): Promise<void> {
12010
12971
  this.#assertNoHandoffTransition();
@@ -12013,6 +12974,26 @@ export class AgentSession {
12013
12974
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
12014
12975
  if (images && images.length > 0) content.push(...images);
12015
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);
12016
12997
  this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText, undefined, message));
12017
12998
  if (options?.external) {
12018
12999
  this.#externalSteerMessages.add(message);
@@ -12024,22 +13005,42 @@ export class AgentSession {
12024
13005
  const epoch = this.#claimDeepInterviewUserIntent();
12025
13006
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
12026
13007
  }
12027
- this.agent.steer(message);
12028
- // A live agent loop polls the steering queue at every tool/turn boundary
12029
- // and consumes this message on its own. But when a steer is queued while no
12030
- // loop is actively running — e.g. the session still reports busy only
12031
- // because a finished prompt is unwinding (deferred agent_end / post-prompt
12032
- // work) — nothing delivers it until the next explicit prompt or a
12033
- // user-interrupt abort, so it stalls until the user presses Esc. Schedule a
12034
- // continue so the steer is delivered promptly. A live loop (or an
12035
- // already-drained queue) makes the scheduled continue a no-op.
12036
- if (!this.#cancelAndSubmitInProgress && this.#canAutoContinueForSteer()) {
12037
- this.#scheduleAgentContinue({
12038
- shouldContinue: () => this.#canAutoContinueForSteer() && this.agent.hasQueuedSteering(),
12039
- rescheduleOnBusy: true,
12040
- continueQueuedOnly: true,
12041
- });
12042
- }
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
+ });
12043
13044
  }
12044
13045
 
12045
13046
  /**
@@ -12053,6 +13054,7 @@ export class AgentSession {
12053
13054
  claimsGenuineUserIntent?: boolean;
12054
13055
  onPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
12055
13056
  sdkRunToken?: string;
13057
+ onQueued?: (message: AgentMessage) => void;
12056
13058
  },
12057
13059
  ): Promise<QueuedFollowUpOwner> {
12058
13060
  this.#assertNoHandoffTransition();
@@ -12062,6 +13064,7 @@ export class AgentSession {
12062
13064
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
12063
13065
  if (images && images.length > 0) content.push(...images);
12064
13066
  const message = { role: "user" as const, content, attribution: "user" as const, timestamp: Date.now() };
13067
+ options?.onQueued?.(message);
12065
13068
  // Display entry carries the message identity so positional editing can never
12066
13069
  // misaddress a deferred SDK follow-up held outside the Agent live queue.
12067
13070
  const displayEntry = this.#createQueuedDisplayEntry(displayText, undefined, message);
@@ -12145,6 +13148,7 @@ export class AgentSession {
12145
13148
  * Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
12146
13149
  */
12147
13150
  #canAutoContinueForFollowUp(): boolean {
13151
+ if (this.#abortUnwind) return false;
12148
13152
  if (this.isStreaming) return false;
12149
13153
  if (this.isCompacting) return false;
12150
13154
  if (this.isBashRunning) return false;
@@ -12170,6 +13174,24 @@ export class AgentSession {
12170
13174
  }
12171
13175
  }
12172
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
+
12173
13195
  /**
12174
13196
  * Gate for idle / winding-down steer auto-continue. Unlike the follow-up gate
12175
13197
  * this checks `agent.state.isStreaming` (a live agent loop) rather than the
@@ -12178,6 +13200,7 @@ export class AgentSession {
12178
13200
  * false here because it polls the steering queue itself.
12179
13201
  */
12180
13202
  #canAutoContinueForSteer(): boolean {
13203
+ if (this.#abortUnwind) return false;
12181
13204
  if (this.agent.state.isStreaming) return false;
12182
13205
  if (this.isRetrying) return false;
12183
13206
  const messages = this.agent.state.messages;
@@ -12185,6 +13208,13 @@ export class AgentSession {
12185
13208
  return last?.role === "assistant" || last?.role === "bashExecution" || last?.role === "pythonExecution";
12186
13209
  }
12187
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
+
12188
13218
  queueDeferredMessage(message: CustomMessage): void {
12189
13219
  this.#queueHiddenNextTurnMessage(message, true, "turn");
12190
13220
  }
@@ -12220,7 +13250,16 @@ export class AgentSession {
12220
13250
  }
12221
13251
  this.#scheduledHiddenNextTurnGeneration = generation;
12222
13252
  this.#schedulePostPromptTask(
12223
- async () => {
13253
+ async signal => {
13254
+ if (signal.aborted || this.#isDisposed || this.#sessionAdmissionClosing) return;
13255
+ if (this.#startupTurnBarrier) {
13256
+ try {
13257
+ await this.#awaitStartupTurnBarrier(signal);
13258
+ } catch {
13259
+ return;
13260
+ }
13261
+ }
13262
+ if (signal.aborted || this.#isDisposed || this.#sessionAdmissionClosing) return;
12224
13263
  if (this.#scheduledHiddenNextTurnGeneration === generation) {
12225
13264
  this.#scheduledHiddenNextTurnGeneration = undefined;
12226
13265
  }
@@ -12249,8 +13288,12 @@ export class AgentSession {
12249
13288
  return;
12250
13289
  }
12251
13290
  try {
12252
- await this.#promptQueuedHiddenNextTurnMessages();
13291
+ await this.#promptQueuedHiddenNextTurnMessages(signal);
12253
13292
  } catch {
13293
+ if (this.#isDisposed || this.#sessionAdmissionClosing || this.#disposeAbortController.signal.aborted) {
13294
+ this.#pendingNextTurnMessages = [];
13295
+ return;
13296
+ }
12254
13297
  // Leave the hidden next-turn messages queued for the next explicit prompt.
12255
13298
  }
12256
13299
  },
@@ -12265,7 +13308,7 @@ export class AgentSession {
12265
13308
  );
12266
13309
  }
12267
13310
 
12268
- async #promptQueuedHiddenNextTurnMessages(): Promise<void> {
13311
+ async #promptQueuedHiddenNextTurnMessages(signal?: AbortSignal): Promise<void> {
12269
13312
  if (this.#pendingNextTurnMessages.length === 0) {
12270
13313
  return;
12271
13314
  }
@@ -12295,6 +13338,14 @@ export class AgentSession {
12295
13338
  if (reclassified.length === 0) {
12296
13339
  return;
12297
13340
  }
13341
+ if (this.#isDisposed || this.#sessionAdmissionClosing || this.#disposeAbortController.signal.aborted) {
13342
+ this.#settleDeliveredOwnedRegistrations(reclassified.map(entry => entry.message));
13343
+ return;
13344
+ }
13345
+ if (signal?.aborted) {
13346
+ this.#pendingNextTurnMessages = [...reclassified, ...this.#pendingNextTurnMessages];
13347
+ return;
13348
+ }
12298
13349
  // Allocate a fresh root-turn lineage when the drained batch contains a
12299
13350
  // fresh owned envelope OR an EXTERNAL hidden trigger: an external
12300
13351
  // nextTurn message has no owned envelope, so without this it would
@@ -12316,11 +13367,24 @@ export class AgentSession {
12316
13367
  const textContent = this.#getCustomMessageTextContent(message);
12317
13368
  await this.#syncSkillPromptActiveStateSafely(message, true);
12318
13369
  try {
13370
+ if (this.#isDisposed || this.#sessionAdmissionClosing || this.#disposeAbortController.signal.aborted) {
13371
+ this.#settleDeliveredOwnedRegistrations(reclassified.map(entry => entry.message));
13372
+ return;
13373
+ }
13374
+ if (signal?.aborted) {
13375
+ this.#pendingNextTurnMessages = [...reclassified, ...this.#pendingNextTurnMessages];
13376
+ return;
13377
+ }
12319
13378
  await this.#promptWithMessage(message, textContent, {
12320
13379
  prependMessages,
12321
13380
  skipPostPromptRecoveryWait: true,
13381
+ preflightSignal: signal,
12322
13382
  });
12323
13383
  } catch (error) {
13384
+ if (this.#isDisposed || this.#sessionAdmissionClosing || this.#disposeAbortController.signal.aborted) {
13385
+ this.#settleDeliveredOwnedRegistrations(reclassified.map(entry => entry.message));
13386
+ return;
13387
+ }
12324
13388
  // Requeue only the SURVIVING reclassified entries: a completion
12325
13389
  // denied by scope:"owned" was settled (dropped) above and must
12326
13390
  // never reach a later prompt — once its scope and now-unoccupied
@@ -12347,19 +13411,55 @@ export class AgentSession {
12347
13411
  // another session's manager (which lacks the same local job id) would
12348
13412
  // see job === undefined and remove a still-live registration (review
12349
13413
  // thread P1).
12350
- const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
12351
13414
  for (const message of messages) {
12352
13415
  const details = (message as { details?: { ownedCompletions?: OwnedCompletionEnvelope[] } }).details;
12353
- for (const envelope of details?.ownedCompletions ?? []) {
12354
- const job = manager?.getJob(envelope.registration.jobId);
12355
- const status = job?.generation === envelope.registration.jobGeneration ? job?.status : undefined;
12356
- // Evicted jobs have no live record (job === undefined); terminal
12357
- // statuses settle the registration.
12358
- if (job === undefined || status === "completed" || status === "cancelled" || status === "failed") {
12359
- unregisterOwnedRegistration(envelope.registration);
13416
+ for (const envelope of details?.ownedCompletions ?? []) this.#settleOwnedCompletionEnvelope(envelope);
13417
+ }
13418
+ }
13419
+
13420
+ #settleOwnedCompletionEnvelope(envelope: OwnedCompletionEnvelope): void {
13421
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
13422
+ const job = manager?.getJob(envelope.registration.jobId);
13423
+ const status = job?.generation === envelope.registration.jobGeneration ? job?.status : undefined;
13424
+ // Evicted jobs have no live record (job === undefined); terminal statuses
13425
+ // settle the registration.
13426
+ if (job === undefined || status === "completed" || status === "cancelled" || status === "failed") {
13427
+ unregisterOwnedRegistration(envelope.registration);
13428
+ }
13429
+ }
13430
+
13431
+ /**
13432
+ * Drop every queued yield entry, releasing its retained delivery claim and
13433
+ * settling terminal owned-completion envelopes so neither outlives this
13434
+ * session's disposal. Claims resolve to the manager that actually retained
13435
+ * them: owned entries via their registration endpoint (subagents inherit a
13436
+ * parent's manager, and the process-global instance may belong to a
13437
+ * different concurrent session), ordinary entries only on the session-OWNED
13438
+ * manager. Disposal runs this twice: before the awaited session_shutdown
13439
+ * emit, and again after producer cancellation — a completion enqueued
13440
+ * inside that window would otherwise survive disposal, its claim retained
13441
+ * on a shared manager and interfering with later delivery teardown (Codex
13442
+ * review P2 on #5088).
13443
+ */
13444
+ #drainTerminalOwnedYieldEntries(): void {
13445
+ this.yieldQueue.clear((kind, entries) => {
13446
+ if (kind !== "async-result") return;
13447
+ const ownedManager = this.#ownedAsyncJobManager;
13448
+ for (const entry of entries) {
13449
+ if (typeof entry !== "object" || entry === null) continue;
13450
+ const record = entry as { generation?: unknown; ownedCompletion?: unknown };
13451
+ if (typeof record.generation === "string" && record.generation !== "") {
13452
+ const envelope = isOwnedCompletionEnvelope(record.ownedCompletion) ? record.ownedCompletion : undefined;
13453
+ const endpointId = envelope?.registration.endpointId;
13454
+ const manager =
13455
+ (endpointId !== undefined ? AsyncJobManager.forEndpoint(endpointId) : undefined) ?? ownedManager;
13456
+ manager?.releaseDeliveryClaim(record.generation);
13457
+ }
13458
+ if (isOwnedCompletionEnvelope(record.ownedCompletion)) {
13459
+ this.#settleOwnedCompletionEnvelope(record.ownedCompletion);
12360
13460
  }
12361
13461
  }
12362
- }
13462
+ });
12363
13463
  }
12364
13464
 
12365
13465
  #getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
@@ -12403,6 +13503,7 @@ export class AgentSession {
12403
13503
  triggerTurn?: boolean;
12404
13504
  deliverAs?: "steer" | "followUp" | "nextTurn";
12405
13505
  followUpQueuePolicy?: "respect-mode" | "sequential";
13506
+ steerQueuePolicy?: "respect-mode" | "sequential";
12406
13507
  /** INTERNAL trusted option: origin of a hidden next-turn message —
12407
13508
  * "turn" (produced by the current turn's continuation machinery,
12408
13509
  * purged by a terminal abort) or "external" (background producers,
@@ -12428,6 +13529,53 @@ export class AgentSession {
12428
13529
  const epoch = preclaimedUserIntentEpoch ?? this.#claimDeepInterviewUserIntent();
12429
13530
  this.#deepInterviewGenuineUserMessageEpochs.set(appMessage, epoch);
12430
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
+ }
12431
13579
  if (this.isStreaming) {
12432
13580
  // A handoff transition owns the session; a background/custom trigger (cron,
12433
13581
  // monitor, skill) must not steer/follow-up/queue against the outgoing turn
@@ -12438,14 +13586,15 @@ export class AgentSession {
12438
13586
  return;
12439
13587
  }
12440
13588
 
12441
- if (options?.deliverAs === "followUp") {
12442
- this.agent.followUp(
12443
- appMessage,
12444
- options.followUpQueuePolicy === "sequential" ? { forceOneAtATime: true } : undefined,
12445
- );
12446
- } else {
12447
- this.agent.steer(appMessage);
12448
- }
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();
12449
13598
  return;
12450
13599
  }
12451
13600
 
@@ -12966,8 +14115,37 @@ export class AgentSession {
12966
14115
  const fromIndex = queue.findIndex(entry => entry.sequence === sequence);
12967
14116
  if (fromIndex === -1) return false;
12968
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;
12969
14147
  const agentMoved =
12970
- 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);
12971
14149
  if (!agentMoved) return false;
12972
14150
  return this.#moveQueuedDisplayEntry(queue, fromIndex, toIndex);
12973
14151
  }
@@ -13327,6 +14505,7 @@ export class AgentSession {
13327
14505
  | "internal";
13328
14506
  silent?: boolean;
13329
14507
  sdkRunToken?: string;
14508
+ preserveCompaction?: boolean;
13330
14509
  }): void {
13331
14510
  const abortGoalState = this.getGoalModeState();
13332
14511
  this.#suppressNextGoalReminderAfterAbortGoalId =
@@ -13343,7 +14522,8 @@ export class AgentSession {
13343
14522
  this.#promptPreflightAbortController.abort();
13344
14523
  this.#promptPreflightAbortController = new AbortController();
13345
14524
  this.#scheduledHiddenNextTurnGeneration = undefined;
13346
- this.abortCompaction();
14525
+ if (options?.preserveCompaction) this.#autoCompactionAbortController?.abort();
14526
+ else this.abortCompaction();
13347
14527
  this.abortHandoff();
13348
14528
  this.abortBash();
13349
14529
  this.abortEval();
@@ -13399,6 +14579,7 @@ export class AgentSession {
13399
14579
  | "tool_abort"
13400
14580
  | "internal";
13401
14581
  silent?: boolean;
14582
+ preserveCompaction?: boolean;
13402
14583
  }): Promise<AbortOutcome> {
13403
14584
  // Advance the admission epoch SYNCHRONOUSLY, before any await and before the
13404
14585
  // shared-unwind branch. A second abort that piggybacks on the first unwind
@@ -13415,12 +14596,15 @@ export class AgentSession {
13415
14596
  this.#promptPreflightCancellationGeneration++;
13416
14597
  this.#promptPreflightAbortController.abort();
13417
14598
  this.#promptPreflightAbortController = new AbortController();
14599
+ this.#drainTerminalOwnedYieldEntries();
14600
+ if (options?.preserveCompaction !== true) this.abortCompaction();
14601
+ const overlappingPostPromptDrain = this.#cancelPostPromptTasks();
13418
14602
  // Abort visibility is per-request: a later real abort must not inherit an
13419
14603
  // earlier silent abort's suppression and swallow the user-visible notice.
13420
14604
  if (options?.silent !== true) this.#silentAbortPending = false;
13421
14605
  // Capture the unwind: the field clears once the first abort settles, and
13422
14606
  // the awaits below must keep watching THIS unwind, not a successor's.
13423
- const sharedUnwind = this.#abortUnwind;
14607
+ const sharedUnwind = Promise.all([this.#abortUnwind, overlappingPostPromptDrain]).then(() => {});
13424
14608
  if (options?.timeoutMs !== undefined) {
13425
14609
  const timeoutMs = Math.max(0, options.timeoutMs);
13426
14610
  const deadline = Date.now() + timeoutMs;
@@ -13451,6 +14635,17 @@ export class AgentSession {
13451
14635
  this.#abortUnwind = unwind.promise;
13452
14636
  try {
13453
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
+ }
14648
+ this.#drainTerminalOwnedYieldEntries();
13454
14649
  const postPromptDrain = this.#cancelPostPromptTasks();
13455
14650
  const managedLogicalRunId =
13456
14651
  this.#defaultFallbackChain().chain.entries.length > 1 ? this.agent.currentManagedLogicalRunId : undefined;
@@ -13478,15 +14673,18 @@ export class AgentSession {
13478
14673
  this.agent.requestRunTerminal(managedLogicalRunId, { stopReason: "cancelled" });
13479
14674
  this.#flushPendingBackgroundExchanges();
13480
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.
13481
14679
  if (
13482
14680
  !this.#cancelAndSubmitInProgress &&
13483
14681
  (options?.cause ?? "internal") === "user_interrupt" &&
13484
- this.agent.hasQueuedSteering()
14682
+ this.agent.hasQueuedMessages()
13485
14683
  ) {
13486
14684
  this.#scheduleAgentContinue({
13487
14685
  delayMs: 1,
13488
14686
  generation: this.#promptGeneration,
13489
- shouldContinue: () => this.agent.hasQueuedSteering(),
14687
+ shouldContinue: () => this.agent.hasQueuedMessages(),
13490
14688
  rescheduleOnBusy: true,
13491
14689
  continueQueuedOnly: true,
13492
14690
  });
@@ -13499,6 +14697,10 @@ export class AgentSession {
13499
14697
  if (this.#toolChoiceQueue.hasInFlight) this.#toolChoiceQueue.reject("aborted");
13500
14698
  }
13501
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 = [];
13502
14704
  this.#abortUnwind = undefined;
13503
14705
  this.#abortForceRecoveryStarted = false;
13504
14706
  unwind.resolve();
@@ -13518,6 +14720,7 @@ export class AgentSession {
13518
14720
  | "tool_abort"
13519
14721
  | "internal";
13520
14722
  silent?: boolean;
14723
+ preserveCompaction?: boolean;
13521
14724
  }): Promise<void> {
13522
14725
  const outcome = await this.#abortWithOutcome(options);
13523
14726
  if (outcome.kind === "error") throw outcome.cause;
@@ -13638,10 +14841,6 @@ export class AgentSession {
13638
14841
  }
13639
14842
  > {
13640
14843
  let registeredScope: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string } | undefined;
13641
- // The purge snapshot is consumed per-admission inside the terminal
13642
- // block; the rearm below (reachable only when a scope was registered,
13643
- // i.e. only after the purge ran) reads the same value.
13644
- let abortSteeringSnapshot: number | undefined;
13645
14844
  if (options.terminal) {
13646
14845
  // The terminal scope must be registered for the ABORTED turn's
13647
14846
  // lineage, never a successor's. A new root turn advances the attempt
@@ -13696,10 +14895,10 @@ export class AgentSession {
13696
14895
  };
13697
14896
  }
13698
14897
  // Terminal abort blocks STEERING continuations of the aborted turn:
13699
- // purge steering queued just before the abort won (the loop can
13700
- // exit on the abort signal without polling it) so it cannot alter
13701
- // the next user turn (review thread P2). The follow-up queue is
13702
- // 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.
13703
14902
  const turnSnapshotKey = `${lineageIdHash}:${this.#promptGeneration}`;
13704
14903
  const queuedTurnSnapshots = this.#terminalAbortSteeringSnapshots.get(turnSnapshotKey);
13705
14904
  const snapshotIndex =
@@ -13709,39 +14908,31 @@ export class AgentSession {
13709
14908
  const consumedSnapshot =
13710
14909
  queuedTurnSnapshots && snapshotIndex >= 0 ? queuedTurnSnapshots.splice(snapshotIndex, 1)[0] : undefined;
13711
14910
  if (consumedSnapshot) this.#terminalAbortSteeringSnapshotKeys.delete(consumedSnapshot.token);
13712
- abortSteeringSnapshot = consumedSnapshot?.seq ?? this.#steeringAdmissionSeq;
14911
+ const snapshotSeq: number = consumedSnapshot?.seq ?? this.#steeringAdmissionSeq;
13713
14912
  if (queuedTurnSnapshots?.length === 0) {
13714
14913
  this.#terminalAbortSteeringSnapshots.delete(turnSnapshotKey);
13715
14914
  }
13716
- const admittedAfterSnapshot = (message: AgentMessage): boolean =>
13717
- (this.#externalSteerAdmissionSeq.get(message) ?? 0) > (abortSteeringSnapshot ?? -1);
13718
- // Steering purge, STEERING queue only: the agent-wide removal
13719
- // would also wipe the follow-up queue, which owned-completion
13720
- // resumes must still deliver (review thread P1).
13721
- const steeringBeforePurge = this.agent.snapshotSteering();
13722
- const purgedSteering = steeringBeforePurge.filter(
13723
- message => !(this.#externalSteerMessages.has(message) && admittedAfterSnapshot(message)),
13724
- );
13725
- this.agent.removeQueuedMessages(
13726
- message => !(this.#externalSteerMessages.has(message) && admittedAfterSnapshot(message)),
13727
- "steering",
13728
- );
13729
- // Purged steering leaves without consumption: terminalize its accepted
13730
- // SDK submissions boundedly (#4668 review P1).
13731
- this.#fireQueuedRemovalHooks(purgedSteering);
13732
- // Keep the display list aligned with the ACTUAL purge decisions:
13733
- // preserved post-snapshot external steers stay visible and
13734
- // purged internal steers disappear — a stale entry would let the
13735
- // positional editing APIs remove a different preserved steer
13736
- // (review thread P2).
13737
- const steeringAfterPurge = new Set(this.agent.snapshotSteering());
13738
- const keptSteeringDisplays: QueuedDisplayEntry[] = [];
13739
- for (let displayIndex = 0; displayIndex < steeringBeforePurge.length; displayIndex++) {
13740
- if (steeringAfterPurge.has(steeringBeforePurge[displayIndex]!)) {
13741
- keptSteeringDisplays.push(this.#steeringMessages[displayIndex]!);
13742
- }
13743
- }
13744
- 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 };
13745
14936
 
13746
14937
  // Remove ORDINARY follow-ups queued for the aborted turn: the
13747
14938
  // aborted loop exits without polling them, so a later unrelated
@@ -13796,6 +14987,7 @@ export class AgentSession {
13796
14987
  }
13797
14988
  const aborted = this.#runCancellationDomains.abort(handle);
13798
14989
  if (!aborted.ok) {
14990
+ this.#disownedSteeringDisposition = undefined;
13799
14991
  if (aborted.reason === "quarantined") {
13800
14992
  return {
13801
14993
  ...(await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: 0 })),
@@ -13812,6 +15004,9 @@ export class AgentSession {
13812
15004
  if (handle === this.agent.activeResourceRunId) this.agent.abort();
13813
15005
  const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs });
13814
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;
13815
15010
  // Rearm surviving owned-completion follow-ups once the abort has
13816
15011
  // settled: the aborted loop exits before polling the follow-up queue,
13817
15012
  // so preserving the envelopes alone leaves them stranded until an
@@ -13851,19 +15046,10 @@ export class AgentSession {
13851
15046
  message =>
13852
15047
  ownedCompletionResumeAction(message as never) === "fresh" || this.#externalFollowUps.has(message),
13853
15048
  );
13854
- // A preserved post-snapshot client steer is also an independent
13855
- // root-turn request: the abort exits the loop and never polls the
13856
- // steering queue, so without a rearm the accepted steer stays
13857
- // stranded until unrelated activity (review thread P1).
13858
- const hasPreservedSteering = (): boolean =>
13859
- this.agent
13860
- .snapshotQueues()
13861
- .steering.some(
13862
- message =>
13863
- this.#externalSteerMessages.has(message) &&
13864
- (this.#externalSteerAdmissionSeq.get(message) ?? 0) > (abortSteeringSnapshot ?? -1),
13865
- );
13866
- 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()) {
13867
15053
  // ANY preserved follow-up or steer — an owned-completion resume,
13868
15054
  // an independently requested external follow-up, or a
13869
15055
  // post-snapshot client steer — needs a fresh lineage before its
@@ -13878,7 +15064,7 @@ export class AgentSession {
13878
15064
  this.#scheduleAgentContinue({
13879
15065
  delayMs: 1,
13880
15066
  generation: this.#promptGeneration,
13881
- shouldContinue: () => hasPreservedFollowUp() || hasPreservedSteering(),
15067
+ shouldContinue: () => hasPreservedFollowUp(),
13882
15068
  // The rearmed continuation must consume the queued steer inside
13883
15069
  // the run acceptance (continueQueuedMessages) rather than
13884
15070
  // dequeue it later via getSteeringMessages: a terminal abort
@@ -13966,9 +15152,14 @@ export class AgentSession {
13966
15152
  ];
13967
15153
  };
13968
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" };
13969
15159
  const outcome = this.#cancelAndSubmitAbortOutcomeProviderForTests
13970
15160
  ? await this.#cancelAndSubmitAbortOutcomeProviderForTests()
13971
15161
  : await this.#abortWithOutcome({ cause: "user_interrupt", timeoutMs: 5_000 });
15162
+ this.#disownedSteeringDisposition = undefined;
13972
15163
  if (outcome.kind !== "settled") {
13973
15164
  restore();
13974
15165
  if (outcome.kind === "error") {
@@ -14010,18 +15201,29 @@ export class AgentSession {
14010
15201
  followUp: [...heldFollowUp, ...current.followUp],
14011
15202
  });
14012
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;
14013
15210
  this.agent.restoreQueues({
14014
15211
  steering: [...queuedDuringWindow.steering],
14015
15212
  followUp: selected
14016
15213
  ? [...queuedDuringWindow.followUp]
14017
- : [...queueSnapshot.steering, ...queueSnapshot.followUp, ...queuedDuringWindow.followUp],
15214
+ : [...queueSnapshot.followUp, ...queuedDuringWindow.followUp],
14018
15215
  });
14019
15216
  this.#steeringMessages = steeringDisplaysDuringWindow;
14020
- this.#followUpMessages = [
14021
- ...steeringDisplaySnapshot,
14022
- ...followUpDisplaySnapshot,
14023
- ...followUpDisplaysDuringWindow,
14024
- ];
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];
14025
15227
  const message = selectedMessage ?? {
14026
15228
  role: "user" as const,
14027
15229
  content: [{ type: "text" as const, text }],
@@ -14050,8 +15252,19 @@ export class AgentSession {
14050
15252
  admissionLease: admission,
14051
15253
  resetRetryReplaySafety: true,
14052
15254
  sdkRunToken: selectedSdkRunToken,
15255
+ skipInitialSteeringPoll: heldSteering.length > 0,
14053
15256
  onRunAccepted: () => {
14054
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
+ }
14055
15268
  if (selected) this.#fireQueuedPromotionHooks([message], { startsOwnRun: true });
14056
15269
  if (selected) {
14057
15270
  this.#steeringMessages = this.#steeringMessages.filter(entry => entry !== selected.display);
@@ -14080,6 +15293,10 @@ export class AgentSession {
14080
15293
  } finally {
14081
15294
  this.#cancelAndSubmitInProgress = false;
14082
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);
14083
15300
  }
14084
15301
  }
14085
15302
 
@@ -15239,6 +16456,10 @@ export class AgentSession {
15239
16456
  onBeforeMutation?: () => void;
15240
16457
  /** Run inside the selection admission after the durable mutation. */
15241
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>;
15242
16463
  },
15243
16464
  ): Promise<DefaultModelSelectionResult> {
15244
16465
  // Reserve the causal selection fence synchronously, before credential
@@ -15254,6 +16475,7 @@ export class AgentSession {
15254
16475
  throw new Error("Default model selection cannot inherit a thinking level");
15255
16476
  }
15256
16477
  const expectedSessionId = this.sessionId;
16478
+ const selectionTransaction = Symbol("default-model-selection");
15257
16479
  const priorSelectionFence = this.#selectionFenceTail;
15258
16480
  const selectionFence = Promise.withResolvers<void>();
15259
16481
  this.#selectionFenceGeneration += 1;
@@ -15289,9 +16511,11 @@ export class AgentSession {
15289
16511
  },
15290
16512
  undefined,
15291
16513
  undefined,
15292
- { allowDuringClosing: true },
16514
+ { allowDuringClosing: true, selectionTransaction },
15293
16515
  );
16516
+ this.#selectionAwaitingMutationTransaction = selectionTransaction;
15294
16517
  await this.waitForIdle(selectionFenceGeneration);
16518
+ await options?.onBeforeMutationAdmissionForTests?.();
15295
16519
  return await this.#withSessionAdmission(
15296
16520
  "selection",
15297
16521
  async () => {
@@ -15373,9 +16597,16 @@ export class AgentSession {
15373
16597
  },
15374
16598
  undefined,
15375
16599
  undefined,
15376
- { allowDuringClosing: true },
16600
+ {
16601
+ allowDuringClosing: true,
16602
+ closedSelectionTransaction: selectionTransaction,
16603
+ selectionTransaction,
16604
+ onAfterReadyForTests: options?.onAfterMutationAdmissionReadyForTests,
16605
+ },
15377
16606
  );
15378
16607
  } finally {
16608
+ if (this.#selectionAwaitingMutationTransaction === selectionTransaction)
16609
+ this.#selectionAwaitingMutationTransaction = undefined;
15379
16610
  selectionFence.resolve();
15380
16611
  this.#pendingSelectionFences -= 1;
15381
16612
  if (this.#oldestPendingSelectionFenceGeneration === selectionFenceGeneration) {
@@ -16041,13 +17272,13 @@ export class AgentSession {
16041
17272
  }
16042
17273
 
16043
17274
  /**
16044
- * Set interrupt mode.
17275
+ * Set the tool interrupt policy (whether a steer aborts in-flight tools).
16045
17276
  * Saves to settings.
16046
17277
  */
16047
- setInterruptMode(mode: "immediate" | "wait"): void {
17278
+ setToolInterruptPolicy(policy: "abort_tools" | "finish_tools"): void {
16048
17279
  this.#assertDurableSettingsWritable();
16049
- this.agent.setInterruptMode(mode);
16050
- this.settings.set("interruptMode", mode);
17280
+ this.agent.setToolInterruptPolicy(policy);
17281
+ this.settings.set("toolInterruptPolicy", policy);
16051
17282
  }
16052
17283
 
16053
17284
  // =========================================================================
@@ -16434,22 +17665,35 @@ export class AgentSession {
16434
17665
  * @param options Optional callbacks for completion/error handling
16435
17666
  */
16436
17667
  async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
17668
+ this.#assertSessionAdmissionOpen();
16437
17669
  // Serialize with every other session-identity transition via the shared lease
16438
17670
  // (bidirectional mutual exclusion with handoff/new/switch/branch/clear/fork/
16439
17671
  // navigateTree). Released in the outer finally below.
16440
17672
  this.#beginSessionTransition("compact");
17673
+ const completion = Promise.withResolvers<void>();
17674
+ this.#compactionCompletion = completion.promise;
16441
17675
  try {
16442
17676
  if (this.#compactionAbortController) {
16443
17677
  throw new Error("Compaction already in progress");
16444
17678
  }
16445
- this.#disconnectFromAgent();
16446
- await this.abort();
16447
17679
  const compactionAbortController = new AbortController();
16448
17680
  this.#compactionAbortController = compactionAbortController;
16449
- // Take this invocation's state snapshot for the summarizer context.
16450
- const compactionStateSnapshot = await this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true });
16451
-
17681
+ this.#disconnectFromAgent();
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();
16452
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
+ });
16453
17697
  if (!this.model) {
16454
17698
  throw new Error("No model selected");
16455
17699
  }
@@ -16586,9 +17830,11 @@ export class AgentSession {
16586
17830
  if (this.#compactionAbortController === compactionAbortController) {
16587
17831
  this.#compactionAbortController = undefined;
16588
17832
  }
16589
- this.#reconnectToAgent();
17833
+ if (!this.#isDisposed && !this.#sessionAdmissionClosing) this.#reconnectToAgent();
16590
17834
  }
16591
17835
  } finally {
17836
+ completion.resolve();
17837
+ if (this.#compactionCompletion === completion.promise) this.#compactionCompletion = undefined;
16592
17838
  this.#endSessionTransition();
16593
17839
  }
16594
17840
  }
@@ -16650,9 +17896,21 @@ export class AgentSession {
16650
17896
  }
16651
17897
  }
16652
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
+
16653
17911
  /** Trigger idle compaction through the auto-compaction flow (with UI events). */
16654
17912
  async runIdleCompaction(): Promise<void> {
16655
- if (this.isStreaming || this.isCompacting) return;
17913
+ if (this.#isDisposed || this.#sessionAdmissionClosing || this.isStreaming || this.isCompacting) return;
16656
17914
  // Do not start idle compaction while a handoff transition owns the session.
16657
17915
  if (this.isGeneratingHandoff || this.#handoffTransitionActive) return;
16658
17916
  await this.#runAutoCompaction("idle", false, true);
@@ -18500,7 +19758,27 @@ export class AgentSession {
18500
19758
  /**
18501
19759
  * Internal: Run auto-compaction with events.
18502
19760
  */
18503
- 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(
18504
19782
  reason: "overflow" | "threshold" | "idle",
18505
19783
  willRetry: boolean,
18506
19784
  deferred = false,
@@ -19493,11 +20771,15 @@ export class AgentSession {
19493
20771
  }
19494
20772
 
19495
20773
  async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
20774
+ const attemptAbortEpoch = this.#abortAdmissionEpoch;
20775
+ const attemptCancelled = () =>
20776
+ this.#isDisposed || this.#sessionAdmissionClosing || this.#abortAdmissionEpoch !== attemptAbortEpoch;
19496
20777
  const activePromptHandle = this.activePromptHandle;
19497
20778
  const cancellationSignal = activePromptHandle
19498
20779
  ? this.#runCancellationDomains.lookup(activePromptHandle)?.signal
19499
20780
  : undefined;
19500
- if (cancellationSignal?.aborted) return { type: "terminal", terminal: { stopReason: "cancelled" } };
20781
+ if (attemptCancelled() || cancellationSignal?.aborted)
20782
+ return { type: "terminal", terminal: { stopReason: "cancelled" } };
19501
20783
  if (outcome.type === "run_terminal") {
19502
20784
  this.#defaultFallbackChain().resetAttemptBudget();
19503
20785
  return { type: "terminal", terminal: { stopReason: outcome.reason } };
@@ -19533,7 +20815,6 @@ export class AgentSession {
19533
20815
  const controllerStateBeforeFailure = controller.snapshotRuntimeState();
19534
20816
  const advanced = controller.recordEscapedArgumentsFailure(errorMessage, false);
19535
20817
  this.#escapedNonAsciiManagedRetries = 0;
19536
- const abortEpoch = this.#abortAdmissionEpoch;
19537
20818
  if (advanced) {
19538
20819
  const transitionGeneration = ++this.#fallbackTransitionGeneration;
19539
20820
  return {
@@ -19554,7 +20835,9 @@ export class AgentSession {
19554
20835
  !ownership.isCurrent() ||
19555
20836
  ownership.lease.signal.aborted ||
19556
20837
  cancellationSignal?.aborted ||
19557
- this.#abortAdmissionEpoch !== abortEpoch ||
20838
+ this.#isDisposed ||
20839
+ this.#sessionAdmissionClosing ||
20840
+ this.#abortAdmissionEpoch !== attemptAbortEpoch ||
19558
20841
  this.#fallbackTransitionGeneration !== transitionGeneration;
19559
20842
  if (continuationCancelled) {
19560
20843
  await restoreOwnedTransition();
@@ -19579,7 +20862,7 @@ export class AgentSession {
19579
20862
  "escaped_non_ascii",
19580
20863
  1,
19581
20864
  cancellationSignal,
19582
- abortEpoch,
20865
+ attemptAbortEpoch,
19583
20866
  controllerStateBeforeFailure,
19584
20867
  previousModel,
19585
20868
  previousThinkingLevel,
@@ -19588,7 +20871,9 @@ export class AgentSession {
19588
20871
  );
19589
20872
  if (!switched) {
19590
20873
  if (
19591
- this.#abortAdmissionEpoch !== abortEpoch ||
20874
+ this.#isDisposed ||
20875
+ this.#sessionAdmissionClosing ||
20876
+ this.#abortAdmissionEpoch !== attemptAbortEpoch ||
19592
20877
  cancellationSignal?.aborted ||
19593
20878
  this.#fallbackTransitionGeneration !== transitionGeneration
19594
20879
  ) {
@@ -19608,6 +20893,15 @@ export class AgentSession {
19608
20893
  });
19609
20894
  return;
19610
20895
  }
20896
+ if (
20897
+ attemptCancelled() ||
20898
+ !ownership.isCurrent() ||
20899
+ ownership.lease.signal.aborted ||
20900
+ this.#fallbackTransitionGeneration !== transitionGeneration
20901
+ ) {
20902
+ await restoreOwnedTransition();
20903
+ return;
20904
+ }
19611
20905
  const continuation = this.agent.continue({
19612
20906
  ...this.#managedFallbackPromptOptions(),
19613
20907
  transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage(),
@@ -19617,7 +20911,9 @@ export class AgentSession {
19617
20911
  });
19618
20912
  if (
19619
20913
  cancellationSignal?.aborted ||
19620
- this.#abortAdmissionEpoch !== abortEpoch ||
20914
+ this.#isDisposed ||
20915
+ this.#sessionAdmissionClosing ||
20916
+ this.#abortAdmissionEpoch !== attemptAbortEpoch ||
19621
20917
  this.#fallbackTransitionGeneration !== transitionGeneration
19622
20918
  ) {
19623
20919
  await restoreOwnedTransition();
@@ -19636,7 +20932,7 @@ export class AgentSession {
19636
20932
  };
19637
20933
  }
19638
20934
  controller.advance();
19639
- if (this.#abortAdmissionEpoch !== abortEpoch || cancellationSignal?.aborted)
20935
+ if (attemptCancelled() || cancellationSignal?.aborted)
19640
20936
  return { type: "terminal", terminal: { stopReason: "cancelled" } };
19641
20937
  this.#defaultFallbackExhaustedLastTurn = true;
19642
20938
  return this.#managedFallbackExhaustionDecision(
@@ -19648,7 +20944,7 @@ export class AgentSession {
19648
20944
  return {
19649
20945
  type: "retry",
19650
20946
  continuation: async ownership => {
19651
- if (!ownership.isCurrent() || ownership.lease.signal.aborted) return;
20947
+ if (attemptCancelled() || !ownership.isCurrent() || ownership.lease.signal.aborted) return;
19652
20948
  await this.agent.continue({
19653
20949
  ...this.#managedFallbackPromptOptions(),
19654
20950
  transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage(),
@@ -19667,7 +20963,7 @@ export class AgentSession {
19667
20963
  return {
19668
20964
  type: "maintenance",
19669
20965
  continuation: async ownership => {
19670
- if (!ownership.isCurrent() || ownership.lease.signal.aborted) return;
20966
+ if (attemptCancelled() || !ownership.isCurrent() || ownership.lease.signal.aborted) return;
19671
20967
  const resourceRunId = String(ownership.logicalRunId);
19672
20968
  const previousLease = this.#postPromptLeases.get(resourceRunId);
19673
20969
  this.#postPromptLeases.set(resourceRunId, ownership.lease);
@@ -19686,7 +20982,13 @@ export class AgentSession {
19686
20982
  resourceRunId,
19687
20983
  ownership.lease.signal,
19688
20984
  );
19689
- if (terminalized || successorScheduled || !ownership.isCurrent() || ownership.lease.signal.aborted)
20985
+ if (
20986
+ terminalized ||
20987
+ successorScheduled ||
20988
+ attemptCancelled() ||
20989
+ !ownership.isCurrent() ||
20990
+ ownership.lease.signal.aborted
20991
+ )
19690
20992
  return;
19691
20993
  this.agent.requestRunTerminal(ownership.handle.logicalRunId, {
19692
20994
  stopReason: "error",
@@ -20129,6 +21431,9 @@ export class AgentSession {
20129
21431
  scope?: AttemptScope,
20130
21432
  scopeWasClean = this.#isRetryScopeClean(scope),
20131
21433
  ): Promise<boolean | ManagedAttemptDecision> {
21434
+ const retryAbortEpoch = this.#abortAdmissionEpoch;
21435
+ const retryCancelled = () =>
21436
+ this.#isDisposed || this.#sessionAdmissionClosing || this.#abortAdmissionEpoch !== retryAbortEpoch;
20132
21437
  const controller = this.#defaultFallbackChain();
20133
21438
  const managedFallback = controller.chain.entries.length > 1;
20134
21439
  const retrySettings = this.settings.getGroup("retry");
@@ -20140,6 +21445,9 @@ export class AgentSession {
20140
21445
  const classification = this.#classifyErrorForRetry(message);
20141
21446
  const localSnapshot = classification === "local_snapshot";
20142
21447
  const localBufferOverflow = classification === "local_buffer_overflow";
21448
+ if (retryCancelled()) {
21449
+ return managedOutcome ? { type: "terminal", terminal: { stopReason: "cancelled" } } : false;
21450
+ }
20143
21451
  // A local machinery failure must never stay charged against the provider
20144
21452
  // fallback budget, no matter which local exit follows (disabled retry,
20145
21453
  // visible-content surface, bounded retry, exhaustion, or the immediate
@@ -20402,7 +21710,7 @@ export class AgentSession {
20402
21710
  ownership?.domain.signal ??
20403
21711
  (activePromptHandle ? this.#runCancellationDomains.lookup(activePromptHandle)?.signal : undefined);
20404
21712
  if (ownership && (!ownership.isCurrent() || cancellationSignal?.aborted)) return;
20405
- const abortEpoch = this.#abortAdmissionEpoch;
21713
+ if (retryCancelled()) return;
20406
21714
  let quotaPoolExhausted = false;
20407
21715
  if (managedFallback && !credentialRotated && !providerRetryCeilingReached) {
20408
21716
  const mark = await this.#markFailedCredential(trigger);
@@ -20436,7 +21744,7 @@ export class AgentSession {
20436
21744
  trigger.class,
20437
21745
  attemptsUsed,
20438
21746
  cancellationSignal,
20439
- abortEpoch,
21747
+ retryAbortEpoch,
20440
21748
  controllerStateBeforeAdvance,
20441
21749
  previousModel,
20442
21750
  previousThinkingLevel,
@@ -20446,7 +21754,7 @@ export class AgentSession {
20446
21754
  }
20447
21755
  }
20448
21756
  if (ownership && (!ownership.isCurrent() || cancellationSignal?.aborted)) {
20449
- if (this.#abortAdmissionEpoch === abortEpoch) {
21757
+ if (!retryCancelled()) {
20450
21758
  await this.#restoreDefaultFallbackTransition(
20451
21759
  controller,
20452
21760
  controllerStateBeforeAdvance,
@@ -20456,7 +21764,7 @@ export class AgentSession {
20456
21764
  }
20457
21765
  return;
20458
21766
  }
20459
- if (this.#abortAdmissionEpoch !== abortEpoch) {
21767
+ if (retryCancelled()) {
20460
21768
  controller.resetForNewTurn();
20461
21769
  return;
20462
21770
  }
@@ -20585,8 +21893,9 @@ export class AgentSession {
20585
21893
 
20586
21894
  if (managedOutcome) {
20587
21895
  try {
21896
+ if (retryCancelled() || ownershipCancelled()) return;
20588
21897
  await this.#checkEstimatedContextBeforePrompt();
20589
- if (ownershipCancelled()) {
21898
+ if (retryCancelled() || ownershipCancelled()) {
20590
21899
  const attempt = this.#retryAttempt;
20591
21900
  this.#retryAttempt = 0;
20592
21901
  await this.#emitSessionEvent({
@@ -20687,6 +21996,7 @@ export class AgentSession {
20687
21996
  toolChoice?: ToolChoice;
20688
21997
  sdkRunToken?: string;
20689
21998
  fallbackManaged?: boolean;
21999
+ skipInitialSteeringPoll?: boolean;
20690
22000
  onRunAccepted?: (handle: AttemptRunHandle) => void;
20691
22001
  },
20692
22002
  predecessorAgentEndHold?: symbol,