@gajae-code/coding-agent 0.13.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (599) hide show
  1. package/CHANGELOG.md +184 -2
  2. package/README.md +2 -0
  3. package/bin/gajaessi.js +4 -0
  4. package/dist/types/async/job-manager.d.ts +55 -2
  5. package/dist/types/async/support.d.ts +14 -0
  6. package/dist/types/capability/mcp.d.ts +6 -0
  7. package/dist/types/capability/types.d.ts +6 -0
  8. package/dist/types/cli/accounts-cli.d.ts +19 -0
  9. package/dist/types/cli/args.d.ts +3 -0
  10. package/dist/types/cli/config-cli.d.ts +6 -0
  11. package/dist/types/cli/crash-cli.d.ts +10 -0
  12. package/dist/types/cli/customize-doctor.d.ts +145 -0
  13. package/dist/types/cli/daemon-cli.d.ts +3 -1
  14. package/dist/types/cli/mcp-cli.d.ts +11 -0
  15. package/dist/types/cli/notify-cli.d.ts +1 -1
  16. package/dist/types/cli/root-flags.d.ts +12 -0
  17. package/dist/types/cli/setup-cli.d.ts +3 -1
  18. package/dist/types/cli/skills-cli.d.ts +2 -1
  19. package/dist/types/cli/update-cli.d.ts +19 -1
  20. package/dist/types/cli.d.ts +6 -0
  21. package/dist/types/commands/accounts.d.ts +40 -0
  22. package/dist/types/commands/crash.d.ts +24 -0
  23. package/dist/types/commands/customize.d.ts +22 -0
  24. package/dist/types/commands/daemon.d.ts +1 -1
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/commands/setup.d.ts +6 -0
  27. package/dist/types/commands/skills.d.ts +6 -1
  28. package/dist/types/commands/update.d.ts +7 -0
  29. package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
  30. package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
  31. package/dist/types/config/config-schema-version.d.ts +10 -0
  32. package/dist/types/config/file-lock.d.ts +18 -0
  33. package/dist/types/config/model-profile-activation.d.ts +3 -1
  34. package/dist/types/config/model-registry.d.ts +9 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +70 -6
  37. package/dist/types/config/settings.d.ts +63 -0
  38. package/dist/types/config/skill-settings-defaults.d.ts +26 -0
  39. package/dist/types/config/task-delegation.d.ts +39 -0
  40. package/dist/types/config/update-channel.d.ts +10 -0
  41. package/dist/types/coordinator-mcp/server.d.ts +5 -1
  42. package/dist/types/crash/index-store.d.ts +87 -0
  43. package/dist/types/crash/nudge.d.ts +50 -0
  44. package/dist/types/crash/record-loader.d.ts +17 -0
  45. package/dist/types/crash/report.d.ts +134 -0
  46. package/dist/types/crash/sanitize.d.ts +25 -0
  47. package/dist/types/cursor.d.ts +13 -2
  48. package/dist/types/customization/import.d.ts +37 -0
  49. package/dist/types/customization/index.d.ts +4 -0
  50. package/dist/types/customization/inventory.d.ts +25 -0
  51. package/dist/types/customization/mutations.d.ts +32 -0
  52. package/dist/types/customization/types.d.ts +115 -0
  53. package/dist/types/daemon/control-types.d.ts +8 -1
  54. package/dist/types/daemon/operator-contract.d.ts +4 -4
  55. package/dist/types/discovery/claude.d.ts +39 -1
  56. package/dist/types/discovery/codex.d.ts +39 -1
  57. package/dist/types/discovery/index.d.ts +2 -0
  58. package/dist/types/discovery/mcp-compat.d.ts +30 -0
  59. package/dist/types/edit/renderer.d.ts +23 -0
  60. package/dist/types/extensibility/extensions/runner.d.ts +3 -0
  61. package/dist/types/extensibility/extensions/types.d.ts +21 -4
  62. package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
  63. package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
  64. package/dist/types/extensibility/hooks/loader.d.ts +19 -5
  65. package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
  66. package/dist/types/extensibility/skill-management.d.ts +107 -0
  67. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
  68. package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
  69. package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
  70. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
  71. package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
  72. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
  73. package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
  74. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
  75. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
  76. package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
  77. package/dist/types/hooks/events.d.ts +60 -0
  78. package/dist/types/hooks/normalize.d.ts +83 -0
  79. package/dist/types/hooks/skill-state.d.ts +9 -2
  80. package/dist/types/main.d.ts +1 -1
  81. package/dist/types/modes/acp/abort-scope.d.ts +13 -0
  82. package/dist/types/modes/acp/acp-agent.d.ts +7 -4
  83. package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
  84. package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
  85. package/dist/types/modes/components/customization/index.d.ts +1 -0
  86. package/dist/types/modes/components/gajae-pet-widget.d.ts +8 -30
  87. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  88. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  89. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  90. package/dist/types/modes/components/pet-capability.d.ts +11 -25
  91. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  92. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  93. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  94. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  95. package/dist/types/modes/interactive-mode.d.ts +1 -0
  96. package/dist/types/modes/types.d.ts +1 -0
  97. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  98. package/dist/types/runtime-mcp/config.d.ts +9 -0
  99. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  100. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  101. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  102. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  103. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  104. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  105. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  106. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  107. package/dist/types/runtime-mcp/types.d.ts +102 -10
  108. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  109. package/dist/types/sdk/broker/broker.d.ts +3 -55
  110. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  111. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  112. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  113. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  114. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  115. package/dist/types/sdk/bus/config.d.ts +11 -0
  116. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  117. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  118. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  119. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  120. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  121. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  122. package/dist/types/sdk/bus/index.d.ts +85 -99
  123. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  124. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  125. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  126. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  127. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  128. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  129. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  130. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  131. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  132. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  133. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  135. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  136. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  137. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  138. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  139. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  140. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  141. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  142. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  143. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  144. package/dist/types/sdk/cli/rows.d.ts +2 -11
  145. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  146. package/dist/types/sdk/client/client.d.ts +97 -1
  147. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  148. package/dist/types/sdk/host/control/index.d.ts +1 -1
  149. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  150. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  151. package/dist/types/sdk/host/host.d.ts +15 -1
  152. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  153. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  154. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  155. package/dist/types/sdk/host/types.d.ts +1 -1
  156. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  157. package/dist/types/sdk/index.d.ts +2 -1
  158. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  159. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  160. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  161. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  162. package/dist/types/sdk/mcp/server.d.ts +9 -6
  163. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  164. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  165. package/dist/types/sdk/prompt-status.d.ts +10 -3
  166. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  167. package/dist/types/sdk/receipt-state.d.ts +17 -0
  168. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  169. package/dist/types/sdk/router/index.d.ts +1 -0
  170. package/dist/types/sdk/router/session-router.d.ts +152 -0
  171. package/dist/types/sdk/session-list.d.ts +22 -0
  172. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  173. package/dist/types/sdk/session.d.ts +34 -1
  174. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  175. package/dist/types/session/account-inventory.d.ts +86 -0
  176. package/dist/types/session/agent-session.d.ts +145 -6
  177. package/dist/types/session/agent-storage.d.ts +10 -0
  178. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  179. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  180. package/dist/types/session/messages.d.ts +1 -1
  181. package/dist/types/session/secure-token-file.d.ts +3 -0
  182. package/dist/types/session/session-manager.d.ts +40 -1
  183. package/dist/types/session/startup-auth-config.d.ts +44 -0
  184. package/dist/types/session/terminal-abort.d.ts +378 -0
  185. package/dist/types/session/yield-queue.d.ts +7 -0
  186. package/dist/types/session-import/claude.d.ts +26 -0
  187. package/dist/types/session-import/codex.d.ts +88 -0
  188. package/dist/types/session-import/command.d.ts +35 -0
  189. package/dist/types/session-import/detect.d.ts +22 -0
  190. package/dist/types/session-import/index.d.ts +7 -0
  191. package/dist/types/session-import/provider-codex.d.ts +22 -0
  192. package/dist/types/session-import/provider-service.d.ts +38 -0
  193. package/dist/types/session-import/redact.d.ts +23 -0
  194. package/dist/types/session-import/service.d.ts +47 -0
  195. package/dist/types/session-import/types.d.ts +120 -0
  196. package/dist/types/setup/paseo/check.d.ts +16 -0
  197. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  198. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  199. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  200. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  201. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  202. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  203. package/dist/types/setup/paseo/remove.d.ts +14 -0
  204. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  205. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  206. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  207. package/dist/types/skill-state/active-state.d.ts +5 -1
  208. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  209. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  210. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  211. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  212. package/dist/types/slash-commands/types.d.ts +7 -1
  213. package/dist/types/task/executor.d.ts +10 -0
  214. package/dist/types/task/index.d.ts +3 -1
  215. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  216. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  217. package/dist/types/tools/bash.d.ts +2 -6
  218. package/dist/types/tools/browser/attach.d.ts +11 -0
  219. package/dist/types/tools/browser/launch.d.ts +20 -0
  220. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  221. package/dist/types/tools/browser/render.d.ts +1 -0
  222. package/dist/types/tools/browser.d.ts +3 -1
  223. package/dist/types/tools/image-gen.d.ts +9 -2
  224. package/dist/types/tools/index.d.ts +8 -0
  225. package/dist/types/tools/monitor.d.ts +1 -1
  226. package/dist/types/tools/skill-discovery.d.ts +8 -1
  227. package/dist/types/tools/subagent.d.ts +2 -0
  228. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  229. package/dist/types/utils/herdr-pane.d.ts +76 -0
  230. package/package.json +19 -9
  231. package/scripts/build-binary.ts +2 -1
  232. package/scripts/build-sdk-package-smoke.ts +2 -13
  233. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  234. package/scripts/compile-args.ts +2 -0
  235. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  236. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  237. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  238. package/scripts/generate-tool-catalog.ts +6 -4
  239. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  240. package/src/async/job-manager.ts +352 -17
  241. package/src/async/support.ts +25 -0
  242. package/src/autoresearch/dashboard.ts +28 -13
  243. package/src/capability/index.ts +3 -1
  244. package/src/capability/mcp.ts +6 -0
  245. package/src/capability/types.ts +6 -0
  246. package/src/cli/accounts-cli.ts +395 -0
  247. package/src/cli/args.ts +23 -0
  248. package/src/cli/auth-broker-cli.ts +136 -66
  249. package/src/cli/auth-gateway-cli.ts +94 -70
  250. package/src/cli/config-cli.ts +27 -2
  251. package/src/cli/crash-cli.ts +78 -0
  252. package/src/cli/customize-doctor.ts +1605 -0
  253. package/src/cli/daemon-cli.ts +12 -3
  254. package/src/cli/fast-help.ts +1 -0
  255. package/src/cli/mcp-cli.ts +128 -60
  256. package/src/cli/notify-cli.ts +8 -14
  257. package/src/cli/root-flags.ts +8 -0
  258. package/src/cli/setup-cli.ts +145 -0
  259. package/src/cli/skills-cli.ts +38 -2
  260. package/src/cli/update-cli.ts +167 -12
  261. package/src/cli.ts +4 -0
  262. package/src/commands/accounts.ts +71 -0
  263. package/src/commands/crash.ts +35 -0
  264. package/src/commands/customize.ts +84 -0
  265. package/src/commands/daemon.ts +2 -2
  266. package/src/commands/deep-interview.ts +2 -0
  267. package/src/commands/launch.ts +1 -0
  268. package/src/commands/mcp.ts +12 -7
  269. package/src/commands/ralplan.ts +2 -0
  270. package/src/commands/sdk.ts +65 -32
  271. package/src/commands/setup.ts +5 -0
  272. package/src/commands/skills.ts +14 -4
  273. package/src/commands/ultragoal.ts +10 -1
  274. package/src/commands/update.ts +26 -15
  275. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  276. package/src/config/atomic-yaml-patch.ts +769 -29
  277. package/src/config/config-schema-version.ts +10 -0
  278. package/src/config/file-lock.ts +70 -1
  279. package/src/config/mcp-schema.json +52 -14
  280. package/src/config/model-profile-activation.ts +20 -1
  281. package/src/config/model-profiles.ts +29 -19
  282. package/src/config/model-registry.ts +132 -21
  283. package/src/config/models-config-schema.ts +1 -1
  284. package/src/config/settings-schema.ts +76 -6
  285. package/src/config/settings.ts +4038 -199
  286. package/src/config/skill-settings-defaults.ts +41 -3
  287. package/src/config/task-delegation.ts +87 -0
  288. package/src/config/update-channel.ts +23 -0
  289. package/src/coordinator-mcp/server.ts +377 -308
  290. package/src/crash/index-store.ts +723 -0
  291. package/src/crash/nudge.ts +89 -0
  292. package/src/crash/record-loader.ts +149 -0
  293. package/src/crash/report.ts +551 -0
  294. package/src/crash/sanitize.ts +129 -0
  295. package/src/cursor.ts +36 -10
  296. package/src/customization/import.ts +946 -0
  297. package/src/customization/index.ts +4 -0
  298. package/src/customization/inventory.ts +335 -0
  299. package/src/customization/mutations.ts +141 -0
  300. package/src/customization/types.ts +223 -0
  301. package/src/daemon/control-types.ts +8 -1
  302. package/src/daemon/operator-contract.ts +6 -6
  303. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  304. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  305. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  306. package/src/discovery/claude-hooks.ts +38 -0
  307. package/src/discovery/claude.ts +142 -209
  308. package/src/discovery/codex-hooks.ts +38 -0
  309. package/src/discovery/codex.ts +132 -198
  310. package/src/discovery/helpers.ts +8 -1
  311. package/src/discovery/index.ts +2 -0
  312. package/src/discovery/mcp-compat.ts +234 -0
  313. package/src/discovery/mcp-json.ts +6 -0
  314. package/src/edit/renderer.ts +40 -0
  315. package/src/edit/streaming.ts +4 -2
  316. package/src/exec/bash-executor.ts +24 -20
  317. package/src/export/html/index.ts +51 -2
  318. package/src/extensibility/extensions/loader.ts +9 -1
  319. package/src/extensibility/extensions/runner.ts +17 -4
  320. package/src/extensibility/extensions/types.ts +31 -6
  321. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  322. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  323. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  324. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  325. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  326. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  327. package/src/extensibility/hooks/loader.ts +154 -10
  328. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  329. package/src/extensibility/skill-management.ts +368 -0
  330. package/src/extensibility/skills.ts +52 -16
  331. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  332. package/src/gjc-runtime/launch-worktree.ts +56 -2
  333. package/src/gjc-runtime/linux-proc.ts +7 -1
  334. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  335. package/src/gjc-runtime/session-state-lock.ts +992 -0
  336. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  337. package/src/gjc-runtime/state-writer.ts +39 -14
  338. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  339. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  340. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  341. package/src/gjc-runtime/workflow-settings.ts +596 -0
  342. package/src/goals/tools/goal-tool.ts +6 -1
  343. package/src/hooks/events.ts +291 -0
  344. package/src/hooks/native-skill-hook.ts +4 -0
  345. package/src/hooks/normalize.ts +369 -0
  346. package/src/hooks/skill-state.ts +165 -46
  347. package/src/internal-urls/docs-index.generated.ts +40 -32
  348. package/src/main.ts +32 -5
  349. package/src/migrate/mcp-mapper.ts +25 -2
  350. package/src/modes/acp/abort-scope.ts +29 -0
  351. package/src/modes/acp/acp-agent.ts +423 -98
  352. package/src/modes/components/assistant-message.ts +6 -1
  353. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  354. package/src/modes/components/customization/import-wizard.ts +372 -0
  355. package/src/modes/components/customization/index.ts +1 -0
  356. package/src/modes/components/gajae-pet-widget.ts +539 -213
  357. package/src/modes/components/irc-sidebar.ts +11 -0
  358. package/src/modes/components/iterm-pet-transport.ts +585 -0
  359. package/src/modes/components/oauth-selector.ts +277 -86
  360. package/src/modes/components/pet-capability.ts +39 -40
  361. package/src/modes/components/settings-selector.ts +9 -0
  362. package/src/modes/components/status-line/gh.ts +45 -1
  363. package/src/modes/components/status-line/segments.ts +16 -1
  364. package/src/modes/components/tool-status-header.ts +13 -3
  365. package/src/modes/controllers/command-controller.ts +20 -8
  366. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  367. package/src/modes/controllers/input-controller.ts +10 -0
  368. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  369. package/src/modes/controllers/selector-controller.ts +177 -72
  370. package/src/modes/controllers/stt-controller.ts +3 -3
  371. package/src/modes/interactive-mode.ts +88 -16
  372. package/src/modes/print-mode.ts +35 -6
  373. package/src/modes/runtime-init.ts +21 -68
  374. package/src/modes/types.ts +1 -0
  375. package/src/modes/utils/injected-user-submission.ts +25 -0
  376. package/src/modes/utils/terminal-bell.ts +15 -1
  377. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  378. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  379. package/src/prompts/agents/critic.md +1 -1
  380. package/src/prompts/system/rlm-research.md +1 -1
  381. package/src/prompts/system/system-prompt.md +0 -2
  382. package/src/prompts/tools/bash.md +7 -1
  383. package/src/prompts/tools/browser.md +1 -1
  384. package/src/prompts/tools/cron.md +6 -6
  385. package/src/prompts/tools/read.md +0 -2
  386. package/src/prompts/tools/skill-discovery.md +4 -2
  387. package/src/runtime-mcp/client.ts +427 -23
  388. package/src/runtime-mcp/config.ts +37 -5
  389. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  390. package/src/runtime-mcp/manager.ts +52 -2
  391. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  392. package/src/runtime-mcp/oauth-flow.ts +46 -1
  393. package/src/runtime-mcp/pool-key.ts +5 -1
  394. package/src/runtime-mcp/pool.test.ts +9 -0
  395. package/src/runtime-mcp/protocol.test.ts +353 -0
  396. package/src/runtime-mcp/protocol.ts +624 -0
  397. package/src/runtime-mcp/scope-config.ts +78 -0
  398. package/src/runtime-mcp/tool-bridge.ts +44 -10
  399. package/src/runtime-mcp/transports/http.ts +84 -13
  400. package/src/runtime-mcp/types.ts +123 -12
  401. package/src/sdk/acp/adapter.ts +328 -97
  402. package/src/sdk/broker/broker.ts +160 -741
  403. package/src/sdk/broker/ensure.ts +9 -1
  404. package/src/sdk/broker/lifecycle.ts +510 -116
  405. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  406. package/src/sdk/broker/process-incarnation.ts +19 -0
  407. package/src/sdk/broker/session-index.ts +516 -117
  408. package/src/sdk/broker/startup-failure.ts +28 -5
  409. package/src/sdk/broker/transport.ts +1 -13
  410. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  411. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  412. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  413. package/src/sdk/bus/config.ts +13 -0
  414. package/src/sdk/bus/conversation-store.ts +59 -171
  415. package/src/sdk/bus/daemon-paths.ts +20 -0
  416. package/src/sdk/bus/discord-conversation.ts +2 -0
  417. package/src/sdk/bus/discord-daemon.ts +524 -126
  418. package/src/sdk/bus/discord-live-provider.ts +206 -79
  419. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  420. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  421. package/src/sdk/bus/index.ts +2480 -456
  422. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  423. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  424. package/src/sdk/bus/managed-daemon.ts +2 -145
  425. package/src/sdk/bus/notification-service.ts +196 -0
  426. package/src/sdk/bus/operator-runtime.ts +1 -1
  427. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  428. package/src/sdk/bus/reconciliation-store.ts +553 -24
  429. package/src/sdk/bus/session-control.ts +49 -3
  430. package/src/sdk/bus/slack-conversation.ts +4 -0
  431. package/src/sdk/bus/slack-daemon.ts +733 -134
  432. package/src/sdk/bus/slack-live-provider.ts +65 -15
  433. package/src/sdk/bus/slack-provider.ts +4 -0
  434. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  435. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  436. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  437. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  438. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  439. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  440. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  441. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  442. package/src/sdk/bus/telegram-reference.ts +1 -226
  443. package/src/sdk/bus/telegram-setup.ts +2 -2
  444. package/src/sdk/bus/threaded-render.ts +1 -0
  445. package/src/sdk/bus/topic-registry.ts +279 -146
  446. package/src/sdk/cli/rows.ts +2 -35
  447. package/src/sdk/cli/session-cli.ts +776 -700
  448. package/src/sdk/client/client.ts +892 -1
  449. package/src/sdk/host/control/dispatch.ts +93 -58
  450. package/src/sdk/host/control/index.ts +1 -1
  451. package/src/sdk/host/control/operations.ts +18 -10
  452. package/src/sdk/host/control/runtime-gate.ts +40 -0
  453. package/src/sdk/host/host.ts +63 -12
  454. package/src/sdk/host/query/handlers.ts +66 -7
  455. package/src/sdk/host/reverse-leases.ts +2 -2
  456. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  457. package/src/sdk/host/session-runtime.test.ts +2787 -34
  458. package/src/sdk/host/session-runtime.ts +1489 -101
  459. package/src/sdk/host/surface-policy.ts +12 -8
  460. package/src/sdk/host/types.ts +1 -1
  461. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  462. package/src/sdk/host/websocket-transport.ts +85 -50
  463. package/src/sdk/index.ts +2 -1
  464. package/src/sdk/lifecycle/broker-client.ts +53 -0
  465. package/src/sdk/lifecycle/client.ts +234 -0
  466. package/src/sdk/lifecycle/index.ts +3 -0
  467. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  468. package/src/sdk/lifecycle/service.ts +720 -0
  469. package/src/sdk/mcp/server.ts +226 -188
  470. package/src/sdk/prompt-deadline-lease.ts +55 -0
  471. package/src/sdk/prompt-deadline-manager.ts +148 -0
  472. package/src/sdk/prompt-status.ts +11 -3
  473. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  474. package/src/sdk/protocol/operation-registry.ts +71 -8
  475. package/src/sdk/receipt-state.ts +43 -0
  476. package/src/sdk/reconciliation-extensions.ts +14 -0
  477. package/src/sdk/router/index.ts +1 -0
  478. package/src/sdk/router/session-router.ts +1658 -0
  479. package/src/sdk/session-list.ts +93 -0
  480. package/src/sdk/session-reconnect.ts +45 -1
  481. package/src/sdk/session.ts +716 -120
  482. package/src/sdk/transport/serve-cli.ts +18 -34
  483. package/src/session/account-inventory.ts +603 -0
  484. package/src/session/agent-session.ts +2857 -582
  485. package/src/session/agent-storage.ts +35 -0
  486. package/src/session/blob-store.ts +32 -1
  487. package/src/session/coordinator-tool-label.ts +40 -0
  488. package/src/session/internal/managed-session-scope.ts +4 -0
  489. package/src/session/internal/managed-session-storage.ts +173 -12
  490. package/src/session/messages.ts +1 -1
  491. package/src/session/secure-token-file.ts +80 -0
  492. package/src/session/session-manager.ts +730 -58
  493. package/src/session/session-storage.ts +25 -16
  494. package/src/session/startup-auth-config.ts +297 -0
  495. package/src/session/terminal-abort.ts +1253 -0
  496. package/src/session/yield-queue.ts +57 -15
  497. package/src/session-import/claude.ts +334 -0
  498. package/src/session-import/codex.ts +1033 -0
  499. package/src/session-import/command.ts +77 -0
  500. package/src/session-import/detect.ts +133 -0
  501. package/src/session-import/index.ts +7 -0
  502. package/src/session-import/provider-codex.ts +327 -0
  503. package/src/session-import/provider-service.ts +457 -0
  504. package/src/session-import/redact.ts +128 -0
  505. package/src/session-import/service.ts +1264 -0
  506. package/src/session-import/types.ts +148 -0
  507. package/src/setup/paseo/check.ts +213 -0
  508. package/src/setup/paseo/install-saga.ts +293 -0
  509. package/src/setup/paseo/json-publisher.ts +263 -0
  510. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  511. package/src/setup/paseo/paseo-ownership.ts +224 -0
  512. package/src/setup/paseo/paseo-setup.ts +253 -0
  513. package/src/setup/paseo/provider-config.ts +139 -0
  514. package/src/setup/paseo/remove.ts +183 -0
  515. package/src/setup/paseo/result-types.ts +79 -0
  516. package/src/setup/paseo/setup-deps.ts +175 -0
  517. package/src/setup/paseo/skills-bridge.ts +260 -0
  518. package/src/skill-state/active-state.ts +21 -11
  519. package/src/slash-commands/acp-builtins.ts +9 -1
  520. package/src/slash-commands/builtin-registry.ts +98 -16
  521. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  522. package/src/slash-commands/helpers/mcp.ts +9 -3
  523. package/src/slash-commands/helpers/usage-report.ts +61 -56
  524. package/src/slash-commands/types.ts +7 -2
  525. package/src/task/executor.ts +30 -11
  526. package/src/task/index.ts +66 -7
  527. package/src/task/render.ts +23 -9
  528. package/src/tools/ask-answer-registry.ts +23 -7
  529. package/src/tools/ask.ts +8 -4
  530. package/src/tools/bash-sleep-advisory.ts +115 -0
  531. package/src/tools/bash.ts +98 -18
  532. package/src/tools/browser/attach.ts +71 -3
  533. package/src/tools/browser/launch.ts +86 -13
  534. package/src/tools/browser/profile-discovery.ts +57 -7
  535. package/src/tools/browser/render.ts +8 -1
  536. package/src/tools/browser.ts +130 -21
  537. package/src/tools/computer.enforcement.test.ts +1 -1
  538. package/src/tools/computer.ts +3 -2
  539. package/src/tools/cron.ts +12 -1
  540. package/src/tools/image-gen.ts +27 -16
  541. package/src/tools/index.ts +8 -0
  542. package/src/tools/job.ts +33 -6
  543. package/src/tools/monitor.ts +41 -2
  544. package/src/tools/skill-discovery.ts +21 -29
  545. package/src/tools/subagent.ts +142 -24
  546. package/src/tools/tool-catalog.generated.ts +6 -6
  547. package/src/tools/ultragoal-ask-guard.ts +10 -3
  548. package/src/utils/herdr-pane.ts +345 -0
  549. package/src/utils/title-generator.ts +2 -0
  550. package/src/web/scrapers/index.ts +6 -4
  551. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  552. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  553. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  554. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  555. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  556. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  557. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  558. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  559. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  560. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  561. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  562. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  563. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  564. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  565. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  566. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  567. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  568. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  569. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  570. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  571. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  572. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  573. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  574. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  575. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  576. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  577. package/dist/types/session/auth-broker-config.d.ts +0 -13
  578. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  579. package/dist/types/skills/skills.test.d.ts +0 -1
  580. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  581. package/dist/types/task/token-log.test.d.ts +0 -1
  582. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  583. package/dist/types/tools/descriptors.test.d.ts +0 -1
  584. package/scripts/g005-daemon-smoke.ts +0 -180
  585. package/scripts/g011-daemon-path-smoke.ts +0 -153
  586. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  587. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  588. package/src/sdk/bus/telegram-cli.ts +0 -212
  589. package/src/sdk/elevation/allowlist.ts +0 -54
  590. package/src/sdk/elevation/audit.ts +0 -208
  591. package/src/sdk/elevation/capability.ts +0 -41
  592. package/src/sdk/elevation/digest.ts +0 -129
  593. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  594. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  595. package/src/sdk/elevation/owner.ts +0 -109
  596. package/src/session/auth-broker-config.ts +0 -109
  597. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  598. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  599. /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
@@ -33,7 +33,9 @@ import {
33
33
  assertImagePlaceholdersHavePayload,
34
34
  type ContextMaintenanceResult,
35
35
  canContinuePersistedHistory,
36
+ dispatchedToolIdentity,
36
37
  getAgentTerminalOwnerContext,
38
+ isNonDispatchedToolEvent,
37
39
  type ManagedAttemptContinuationOwnership,
38
40
  type ManagedAttemptDecision,
39
41
  type ManagedAttemptOutcome,
@@ -175,6 +177,7 @@ export interface ForkContextSeedOptions {
175
177
  signal?: AbortSignal;
176
178
  }
177
179
 
180
+ import type { AuthCredentialSelector } from "@gajae-code/ai/core";
178
181
  import type { MacOSPowerAssertion } from "@gajae-code/natives";
179
182
  import {
180
183
  extractRetryHint,
@@ -186,9 +189,14 @@ import {
186
189
  prompt,
187
190
  Snowflake,
188
191
  } from "@gajae-code/utils";
189
-
190
192
  import { createAppendOnlyContextManager, resolveAppendOnlyMode } from "../append-only-mode";
191
- import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager, type OwnerSubagentShutdownLease } from "../async";
193
+ import {
194
+ type AsyncJob,
195
+ type AsyncJobDeliveryState,
196
+ AsyncJobManager,
197
+ asyncJobEndpointId as deriveAsyncJobEndpointId,
198
+ type OwnerSubagentShutdownLease,
199
+ } from "../async";
192
200
  import { reset as resetCapabilities } from "../capability";
193
201
  import type { Rule } from "../capability/rule";
194
202
  import type { CasReceipt } from "../config/atomic-yaml-patch";
@@ -224,6 +232,7 @@ import type { Settings, SkillsSettings } from "../config/settings";
224
232
  import { onAppendOnlyModeChanged } from "../config/settings";
225
233
  import type { SettingPath } from "../config/settings-schema";
226
234
  import { getDefault } from "../config/settings-schema";
235
+ import { resolveEagerTaskDelegation } from "../config/task-delegation";
227
236
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
228
237
  import { loadCapability } from "../discovery";
229
238
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
@@ -294,16 +303,23 @@ import {
294
303
  sessionStateDir,
295
304
  } from "../gjc-runtime/session-layout";
296
305
  import {
306
+ type CoordinatorToolObservation,
297
307
  ownerTerminalContextFromEnvironment,
298
308
  persistCoordinatorRuntimeStateFromEvent,
299
309
  registerCoordinatorRuntimeStateFinalizer,
310
+ UNPROVEN_TOOL_LABEL,
300
311
  } from "../gjc-runtime/session-state-sidecar";
301
312
  import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
302
313
  import { GjcTeamWorkerHeartbeatReporter } from "../gjc-runtime/team-worker-heartbeat";
303
314
  import { GoalRuntime } from "../goals/runtime";
304
315
  import type { Goal, GoalModeState } from "../goals/state";
305
316
  import type { HindsightSessionState } from "../hindsight/state";
306
- import { buildSkillStopOutput, ensureWorkflowSkillActivationState } from "../hooks/skill-state";
317
+ import {
318
+ buildSkillStopOutput,
319
+ ensureWorkflowSkillActivationSeed,
320
+ ensureWorkflowSkillActivationState,
321
+ type WorkflowSkillActivationSeed,
322
+ } from "../hooks/skill-state";
307
323
  import { initializeLocalRoot, type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
308
324
  import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
309
325
  import { resolveMemoryBackendId } from "../memory-backend/resolve";
@@ -410,6 +426,7 @@ import {
410
426
  type ContributionPrepResult,
411
427
  prepareContributionPrep,
412
428
  } from "./contribution-prep";
429
+ import { canonicalCoordinatorToolLabel } from "./coordinator-tool-label";
413
430
  import { pruneStaleFileMentions } from "./file-mention-pruning";
414
431
  import type { MemoryGuardRestoreResult } from "./memory-guard-checkpoint-participant";
415
432
  import {
@@ -448,9 +465,22 @@ import {
448
465
  SessionAppendPersistenceError,
449
466
  SessionContextTooLargeError,
450
467
  SessionManager,
468
+ SessionNearLimitAppendError,
451
469
  transferSessionMessageIdentity,
452
470
  } from "./session-manager";
453
471
  import { getEntriesForInternalRead, getSessionContextForInternalRead } from "./session-manager-internal";
472
+ import {
473
+ bindToolLineage,
474
+ classifyOwnedEnvelope,
475
+ isOwnedCompletionEnvelope,
476
+ lookupTerminalScope,
477
+ mintTurnLineageIdHash,
478
+ type OwnedCompletionEnvelope,
479
+ registerTerminalTurnScope,
480
+ retireOwnedRegistrationsForEndpoint,
481
+ settleToolLineageRegistrationWindow,
482
+ unregisterOwnedRegistration,
483
+ } from "./terminal-abort";
454
484
 
455
485
  import { ToolChoiceQueue } from "./tool-choice-queue";
456
486
  import { pruneSupersededMaintenanceReminders, pruneSupersededVolatileProjectContext } from "./volatile-context-pruning";
@@ -479,6 +509,37 @@ function appendCompactionStateContext(summary: string, stateContext: string[]):
479
509
  if (stateContext.length === 0) return summary;
480
510
  return `${summary}\n\n<compaction-state>\n${stateContext.join("\n")}\n</compaction-state>`;
481
511
  }
512
+ /**
513
+ * Classify an async-result delivery against terminal-abort ownership:
514
+ * - "ordinary": no owned-completion envelope — deliver as before.
515
+ * - "fresh": an exact registered owned-completion the owning scope's gate
516
+ * authorizes as a fresh-turn resume (scope:"turn", policy enabled).
517
+ * - "drop": a recognized owned-completion the gate denies — scope:"owned"
518
+ * (policy disabled, stopped work must never call followUp/prompt), a
519
+ * forged/unregistered tuple, or an envelope whose terminal scope no longer
520
+ * exists. Dropped entries never reach the agent (AC 36 zero final calls
521
+ * from stopped work), even if a delivery races the settlement purge.
522
+ */
523
+ export function ownedCompletionResumeAction(message: AgentMessage): "ordinary" | "fresh" | "drop" {
524
+ const details = (message as { details?: { ownedCompletions?: unknown } }).details;
525
+ const envelopes = details?.ownedCompletions;
526
+ // Extension-provided details are untrusted: the field must be an array of
527
+ // structurally valid internal envelopes, or it is ignored entirely —
528
+ // malformed metadata must never crash idle triggerTurn delivery or the
529
+ // agent loop (review thread P2).
530
+ if (!Array.isArray(envelopes) || envelopes.length === 0) return "ordinary";
531
+ // Three states per envelope: no terminal scope (no abort) is ORDINARY,
532
+ // turn-scope enabled is FRESH, owned-scope disabled is DROP. ANY drop in a
533
+ // mixed batch drops the whole delivery (defense in depth).
534
+ let anyFresh = false;
535
+ for (const envelope of envelopes) {
536
+ if (!isOwnedCompletionEnvelope(envelope)) continue;
537
+ const action = classifyOwnedEnvelope(envelope);
538
+ if (action === "drop") return "drop";
539
+ if (action === "fresh") anyFresh = true;
540
+ }
541
+ return anyFresh ? "fresh" : "ordinary";
542
+ }
482
543
 
483
544
  const PRUNED_ARTIFACT_REF_MAX_CHARS = 64;
484
545
 
@@ -571,6 +632,9 @@ export interface AgentSessionConfig {
571
632
  agent: Agent;
572
633
  sessionManager: SessionManager;
573
634
  settings: Settings;
635
+ /** The session's REQUESTED effective agent directory, independent of the
636
+ * global Settings singleton (which may be reused across sessions). */
637
+ agentDir?: string;
574
638
  /** Lazy memory backend service; omitted callers receive a session-local default. */
575
639
  memoryBackend?: LazyService<MemoryBackend>;
576
640
  /** Lazy workspace-tree service; omitted callers retain the legacy direct-scan path. */
@@ -614,6 +678,12 @@ export interface AgentSessionConfig {
614
678
  workflowGatePublication?: "endpoint" | "local";
615
679
  /** Tool registry for LSP and settings */
616
680
  toolRegistry?: Map<string, AgentTool>;
681
+ /**
682
+ * Tool objects this session's builder constructed from built-in descriptors, captured
683
+ * before any extension, MCP, or dynamically registered tool could claim their registry
684
+ * names. Absent means no provenance was proven and every tool reports as `custom`.
685
+ */
686
+ builtinToolIdentities?: ReadonlySet<object>;
617
687
  /** Tool-session factory context used to lazily attach workflow-gate-only tools. */
618
688
  workflowGateToolSession?: ToolSession;
619
689
  /** Current session pre-LLM message transform pipeline */
@@ -695,6 +765,8 @@ export interface AgentSessionConfig {
695
765
  * **MUST NOT** dispose it on their own teardown.
696
766
  */
697
767
  ownedAsyncJobManager?: AsyncJobManager;
768
+ /** False when a child borrows its parent's manager and must not dispose it. */
769
+ disposeAsyncJobManager?: boolean;
698
770
  /** Cheap TUI retained-memory counters; absent for headless sessions. */
699
771
  retainedMemorySampler?: () => RetainedMemorySample;
700
772
  /**
@@ -723,8 +795,12 @@ export interface AgentSessionConfig {
723
795
  providerSessionId?: string;
724
796
  /** Optional auth-selection identity, distinct from logical/canonical and provider-cache identity. */
725
797
  credentialSessionId?: string;
798
+ /** Opaque credential-store authority fingerprint for durable numeric session pins. */
799
+ credentialStoreIdentity?: string;
726
800
  /** Optional provider-facing cache identity, distinct from logical session identity. */
727
801
  providerCacheSessionId?: string;
802
+ /** Explicit provider affinity whose persisted transcript path scopes async ownership. */
803
+ asyncJobProviderSessionId?: string;
728
804
  }
729
805
 
730
806
  export interface AgentSessionMemoryGuardRestoreInput
@@ -802,6 +878,8 @@ export interface PromptOptions {
802
878
  onPreflightAcceptCommit?: () => void | Promise<void>;
803
879
  /** Skill-only: prepared metadata before the durable fence (path/lineCount/cleanedArgs). */
804
880
  onSkillPrepared?: (meta: { name: string; path: string; lineCount?: number; cleanedArgs?: string }) => void;
881
+ /** Optional invocation-scoped cancellation fence used before an accepted skill starts execution. */
882
+ preflightSignal?: AbortSignal;
805
883
  }
806
884
 
807
885
  function promptPreflightCancelledError(): Error {
@@ -809,6 +887,18 @@ function promptPreflightCancelledError(): Error {
809
887
  error.name = "PromptPreflightCancelledError";
810
888
  return error;
811
889
  }
890
+ async function awaitPromptInvocationPreflight<T>(pending: Promise<T>, signal?: AbortSignal): Promise<T> {
891
+ if (!signal) return await pending;
892
+ if (signal.aborted) throw promptPreflightCancelledError();
893
+ const cancellation = Promise.withResolvers<never>();
894
+ const cancel = () => cancellation.reject(promptPreflightCancelledError());
895
+ signal.addEventListener("abort", cancel, { once: true });
896
+ try {
897
+ return await Promise.race([pending, cancellation.promise]);
898
+ } finally {
899
+ signal.removeEventListener("abort", cancel);
900
+ }
901
+ }
812
902
 
813
903
  function isPromptPreflightCancelledError(error: unknown): boolean {
814
904
  return error instanceof Error && error.name === "PromptPreflightCancelledError";
@@ -950,17 +1040,12 @@ type RetryErrorClassification =
950
1040
  | "empty_response"
951
1041
  | "transient"
952
1042
  | "local_unavailable"
1043
+ | "local_snapshot"
1044
+ | "local_buffer_overflow"
953
1045
  | "unknown";
954
1046
 
955
1047
  const BARE_DEFAULT_WATCHDOG_ERROR =
956
1048
  /^(?:[A-Za-z][A-Za-z0-9-]*(?: [A-Za-z][A-Za-z0-9-]*){0,3} )stream (?:timed out while waiting for the first event|stalled while waiting for the next event)$/;
957
- /**
958
- * Consecutive same-model retries allowed for `escaped_arguments_discarded`
959
- * managed outcomes before the run terminates. Mirrors the agent loop's
960
- * escaped-non-ASCII resample budget on dev; enforced session-side here because
961
- * a managed retry re-enters the loop as a fresh run.
962
- */
963
- const MAX_ESCAPED_ARGUMENT_RETRIES = 2;
964
1049
  const PROVIDER_FIRST_EVENT_TIMEOUT_ERROR = "Provider stream timed out while waiting for the first event";
965
1050
  const WRAPPED_PROVIDER_FIRST_EVENT_TIMEOUT_ERROR = `Error: ${PROVIDER_FIRST_EVENT_TIMEOUT_ERROR}`;
966
1051
  const PROVIDER_FIRST_EVENT_TIMEOUT_WITHOUT_ARTICLE_ERROR = "Provider stream timed out while waiting for first event";
@@ -1047,7 +1132,12 @@ function assistantMessageHasVisibleOrToolContent(message: AssistantMessage): boo
1047
1132
 
1048
1133
  function isLocalModelEndpoint(model: Model | undefined): boolean {
1049
1134
  if (!model) return false;
1050
- if (model.provider === "ollama" || model.provider === "lm-studio" || model.provider === "llama.cpp") {
1135
+ if (
1136
+ model.provider === "ollama" ||
1137
+ model.provider === "lm-studio" ||
1138
+ model.provider === "llama.cpp" ||
1139
+ model.provider === "omlx"
1140
+ ) {
1051
1141
  return true;
1052
1142
  }
1053
1143
  try {
@@ -1511,6 +1601,7 @@ function extractPermissionLocations(
1511
1601
  * stable edit id while the display arrays preserve delivery order. */
1512
1602
  type QueuedDisplayEntry = { text: string; tag?: string; sequence: number };
1513
1603
  type IrcRosterClaim = { token: symbol; signature: string; epoch: number; message: CustomMessage };
1604
+ type QueuedFollowUpOwner = { cancel(): boolean };
1514
1605
  export type QueuedMessageEditMode = "steer" | "followUp";
1515
1606
 
1516
1607
  export interface QueuedMessageEditEntry {
@@ -1801,12 +1892,19 @@ type SessionAdmissionEntry = {
1801
1892
  ready: PromiseWithResolvers<void>;
1802
1893
  settled: PromiseWithResolvers<void>;
1803
1894
  released: boolean;
1895
+ selectionFenceGeneration: number;
1896
+ continuationCapability?: symbol;
1804
1897
  };
1805
1898
 
1806
1899
  type SessionAdmissionLease = {
1807
1900
  release(): void;
1808
1901
  };
1809
1902
 
1903
+ type ScheduledContinuationAdmission = {
1904
+ entry: SessionAdmissionEntry;
1905
+ capability: symbol;
1906
+ };
1907
+
1810
1908
  export interface DefaultFallbackRuntimeState {
1811
1909
  chain: ConfiguredFallbackChain;
1812
1910
  controller: FallbackChainRuntimeState;
@@ -1838,10 +1936,37 @@ function deobfuscateSessionContext(context: SessionContext, obfuscator: SecretOb
1838
1936
  return { ...context, messages };
1839
1937
  }
1840
1938
 
1939
+ /**
1940
+ * Canonical message_end admission slot. A reservation whose slot is already
1941
+ * `released` needs no await at the canonical append site, so an uncontended
1942
+ * admission never costs a microtask and external emitters keep synchronous
1943
+ * visibility of the persisted append.
1944
+ */
1945
+ interface CanonicalMessageAdmissionSlot {
1946
+ promise: Promise<void>;
1947
+ released: boolean;
1948
+ }
1949
+
1950
+ interface CanonicalMessageAdmission {
1951
+ predecessor: CanonicalMessageAdmissionSlot;
1952
+ release: () => void;
1953
+ }
1954
+
1841
1955
  export class AgentSession {
1956
+ #provisionalStreamingToolCallIds = new Set<string>();
1842
1957
  readonly agent: Agent;
1843
1958
  sessionManager: SessionManager;
1844
1959
  readonly settings: Settings;
1960
+ readonly #requestedAgentDir: string | undefined;
1961
+
1962
+ /**
1963
+ * The session's effective agent directory: its REQUESTED directory when
1964
+ * provided, else the global Settings singleton's (which may be reused
1965
+ * across sessions and therefore not the requesting session's own).
1966
+ */
1967
+ getSessionAgentDir(): string {
1968
+ return this.#requestedAgentDir ?? this.settings.getAgentDir();
1969
+ }
1845
1970
  readonly memoryBackend: LazyService<MemoryBackend>;
1846
1971
  readonly notificationSessionController: NotificationSessionController | undefined;
1847
1972
  readonly taskDepth: number;
@@ -1866,8 +1991,20 @@ export class AgentSession {
1866
1991
  #preProfileModel: Model | undefined;
1867
1992
  #sessionAdmissionQueue: SessionAdmissionEntry[] = [];
1868
1993
  #activeSessionAdmission: SessionAdmissionEntry | undefined;
1994
+ #sessionAdmissionClosing = false;
1869
1995
  #sessionAdmissionClosed = false;
1870
1996
  #sessionAdmissionContext = new AsyncLocalStorage<SessionAdmissionEntry>();
1997
+ #selectionFenceGenerationContext = new AsyncLocalStorage<number>();
1998
+ #selectionFenceTail: Promise<void> = Promise.resolve();
1999
+ #pendingSelectionFences = 0;
2000
+ #selectionFenceDeferredContinuations = new Map<number, number>();
2001
+ #scopedSettlementWaiters = new Set<() => void>();
2002
+ #oldestPendingSelectionFenceGeneration = 0;
2003
+ #followUpReservationEpoch = 0;
2004
+ /** Epochs of follow-up reservations still between reservation and durable enqueue. */
2005
+ #activeFollowUpReservationEpochs = new Set<number>();
2006
+ #followUpReservationDrainWaiters = new Set<() => void>();
2007
+ #selectionFenceGeneration = 0;
1871
2008
  #defaultModelSelectionMutationRevision = 0;
1872
2009
  #thinkingLevelMutationRevision = 0;
1873
2010
  #thinkingVisibilityMutationRevision = 0;
@@ -1937,9 +2074,62 @@ export class AgentSession {
1937
2074
  /** Tracks pending follow-up messages for UI display. Removed when delivered.
1938
2075
  * See `#steeringMessages` for entry shape. */
1939
2076
  #followUpMessages: QueuedDisplayEntry[] = [];
2077
+ /** User/SDK-requested next turns, distinct from aborted-turn continuations. */
2078
+ readonly #externalFollowUps = new WeakSet<AgentMessage>();
2079
+ // SDK requester-ownership correlation per queued follow-up message: the hook
2080
+ // fires when the message is actually DEQUEUED into a run (onFollowUpConsumed),
2081
+ // not when an independently scheduled continuation is accepted — a skipped
2082
+ // continuation must never discard the correlation of work that is still
2083
+ // consumed (review thread P2).
2084
+ readonly #followUpPromotionHooks = new Map<AgentMessage, () => void>();
2085
+ /** SDK-owned follow-ups held outside Agent's live queue until the active run ends. */
2086
+ #deferredSdkFollowUps: AgentMessage[] = [];
2087
+ // Client/SDK steering (turn.prompt diverted to steer while streaming, or an
2088
+ // explicit turn.steer) is an independent root-turn request ONLY when it is
2089
+ // admitted AFTER the terminal abort snapshot: a terminal abort admitted
2090
+ // earlier must never purge it, or the acknowledged submission would never
2091
+ // run and its reconciliation record would stay accepted indefinitely —
2092
+ // while a steer admitted BEFORE the abort is an accepted-pre-close
2093
+ // continuation of the aborted attempt that the contract requires to be
2094
+ // blocked (review thread P1).
2095
+ readonly #externalSteerMessages = new WeakSet<AgentMessage>();
2096
+ /** Per-message SDK requester-ownership correlation for queued client steers:
2097
+ * fired exactly once when the steer's run accepts it — via the idle
2098
+ * auto-continue or the terminal-abort rearm (review thread P1). */
2099
+ readonly #steerPromotionHooks = new WeakMap<AgentMessage, () => void>();
2100
+ /** Monotonic steering admission sequence; the terminal abort snapshots it. */
2101
+ #steeringAdmissionSeq = 0;
2102
+ readonly #externalSteerAdmissionSeq = new WeakMap<AgentMessage, number>();
2103
+ /**
2104
+ * Per-admission terminal-abort steering snapshots, FIFO per aborted turn
2105
+ * key (lineage:epoch). Each admission pushes { sequence, token } here; the
2106
+ * settlement of THAT admission consumes (shifts) it, so overlapping aborts
2107
+ * of the same turn never overwrite an earlier admission's snapshot (review
2108
+ * thread P1). A replay-only abort discards its own token — the durable
2109
+ * replay path never settles, so without the discard a later real abort
2110
+ * would consume the stale entry and treat steering admitted since the
2111
+ * replay as post-abort (review thread P1).
2112
+ */
2113
+ readonly #terminalAbortSteeringSnapshots = new Map<string, Array<{ seq: number; token: number }>>();
2114
+ readonly #terminalAbortSteeringSnapshotKeys = new Map<number, string>();
2115
+ #terminalAbortAdmissionSeq = 0;
1940
2116
  #queuedDisplaySequence = 0;
2117
+ #fireQueuedPromotionHooks(messages: readonly AgentMessage[]): void {
2118
+ for (const message of messages) {
2119
+ const steerHook = this.#steerPromotionHooks.get(message);
2120
+ if (steerHook) {
2121
+ this.#steerPromotionHooks.delete(message);
2122
+ steerHook();
2123
+ }
2124
+ const followUpHook = this.#followUpPromotionHooks.get(message);
2125
+ if (followUpHook) {
2126
+ this.#followUpPromotionHooks.delete(message);
2127
+ followUpHook();
2128
+ }
2129
+ }
2130
+ }
1941
2131
  /** Messages queued to be included with the next user prompt as context ("asides"). */
1942
- #pendingNextTurnMessages: CustomMessage[] = [];
2132
+ #pendingNextTurnMessages: Array<{ message: CustomMessage; origin: "turn" | "external" }> = [];
1943
2133
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
1944
2134
  #planModeState: PlanModeState | undefined;
1945
2135
  #goalModeState: GoalModeState | undefined;
@@ -1996,6 +2186,7 @@ export class AgentSession {
1996
2186
  this.#retryReplayEpoch++;
1997
2187
  this.#retryReplayUnsafeEpoch = undefined;
1998
2188
  this.#firstEventTimeoutRetryStartedAt = Date.now();
2189
+ this.#providerRetryMaxAttempts = undefined;
1999
2190
  }
2000
2191
 
2001
2192
  #markRetryReplayUnsafe(): void {
@@ -2033,11 +2224,11 @@ export class AgentSession {
2033
2224
  #retryAbortController: AbortController | undefined = undefined;
2034
2225
  #retryNowRequested = false;
2035
2226
  #firstEventTimeoutRetryStartedAt: number | undefined;
2227
+ #providerRetryMaxAttempts: number | undefined;
2036
2228
  #retryAttempt = 0;
2037
2229
  #retryPromise: Promise<void> | undefined = undefined;
2038
2230
  #retryResolve: (() => void) | undefined = undefined;
2039
2231
  #defaultFallbackController: FallbackChainController | undefined;
2040
- #consecutiveEscapedArgumentRetries = 0;
2041
2232
  #overflowMaintenanceAttempts = 0;
2042
2233
  #defaultFallbackExhaustedLastTurn = false;
2043
2234
  #fallbackInvocationId = 0;
@@ -2077,6 +2268,7 @@ export class AgentSession {
2077
2268
  * this undefined and **MUST NOT** dispose the global instance on teardown.
2078
2269
  */
2079
2270
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
2271
+ readonly #disposeAsyncJobManager: boolean;
2080
2272
  readonly #ownedMcpManager: MCPManager | undefined;
2081
2273
  #startupTurnBarrier: Promise<void> | undefined;
2082
2274
  #pendingPythonMessages: Array<{
@@ -2099,7 +2291,9 @@ export class AgentSession {
2099
2291
  #ircRosterClaim: IrcRosterClaim | null = null;
2100
2292
  #providerSessionId: string | undefined;
2101
2293
  #credentialSessionId: string | undefined;
2294
+ #credentialStoreIdentity: string | undefined;
2102
2295
  #providerCacheSessionId: string | undefined;
2296
+ readonly #asyncJobProviderSessionId: string | undefined;
2103
2297
  #isDisposed = false;
2104
2298
  #disposePromise: Promise<void> | undefined;
2105
2299
  readonly #toolSessionCleanups = new Set<() => Promise<void> | void>();
@@ -2108,6 +2302,9 @@ export class AgentSession {
2108
2302
  #newSessionTransition: Promise<boolean> | undefined;
2109
2303
  // Extension system
2110
2304
  #extensionRunner: ExtensionRunner | undefined = undefined;
2305
+ /** SDK follow-up ownership by queued message and the attempt that dequeues it. */
2306
+ #sdkRunTokensByQueuedMessage = new WeakMap<AgentMessage, string>();
2307
+ #sdkRunTokensByAttemptScope = new WeakMap<AttemptScope, string>();
2111
2308
  #attemptAuthority!: AttemptScopeAuthority;
2112
2309
  #attemptRecordStore!: AttemptRecordStore;
2113
2310
  #activeLogicalRunId: AttemptRunHandle["logicalRunId"] | undefined;
@@ -2145,6 +2342,17 @@ export class AgentSession {
2145
2342
 
2146
2343
  // Tool registry and prompt builder for extensions
2147
2344
  #toolRegistry: Map<string, AgentTool>;
2345
+ /**
2346
+ * Session-owned provenance for tool OBJECTS built from a built-in descriptor.
2347
+ *
2348
+ * Copy-owned so this session can extend it with built-ins it creates or refreshes
2349
+ * later (the execution guard wrapper, an SSH reload, the lazily attached workflow-gate
2350
+ * `ask`) without mutating the builder's set. Weak because it holds no opinion about a
2351
+ * tool's lifetime: an object that is no longer reachable can never be dispatched
2352
+ * again. Custom, MCP, and dynamically registered tools never enter it, so a name
2353
+ * collision cannot inherit a built-in's label.
2354
+ */
2355
+ #builtinToolIdentities: WeakSet<object>;
2148
2356
  #workflowGateToolSession: ToolSession | undefined;
2149
2357
  #transformContext: (
2150
2358
  messages: AgentMessage[],
@@ -2242,6 +2450,7 @@ export class AgentSession {
2242
2450
  /** Test-only abort outcome override for cancel-and-submit rollback coverage. */
2243
2451
  #cancelAndSubmitAbortOutcomeProviderForTests: (() => Promise<AbortOutcome>) | undefined = undefined;
2244
2452
  #postPromptTasks = new Set<Promise<void>>();
2453
+ #postPromptTaskSelectionFenceGenerations = new Map<Promise<void>, number>();
2245
2454
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
2246
2455
  #postPromptTasksResolve: (() => void) | undefined = undefined;
2247
2456
  #postPromptTasksAbortController = new AbortController();
@@ -2305,8 +2514,18 @@ export class AgentSession {
2305
2514
 
2306
2515
  #pendingRewindReport: string | undefined = undefined;
2307
2516
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
2517
+ // Private terminal-abort machinery (C04 mode:"terminal"). The lineage id is
2518
+ // minted per prompt turn before the model runs; tool-call bindings attach
2519
+ // the attempt epoch so background registrations can later be classified as
2520
+ // exact owned work (turn-continuation vs owned-completion) by source, never
2521
+ // by timing. Endpoint generation is 0 for local/non-SDK sessions and is
2522
+ // bound by the SDK host layer when a terminal endpoint is known.
2523
+ #terminalEndpointGeneration = 0;
2524
+ #turnLineageIdHash: string | undefined;
2525
+ #terminalLineageSecret = crypto.randomUUID();
2308
2526
  #promptGeneration = 0;
2309
2527
  #promptPreflightAbortController = new AbortController();
2528
+ #promptPreflightCancellationGeneration = 0;
2310
2529
 
2311
2530
  #providerSessionState = new Map<string, ProviderSessionState>();
2312
2531
  #temporaryProviderSessionScopes: TemporaryProviderSessionScopeRecord[] = [];
@@ -2372,6 +2591,12 @@ export class AgentSession {
2372
2591
  });
2373
2592
  }
2374
2593
 
2594
+ #assertSessionAdmissionOpen(): void {
2595
+ if (this.#sessionAdmissionClosing || this.#sessionAdmissionClosed || this.#isDisposed) {
2596
+ throw this.#sessionAdmissionBusyError();
2597
+ }
2598
+ }
2599
+
2375
2600
  /**
2376
2601
  * Reject a turn start while a handoff transition owns the session. Handoff never
2377
2602
  * routes its own generation/injection through these turn-start chokepoints, and
@@ -2420,6 +2645,18 @@ export class AgentSession {
2420
2645
  next.ready.resolve();
2421
2646
  }
2422
2647
 
2648
+ #captureScheduledContinuationAdmission(): ScheduledContinuationAdmission | undefined {
2649
+ const entry = this.#sessionAdmissionContext.getStore();
2650
+ if (
2651
+ entry?.kind !== "prompt" ||
2652
+ entry.released ||
2653
+ this.#activeSessionAdmission !== entry ||
2654
+ entry.continuationCapability === undefined
2655
+ )
2656
+ return undefined;
2657
+ return { entry, capability: entry.continuationCapability };
2658
+ }
2659
+
2423
2660
  async #awaitStartupTurnBarrier(): Promise<void> {
2424
2661
  const barrier = this.#startupTurnBarrier;
2425
2662
  if (!barrier) return;
@@ -2435,11 +2672,43 @@ export class AgentSession {
2435
2672
  async #withSessionAdmission<T>(
2436
2673
  kind: SessionAdmissionKind,
2437
2674
  body: (lease: SessionAdmissionLease) => Promise<T>,
2675
+ signal?: AbortSignal,
2676
+ continuationAdmission?: ScheduledContinuationAdmission,
2677
+ options?: {
2678
+ allowDuringClosing?: boolean;
2679
+ bypassSelectionFenceGeneration?: number;
2680
+ allowPromptContinuationReentry?: boolean;
2681
+ },
2438
2682
  ): Promise<T> {
2439
- if (kind === "prompt") await this.#awaitStartupTurnBarrier();
2440
2683
  const owner = this.#sessionAdmissionContext.getStore();
2441
- if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
2442
- if (this.#sessionAdmissionClosed || this.#isDisposed) throw this.#sessionAdmissionBusyError();
2684
+ if (owner && !owner.released) {
2685
+ if (
2686
+ continuationAdmission?.entry === owner &&
2687
+ continuationAdmission.capability === owner.continuationCapability
2688
+ )
2689
+ return await body({ release: () => {} });
2690
+ if (options?.allowPromptContinuationReentry === true && owner.kind === "prompt") {
2691
+ return await body({ release: () => {} });
2692
+ }
2693
+ throw this.#sessionAdmissionBusyError();
2694
+ }
2695
+ if (kind === "prompt") await awaitPromptInvocationPreflight(this.#awaitStartupTurnBarrier(), signal);
2696
+ const bypassesSelectionFence =
2697
+ options?.bypassSelectionFenceGeneration !== undefined &&
2698
+ options.bypassSelectionFenceGeneration < this.#selectionFenceGeneration;
2699
+ if (
2700
+ kind === "prompt" &&
2701
+ continuationAdmission === undefined &&
2702
+ !bypassesSelectionFence &&
2703
+ this.#pendingSelectionFences > 0
2704
+ ) {
2705
+ await awaitPromptInvocationPreflight(this.#selectionFenceTail, signal);
2706
+ }
2707
+ if (
2708
+ this.#sessionAdmissionClosed ||
2709
+ ((this.#sessionAdmissionClosing || this.#isDisposed) && options?.allowDuringClosing !== true)
2710
+ )
2711
+ throw this.#sessionAdmissionBusyError();
2443
2712
  // Reject new external turns for the whole handoff transition. The handoff
2444
2713
  // itself never acquires prompt admission (it generates via generateHandoff and
2445
2714
  // injects via appendCustomMessageEntry), so this fences external entrants —
@@ -2456,11 +2725,30 @@ export class AgentSession {
2456
2725
  ready: Promise.withResolvers<void>(),
2457
2726
  settled: Promise.withResolvers<void>(),
2458
2727
  released: false,
2728
+ selectionFenceGeneration: this.#selectionFenceGeneration,
2729
+ ...(kind === "prompt" ? { continuationCapability: Symbol("scheduled-continuation") } : {}),
2730
+ };
2731
+ const releaseEntry = () => {
2732
+ if (entry.released) return;
2733
+ entry.released = true;
2734
+ entry.settled.resolve();
2735
+ const queuedIndex = this.#sessionAdmissionQueue.indexOf(entry);
2736
+ if (queuedIndex >= 0) this.#sessionAdmissionQueue.splice(queuedIndex, 1);
2737
+ if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
2738
+ this.#activateNextSessionAdmission();
2459
2739
  };
2460
2740
  this.#sessionAdmissionQueue.push(entry);
2461
2741
  this.#activateNextSessionAdmission();
2462
- await entry.ready.promise;
2463
- if (this.#sessionAdmissionClosed || this.#isDisposed) {
2742
+ try {
2743
+ await awaitPromptInvocationPreflight(entry.ready.promise, signal);
2744
+ } catch (error) {
2745
+ releaseEntry();
2746
+ throw error;
2747
+ }
2748
+ if (
2749
+ this.#sessionAdmissionClosed ||
2750
+ ((this.#sessionAdmissionClosing || this.#isDisposed) && options?.allowDuringClosing !== true)
2751
+ ) {
2464
2752
  entry.released = true;
2465
2753
  entry.settled.resolve();
2466
2754
  if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
@@ -2480,11 +2768,7 @@ export class AgentSession {
2480
2768
  }
2481
2769
 
2482
2770
  const release = () => {
2483
- if (entry.released) return;
2484
- entry.released = true;
2485
- entry.settled.resolve();
2486
- if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
2487
- this.#activateNextSessionAdmission();
2771
+ releaseEntry();
2488
2772
  };
2489
2773
  try {
2490
2774
  return await this.#sessionAdmissionContext.run(entry, () => body({ release }));
@@ -2494,19 +2778,22 @@ export class AgentSession {
2494
2778
  }
2495
2779
 
2496
2780
  async #closeSessionAdmission(): Promise<void> {
2497
- this.#sessionAdmissionClosed = true;
2498
- const queued = this.#sessionAdmissionQueue.splice(0);
2499
- for (const entry of queued) {
2500
- entry.released = true;
2501
- entry.ready.reject(this.#sessionAdmissionBusyError());
2502
- entry.settled.resolve();
2503
- }
2781
+ this.#sessionAdmissionClosing = true;
2504
2782
  const active = this.#activeSessionAdmission;
2505
2783
  if (active?.kind === "prompt") {
2506
2784
  this.#promptGeneration++;
2785
+ this.#promptPreflightCancellationGeneration++;
2507
2786
  this.#promptPreflightAbortController.abort();
2508
2787
  }
2509
2788
  if (active) await active.settled.promise;
2789
+ await this.#selectionFenceTail;
2790
+ this.#sessionAdmissionClosed = true;
2791
+ const queued = this.#sessionAdmissionQueue.splice(0);
2792
+ for (const entry of queued) {
2793
+ entry.released = true;
2794
+ entry.ready.resolve();
2795
+ entry.settled.resolve();
2796
+ }
2510
2797
  }
2511
2798
 
2512
2799
  #beginInFlight(): void {
@@ -2517,6 +2804,50 @@ export class AgentSession {
2517
2804
  // in #refreshTeamWorkerHeartbeat(), including internally dispatched turns.
2518
2805
  }
2519
2806
  }
2807
+ /**
2808
+ * Allocate a FRESH prompt attempt/lineage for an allowed owned-completion
2809
+ * delivery (corrected turn semantics). The new turn gets a new attempt epoch
2810
+ * and an opaque lineage id; it never reuses the aborted attempt's epoch and
2811
+ * is not a retry/TTSR/steering/successor of the aborted turn. The caller
2812
+ * then invokes the existing followUp/prompt path.
2813
+ */
2814
+ #resumeFromOwnedCompletion(): void {
2815
+ // Allocate the fresh epoch from the SESSION prompt epoch, not the
2816
+ // module-global counter (root admissions never advance it, so after a
2817
+ // normal root prompt at epoch N the counter could still be <= N and mint
2818
+ // the SAME lineage as the aborted turn — review thread P1). A
2819
+ // session-relative +1 is always distinct from the current turn's epoch.
2820
+ const freshEpoch = this.#promptGeneration + 1;
2821
+ this.#promptGeneration = freshEpoch;
2822
+ this.#turnLineageIdHash = mintTurnLineageIdHash(
2823
+ this.sessionManager.getSessionId?.() ?? "local",
2824
+ freshEpoch,
2825
+ this.#terminalLineageSecret,
2826
+ );
2827
+ }
2828
+ /**
2829
+ * Whether a same-turn continuation of the current turn is blocked by a
2830
+ * terminal-abort fence. Fails open (false) when no terminal scope exists for
2831
+ * the current lineage+epoch, so ordinary sessions never consult a gate.
2832
+ * Post-close continuations (retry/TTSR/steering/hidden-next-turn/
2833
+ * maintenance/worker successor) are denied at the final synchronous boundary
2834
+ * before method entry; a continuation already linearized as a predecessor
2835
+ * before close stays allowed to finish.
2836
+ */
2837
+ #isTurnContinuationBlocked(): boolean {
2838
+ const lineageIdHash = this.#turnLineageIdHash;
2839
+ if (!lineageIdHash) return false;
2840
+ const scope = lookupTerminalScope(lineageIdHash, this.#promptGeneration);
2841
+ if (!scope) return false;
2842
+ return (
2843
+ scope.gate.authorizeContinuation({
2844
+ kind: "turn-continuation",
2845
+ lineageIdHash,
2846
+ attemptEpoch: this.#promptGeneration,
2847
+ continuationId: crypto.randomUUID(),
2848
+ }) === "deny"
2849
+ );
2850
+ }
2520
2851
 
2521
2852
  #isPromptPreflightCancelled(generation: number, signal: AbortSignal): boolean {
2522
2853
  return signal.aborted || this.#promptGeneration !== generation;
@@ -2550,6 +2881,17 @@ export class AgentSession {
2550
2881
  return hold;
2551
2882
  }
2552
2883
 
2884
+ #restoreAndReserveDeferredAgentEndForContinuation(pending: AgentSessionEvent | undefined): symbol | undefined {
2885
+ if (!pending) return undefined;
2886
+ if (this.#pendingAgentEndEmit && this.#pendingAgentEndEmit !== pending) {
2887
+ throw new Error("Cannot restore a deferred agent_end over a different pending terminal event");
2888
+ }
2889
+ this.#pendingAgentEndEmit = pending;
2890
+ const hold = Symbol("deferred-agent-end-continuation");
2891
+ this.#pendingAgentEndContinuationHolds.set(hold, pending);
2892
+ return hold;
2893
+ }
2894
+
2553
2895
  #claimDeferredAgentEndForContinuation(hold: symbol | undefined): AgentSessionEvent | undefined {
2554
2896
  if (!hold) return undefined;
2555
2897
  const pending = this.#pendingAgentEndContinuationHolds.get(hold);
@@ -2607,33 +2949,159 @@ export class AgentSession {
2607
2949
  }
2608
2950
  lease.closeDiscovery();
2609
2951
  }
2952
+ /**
2953
+ * Keep a continuation parked behind a pending selection fence observable to
2954
+ * external idle waits. The deferral window is bounded: it ends when the
2955
+ * deferred invocation synchronously re-reserves its settlement markers
2956
+ * inside the scheduler (`#endSelectionFenceDeferralTracking`); the
2957
+ * promise-settled release is only the safety net for a deferral whose
2958
+ * re-entry never ran (disposed/aborted session).
2959
+ *
2960
+ * The counter is keyed by the fence generation it parks behind. The
2961
+ * selection that owns that fence must not wait on its own parked
2962
+ * continuations: its mid-selection `waitForIdle` is exactly what lets an
2963
+ * inherited continuation settle before the durable mutation, and waiting on
2964
+ * a continuation parked behind the fence the selection itself holds would
2965
+ * reintroduce the selection self-deadlock (#4519).
2966
+ */
2967
+ #trackSelectionFenceDeferredContinuation(fenceGeneration: number, deferred: Promise<void>): void {
2968
+ this.#selectionFenceDeferredContinuations.set(
2969
+ fenceGeneration,
2970
+ (this.#selectionFenceDeferredContinuations.get(fenceGeneration) ?? 0) + 1,
2971
+ );
2972
+ void deferred
2973
+ .catch(() => {})
2974
+ .finally(() => {
2975
+ this.#endSelectionFenceDeferralTracking(fenceGeneration);
2976
+ });
2977
+ }
2978
+
2979
+ /**
2980
+ * End the deferral-limbo tracking window: called synchronously by the
2981
+ * deferred scheduling path once it has re-reserved its own settlement
2982
+ * markers (the predecessor hold and post-prompt task), so the counter no
2983
+ * longer covers the continuation's own execution.
2984
+ */
2985
+ #endSelectionFenceDeferralTracking(fenceGeneration: number): void {
2986
+ const pending = this.#selectionFenceDeferredContinuations.get(fenceGeneration) ?? 0;
2987
+ if (pending <= 0) return;
2988
+ if (pending === 1) this.#selectionFenceDeferredContinuations.delete(fenceGeneration);
2989
+ else this.#selectionFenceDeferredContinuations.set(fenceGeneration, pending - 1);
2990
+ this.#resolveSessionSettlement();
2991
+ }
2992
+
2993
+ /**
2994
+ * Continuations parked behind selection fences that idle waits must observe.
2995
+ *
2996
+ * Only deferrals whose fence has already settled are counted: their
2997
+ * deferred scheduling tail is (re-)entering within microtask time and the
2998
+ * session is not idle until it does. A deferral behind a still-pending
2999
+ * fence is invisible by construction — the fence owner's own completion may
3000
+ * causally depend on the very settlement the waiter would block on, so
3001
+ * counting it would reintroduce the selection self-deadlock (#4519). It
3002
+ * becomes observable the moment its fence settles, and either the
3003
+ * synchronous re-entry (`#endSelectionFenceDeferralTracking`) or the
3004
+ * promise-settled safety net clears it.
3005
+ */
3006
+ #pendingSelectionFenceDeferredContinuations(ignoreGenerationFrom?: number): number {
3007
+ let total = 0;
3008
+ for (const [generation, count] of this.#selectionFenceDeferredContinuations) {
3009
+ if (ignoreGenerationFrom !== undefined && generation >= ignoreGenerationFrom) continue;
3010
+ total += count;
3011
+ }
3012
+ return total;
3013
+ }
3014
+
3015
+ /**
3016
+ * Deferral generations that causally-upstream settlement work must ignore:
3017
+ * a deferral parked behind a still-pending selection fence cannot settle
3018
+ * before that fence resolves, and the fence owner's completion may itself
3019
+ * depend on this very settlement. Returns the oldest still-pending fence
3020
+ * generation, or undefined when no fence is pending.
3021
+ */
3022
+ #turnSettlementDeferralFloor(): number | undefined {
3023
+ return this.#pendingSelectionFences > 0 ? this.#oldestPendingSelectionFenceGeneration : undefined;
3024
+ }
2610
3025
 
2611
- #isSessionSettlementPending(): boolean {
3026
+ /**
3027
+ * Wait until every follow-up reservation minted before `ownEpoch` has been
3028
+ * released, so a later dispatch can never durably enqueue ahead of an
3029
+ * earlier reserved follow-up. Resolves immediately when no earlier
3030
+ * reservation remains.
3031
+ */
3032
+ async #waitForEarlierFollowUpReservations(
3033
+ ownEpoch: number,
3034
+ requesterSignal?: AbortSignal,
3035
+ admissionSignal?: AbortSignal,
3036
+ ): Promise<void> {
3037
+ while ([...this.#activeFollowUpReservationEpochs].some(epoch => epoch < ownEpoch)) {
3038
+ if (requesterSignal?.aborted || admissionSignal?.aborted) throw promptPreflightCancelledError();
3039
+ const drained = Promise.withResolvers<void>();
3040
+ this.#followUpReservationDrainWaiters.add(drained.resolve);
3041
+ await drained.promise;
3042
+ }
3043
+ }
3044
+
3045
+ #isSessionSettlementPending(ignoreSelectionFenceGeneration?: number): boolean {
2612
3046
  return (
2613
3047
  this.#promptInFlightCount > 0 ||
2614
3048
  this.#agentEventHandlersInFlight > 0 ||
2615
3049
  this.#agentEndPublicationInFlight > 0 ||
2616
3050
  this.#pendingAgentEndContinuationHolds.size > 0 ||
3051
+ this.#pendingSelectionFenceDeferredContinuations(ignoreSelectionFenceGeneration) > 0 ||
2617
3052
  this.#pendingAgentEndEmit !== undefined
2618
3053
  );
2619
3054
  }
2620
3055
 
2621
3056
  #resolveSessionSettlement(): void {
2622
- if (this.#isSessionSettlementPending() || !this.#sessionSettlementResolve) return;
3057
+ if (this.#isSessionSettlementPending() || !this.#sessionSettlementResolve) {
3058
+ this.#wakeScopedSettlementWaiters();
3059
+ return;
3060
+ }
2623
3061
  const resolve = this.#sessionSettlementResolve;
2624
3062
  this.#sessionSettlementResolve = undefined;
2625
3063
  this.#sessionSettlementPromise = undefined;
2626
3064
  resolve();
3065
+ this.#wakeScopedSettlementWaiters();
3066
+ }
3067
+ /** Re-evaluate every scoped settlement waiter (selection-internal drains). */
3068
+ #wakeScopedSettlementWaiters(): void {
3069
+ for (const check of [...this.#scopedSettlementWaiters]) check();
2627
3070
  }
2628
3071
 
2629
- async #waitForSessionSettlement(): Promise<void> {
2630
- while (this.#isSessionSettlementPending()) {
2631
- if (!this.#sessionSettlementPromise) {
2632
- const { promise, resolve } = Promise.withResolvers<void>();
2633
- this.#sessionSettlementPromise = promise;
2634
- this.#sessionSettlementResolve = resolve;
3072
+ /**
3073
+ * Wait for session settlement. `ignoreSelectionFenceGeneration` carries the
3074
+ * selection's own fence generation for its internal drain only (see
3075
+ * {@link waitForIdle}); external callers observe every unresolved
3076
+ * fence-deferred continuation.
3077
+ *
3078
+ * A scoped waiter registers a wake callback instead of sharing the strict
3079
+ * settlement promise: the strict promise may stay pending on a
3080
+ * fence-deferred continuation (correctly), and the selection that owns that
3081
+ * fence must not block behind it (#4519).
3082
+ */
3083
+ async #waitForSessionSettlement(ignoreSelectionFenceGeneration?: number): Promise<void> {
3084
+ while (this.#isSessionSettlementPending(ignoreSelectionFenceGeneration)) {
3085
+ if (ignoreSelectionFenceGeneration === undefined) {
3086
+ if (!this.#sessionSettlementPromise) {
3087
+ const { promise, resolve } = Promise.withResolvers<void>();
3088
+ this.#sessionSettlementPromise = promise;
3089
+ this.#sessionSettlementResolve = resolve;
3090
+ }
3091
+ await this.#sessionSettlementPromise;
3092
+ continue;
3093
+ }
3094
+ const wake = Promise.withResolvers<void>();
3095
+ const check = () => {
3096
+ if (!this.#isSessionSettlementPending(ignoreSelectionFenceGeneration)) wake.resolve();
3097
+ };
3098
+ this.#scopedSettlementWaiters.add(check);
3099
+ check();
3100
+ try {
3101
+ await wake.promise;
3102
+ } finally {
3103
+ this.#scopedSettlementWaiters.delete(check);
2635
3104
  }
2636
- await this.#sessionSettlementPromise;
2637
3105
  }
2638
3106
  }
2639
3107
 
@@ -2670,7 +3138,12 @@ export class AgentSession {
2670
3138
  // A post-prompt continuation runs before its predecessor prompt returns. Its
2671
3139
  // publication must settle, but session-wide settlement can still be owned by
2672
3140
  // that predecessor; waiting here would make each prompt wait on the other.
2673
- if (!predecessorPromptStillInFlight) await this.#waitForSessionSettlement();
3141
+ // Deferrals behind still-pending selection fences are excluded here: they
3142
+ // cannot settle before their fence resolves, and the fence owner may be
3143
+ // waiting on this very settlement (#4519).
3144
+ if (!predecessorPromptStillInFlight) {
3145
+ await this.#waitForSessionSettlement(this.#turnSettlementDeferralFloor());
3146
+ }
2674
3147
  if (flushError) throw flushError;
2675
3148
  }
2676
3149
 
@@ -2746,7 +3219,14 @@ export class AgentSession {
2746
3219
  // Persist before notifying synchronous subscribers: a subscriber may start a
2747
3220
  // successor prompt from agent_end, whose running state must serialize after
2748
3221
  // this terminal boundary rather than be overwritten by it.
2749
- void this.#persistRuntimeStateInBackground(pending);
3222
+ //
3223
+ // Awaited, not fire-and-forget: this is the terminal boundary, and the write
3224
+ // runs under the native identity-bound state-file lock. A detached write
3225
+ // outlives the session that owns it — under `bun test --isolate` the pending
3226
+ // unlink lands after the runtime has torn down the context it was scheduled in
3227
+ // and segfaults the process, and in production it lets a settled session's
3228
+ // last write race whatever reclaims its lock next.
3229
+ await this.#queueCoordinatorRuntimeStatePersist(pending);
2750
3230
  this.#emit(pending);
2751
3231
  extensionDelivery = this.#queueExtensionEvent(
2752
3232
  pending,
@@ -2768,9 +3248,31 @@ export class AgentSession {
2768
3248
 
2769
3249
  constructor(config: AgentSessionConfig) {
2770
3250
  this.agent = config.agent;
3251
+ this.agent.setProvisionalAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
3252
+ const contentIndex = assistantMessageEvent.contentIndex ?? 0;
3253
+ const block = message.content[contentIndex];
3254
+ if (block?.type === "toolCall" && block.id) this.#provisionalStreamingToolCallIds.add(block.id);
3255
+ if (
3256
+ assistantMessageEvent.type !== "toolcall_start" &&
3257
+ assistantMessageEvent.type !== "toolcall_delta" &&
3258
+ assistantMessageEvent.type !== "toolcall_end"
3259
+ ) {
3260
+ return;
3261
+ }
3262
+ const event: AgentEvent = {
3263
+ type: "message_update",
3264
+ message,
3265
+ assistantMessageEvent,
3266
+ };
3267
+ void this.#preCacheStreamingEditFile(event);
3268
+ if (assistantMessageEvent.type === "toolcall_delta" || assistantMessageEvent.type === "toolcall_end") {
3269
+ this.#maybeAbortStreamingEdit(event, this.#promptGeneration);
3270
+ }
3271
+ });
2771
3272
  this.agent.bindRunCancellationDomainBridge(this.#runCancellationDomains, this.#agentSessionClaimKey);
2772
3273
  this.sessionManager = config.sessionManager;
2773
3274
  this.settings = config.settings;
3275
+ this.#requestedAgentDir = config.agentDir ? path.resolve(config.agentDir) : undefined;
2774
3276
  this.sessionManager.setSessionMemoryMode(this.settings.get("sessionMemory.mode"));
2775
3277
  this.#unregisterSessionMemorySettings = this.settings.onChanged(settingPath => {
2776
3278
  if (settingPath === "sessionMemory.mode") {
@@ -2819,6 +3321,7 @@ export class AgentSession {
2819
3321
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
2820
3322
  this.#recoveryHydrationContext = config.recoveryHydrationContext;
2821
3323
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
3324
+ this.#disposeAsyncJobManager = config.disposeAsyncJobManager ?? true;
2822
3325
  this.#retainedMemorySampler = config.retainedMemorySampler;
2823
3326
  this.#ownedMcpManager = config.ownedMcpManager;
2824
3327
  this.#startupTurnBarrier = config.startupTurnBarrier;
@@ -2842,6 +3345,7 @@ export class AgentSession {
2842
3345
  this.#providerSessionState = config.providerSessionState;
2843
3346
  }
2844
3347
  this.#toolRegistry = config.toolRegistry ?? new Map();
3348
+ this.#builtinToolIdentities = new WeakSet(config.builtinToolIdentities ?? []);
2845
3349
  this.#workflowGateToolSession = config.workflowGateToolSession;
2846
3350
  this.#requestedToolNames = config.requestedToolNames;
2847
3351
  this.#transformContext = config.transformContext ?? (messages => messages);
@@ -2878,16 +3382,80 @@ export class AgentSession {
2878
3382
  this.#bindWorkflowGateEmitter();
2879
3383
  this.yieldQueue = new YieldQueue({
2880
3384
  isStreaming: () => this.isStreaming || this.#handoffTransitionActive,
2881
- injectStreaming: message => this.agent.followUp(message),
3385
+ injectStreaming: message => {
3386
+ // Mandated boundary comment (corrected turn semantics): turn-scope
3387
+ // abort blocks only deliveries whose origin is a continuation of the
3388
+ // aborted turn. Owned-completion deliveries from work deliberately
3389
+ // left running are intentionally allowed to resume the agent through
3390
+ // the normal followUp/prompt path and receive a fresh turn attempt.
3391
+ // A denied owned-completion entry (owned scope, forged tuple, or
3392
+ // missing scope) is DROPPED here — it must never call followUp/prompt.
3393
+ const action = ownedCompletionResumeAction(message);
3394
+ if (action === "drop") {
3395
+ // A denied owned-completion entry is terminal: settle its
3396
+ // registration before discarding it. An owned_unsettled abort
3397
+ // (e.g. a job missing the settlement grace) leaves the tuple in
3398
+ // the registry, and without this settlement repeated occurrences
3399
+ // would retain terminal registrations until the bounded registry
3400
+ // saturates and later owned aborts fail closed (review thread
3401
+ // P2).
3402
+ this.#settleDeliveredOwnedRegistrations([message]);
3403
+ return;
3404
+ }
3405
+ // Defer the fresh lineage allocation to the ACTUAL resume admission:
3406
+ // while another prompt is streaming, followUp only queues, so
3407
+ // mutating the session-wide epoch/lineage here would corrupt the
3408
+ // ACTIVE turn's lineage if it is terminal-aborted meanwhile (review
3409
+ // thread P2). The queued resume is admitted through
3410
+ // #promptWithMessage (resetRetryReplaySafety), which allocates the
3411
+ // fresh attempt epoch at turn start. The idle injector, which calls
3412
+ // agent.prompt directly, allocates right before admission.
3413
+ this.agent.followUp(message);
3414
+ },
2882
3415
  injectIdle: async messages => {
2883
- const first = messages[0];
3416
+ // Mandated boundary comment (corrected turn semantics): same origin
3417
+ // split as the streaming injector — an allowed owned-completion
3418
+ // delivery starts a fresh turn attempt/lineage and is not a
3419
+ // continuation of the aborted turn. Denied owned-completion entries
3420
+ // are dropped (mixed batches split before injection).
3421
+ const dropped = messages.filter(message => ownedCompletionResumeAction(message) === "drop");
3422
+ const survivors = messages.filter(message => ownedCompletionResumeAction(message) !== "drop");
3423
+ // Settle the dropped subset (owned scope, forged tuple, vanished
3424
+ // scope): the denied envelopes are terminal and their registrations
3425
+ // must not occupy the bounded registry (review thread P2).
3426
+ if (dropped.length > 0) this.#settleDeliveredOwnedRegistrations(dropped);
3427
+ const first = survivors[0];
2884
3428
  if (!first) return;
2885
3429
  await this.#awaitStartupTurnBarrier();
2886
3430
  if (this.#isDisposed) return;
2887
- if (messages.length === 1) {
2888
- await this.agent.prompt(first, this.#managedFallbackPromptOptions());
2889
- } else {
2890
- await this.agent.prompt(messages, this.#managedFallbackPromptOptions());
3431
+ // A user prompt may have started during the barrier/scheduling
3432
+ // delay: if the session is now streaming, mutating the epoch and
3433
+ // lineage here would corrupt the ACTIVE user turn (and
3434
+ // agent.prompt would then reject as busy, losing the drained
3435
+ // completion). Route the survivors through followUp — the
3436
+ // streaming injector's path — which allocates the fresh resume
3437
+ // lineage at actual admission (review thread P1).
3438
+ if (this.isStreaming) {
3439
+ for (const message of survivors) this.agent.followUp(message);
3440
+ return;
3441
+ }
3442
+ if (survivors.some(message => ownedCompletionResumeAction(message) === "fresh"))
3443
+ this.#resumeFromOwnedCompletion();
3444
+ try {
3445
+ if (survivors.length === 1) {
3446
+ await this.agent.prompt(first, this.#managedFallbackPromptOptions());
3447
+ } else {
3448
+ await this.agent.prompt(survivors, this.#managedFallbackPromptOptions());
3449
+ }
3450
+ } finally {
3451
+ // The owned completions were delivered OR the prompt attempt
3452
+ // failed (e.g. provider rejection): either way the yield
3453
+ // queue already drained the entries, so this is their only
3454
+ // delivery boundary — settle the registrations even on
3455
+ // failure, otherwise repeated failed idle resumptions leak
3456
+ // terminal tuples into the global registries until capacity
3457
+ // is exhausted (review thread P2).
3458
+ this.#settleDeliveredOwnedRegistrations(survivors);
2891
3459
  }
2892
3460
  },
2893
3461
  scheduleIdleFlush: run => {
@@ -2980,9 +3548,83 @@ export class AgentSession {
2980
3548
  this.#agentRegistry = config.agentRegistry;
2981
3549
  this.#providerSessionId = config.providerSessionId;
2982
3550
  this.#credentialSessionId = config.credentialSessionId;
3551
+ this.#credentialStoreIdentity = config.credentialStoreIdentity;
2983
3552
  this.#providerCacheSessionId = config.providerCacheSessionId;
3553
+ this.#asyncJobProviderSessionId = config.asyncJobProviderSessionId;
2984
3554
  // Per-tool TTSR reminders are folded into the matched tool's result via this hook.
2985
- this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
3555
+ this.agent.afterToolCall = ctx => {
3556
+ settleToolLineageRegistrationWindow(ctx.toolCall.id, this.#ownedRegistrationEndpoint());
3557
+ return this.#ttsrAfterToolCall(ctx);
3558
+ };
3559
+ // Bind immutable lineage/attempt metadata to each tool call id before the
3560
+ // tool executes. Background registrations made inside the tool (task, Bash)
3561
+ // read this binding synchronously so their completion can later be
3562
+ // classified as exact owned work instead of a turn continuation. Bindings
3563
+ // intentionally survive the tool call: resumed registrations re-use the
3564
+ // original tool call id and must retain the same owned-completion origin.
3565
+ // They are superseded by a rebind on the same id or by bounded eviction.
3566
+ this.agent.beforeToolCall = ctx => {
3567
+ const lineageIdHash = this.#turnLineageIdHash;
3568
+ if (lineageIdHash) {
3569
+ bindToolLineage(ctx.toolCall.id, {
3570
+ lineageIdHash,
3571
+ promptAttemptEpoch: this.#promptGeneration,
3572
+ endpointGeneration: this.#terminalEndpointGeneration,
3573
+ // Endpoint identity: the owned-registration registry is
3574
+ // process-global and job ids restart per manager, so
3575
+ // concurrent sessions must not collide (review thread P1).
3576
+ // Subagents inherit the parent's manager, so the key follows
3577
+ // the MANAGER's registered endpoint — the child's own
3578
+ // sessionManager id is never registered, and the inherited
3579
+ // manager's completion callback resolves registrations via
3580
+ // AsyncJobManager.endpointIdOf(manager) (review thread P1).
3581
+ endpointId: this.#ownedRegistrationEndpoint(),
3582
+ });
3583
+ }
3584
+ return undefined;
3585
+ };
3586
+ // A queued owned-completion follow-up is consumed by the agent loop
3587
+ // DIRECTLY (getFollowUpMessages), never through #promptWithMessage, so
3588
+ // the fresh attempt/lineage promised for the resume is allocated HERE at
3589
+ // actual resume admission — when the loop dequeues the follow-up for the
3590
+ // next turn, the previously streaming turn has ended, so mutating the
3591
+ // session-wide epoch/lineage is safe and its tools bind the fresh lineage.
3592
+ this.agent.onFollowUpConsumed = messages => {
3593
+ // A follow-up whose owned-completion origin is DENIED — an owned
3594
+ // scope landed after the result was queued, or the tuple is
3595
+ // forged/vanished-disabled — must NOT resume the agent: remove it
3596
+ // from the dequeued batch before the loop processes it (review
3597
+ // thread P1). This is the final consumption boundary for
3598
+ // follow-up-delivered owned completions.
3599
+ const dropped: AgentMessage[] = [];
3600
+ for (let i = messages.length - 1; i >= 0; i--) {
3601
+ if (ownedCompletionResumeAction(messages[i]) === "drop") {
3602
+ dropped.push(messages[i]);
3603
+ messages.splice(i, 1);
3604
+ }
3605
+ }
3606
+ // An allowed owned-completion resume allocates the fresh lineage at
3607
+ // actual admission (see the comment above).
3608
+ if (messages.some(message => ownedCompletionResumeAction(message) === "fresh")) {
3609
+ this.#resumeFromOwnedCompletion();
3610
+ }
3611
+ // Monitor task-notifications now carry the owned-completion envelope
3612
+ // (see tools/monitor.ts), so the general drop + fresh-admission paths
3613
+ // above cover them exactly like async results — no monitor-specific
3614
+ // filter is needed.
3615
+ // Every consumed envelope (delivered OR dropped) is settled: the
3616
+ // dropped ones are captured above so their now-terminal
3617
+ // registrations are retired too — otherwise an owned_unsettled
3618
+ // abort's later drop would keep occupying the global registration
3619
+ // and retained-policy capacities (review thread P2).
3620
+ this.#settleDeliveredOwnedRegistrations([...messages, ...dropped]);
3621
+ // Transfer SDK requester ownership correlation at the ACTUAL dequeue:
3622
+ // every follow-up consumed by this batch's run fires its hook — even
3623
+ // when the batch is drained by a continuation the message did not
3624
+ // schedule (a skipped continuation must never discard the correlation
3625
+ // of work that is still consumed; review thread P2).
3626
+ this.#fireQueuedPromotionHooks(messages);
3627
+ };
2986
3628
  this.agent.providerSessionState = this.#providerSessionState;
2987
3629
  this.#syncAgentSessionId();
2988
3630
  this.#removeEphemeralCustomMessages();
@@ -3639,7 +4281,12 @@ export class AgentSession {
3639
4281
  }
3640
4282
 
3641
4283
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
3642
- const manager = AsyncJobManager.instance();
4284
+ // The session's OWNED manager first: /jobs, the status header, and the
4285
+ // active-background count must reflect THIS session's jobs — with
4286
+ // concurrent top-level sessions the process-global instance belongs to
4287
+ // a different session and would report no running jobs for A or show
4288
+ // B's (review thread P2).
4289
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
3643
4290
  if (!manager) return null;
3644
4291
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
3645
4292
  const running = manager.getRunningJobs(ownerFilter).map(job => ({
@@ -3672,7 +4319,12 @@ export class AgentSession {
3672
4319
  */
3673
4320
  #cancelOwnAsyncJobs(): void {
3674
4321
  if (!this.#agentId) return;
3675
- const manager = AsyncJobManager.instance();
4322
+ // The session's OWNED manager first: with concurrent top-level sessions
4323
+ // the process-global instance may belong to a different session, and a
4324
+ // transition (newSession/fork/handoff/switch) must cancel THIS session's
4325
+ // jobs — otherwise the predecessor's stale completion reaches the
4326
+ // successor as ordinary work (review thread P1).
4327
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
3676
4328
  if (!manager) return;
3677
4329
  // Run owner cleanups first so cron timers (and any other owner-scoped
3678
4330
  // resource cleanup) cannot register fresh jobs while we tear down the
@@ -3683,7 +4335,7 @@ export class AgentSession {
3683
4335
 
3684
4336
  async #settleOwnAsyncJobsBeforeArtifactRetirement(): Promise<void> {
3685
4337
  const ownerId = this.#agentId;
3686
- const manager = AsyncJobManager.instance();
4338
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
3687
4339
  if (!ownerId || !manager) return;
3688
4340
  const lease = manager.beginOwnerSubagentShutdown(ownerId);
3689
4341
  if (!lease) throw new Error("Owned async cleanup is already in progress before artifact retirement.");
@@ -3707,6 +4359,7 @@ export class AgentSession {
3707
4359
  manager: AsyncJobManager,
3708
4360
  lease: OwnerSubagentShutdownLease,
3709
4361
  ownerId: string,
4362
+ predecessorEndpointId: string,
3710
4363
  ): void {
3711
4364
  const finalization = (async () => {
3712
4365
  while (!this.#isDisposed) {
@@ -3717,7 +4370,11 @@ export class AgentSession {
3717
4370
  if (!(await manager.waitForOwnerInFlightDeliveries(ownerId)))
3718
4371
  throw new Error("owner_delivery_settlement_timeout");
3719
4372
  if (!(await manager.cancelAndSettleOwnerJobs(ownerId))) throw new Error("owner_job_settlement_timeout");
3720
- if (this.#isDisposed) break;
4373
+ if (this.#isDisposed) {
4374
+ retireOwnedRegistrationsForEndpoint(predecessorEndpointId);
4375
+ break;
4376
+ }
4377
+ retireOwnedRegistrationsForEndpoint(predecessorEndpointId);
3721
4378
  this.sessionManager.retireEphemeralArtifactsAfterTransition();
3722
4379
  await this.#runToolSessionTransitionCleanups();
3723
4380
  manager.finishOwnerSubagentShutdown(lease, "commit");
@@ -3738,7 +4395,14 @@ export class AgentSession {
3738
4395
 
3739
4396
  #suppressOwnAsyncJobDeliveries(): void {
3740
4397
  if (!this.#agentId) return;
3741
- const manager = AsyncJobManager.instance();
4398
+ // The session's OWNED manager first: with concurrent top-level sessions
4399
+ // the process-global instance belongs to a different session, and a
4400
+ // transition (clearContext/handoff/switch) must acknowledge THIS
4401
+ // session's pending deliveries — otherwise an already-completed
4402
+ // delivery pending in A's manager is injected after A's context was
4403
+ // cleared while a same-owner delivery in B's manager is discarded
4404
+ // (review thread P2).
4405
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
3742
4406
  if (!manager) return;
3743
4407
  const pendingJobIds = manager.getDeliveryState({ ownerId: this.#agentId }).pendingJobIds;
3744
4408
  if (pendingJobIds.length > 0) {
@@ -3808,7 +4472,78 @@ export class AgentSession {
3808
4472
  return queued;
3809
4473
  }
3810
4474
 
4475
+ /**
4476
+ * Immutable coordinator observations, keyed by the exact agent event object they were
4477
+ * taken for.
4478
+ *
4479
+ * Weak because an observation only matters for an event that is eventually published:
4480
+ * a maintenance checkpoint, a rejected terminal, and a superseded run's late event are
4481
+ * observed here but never emitted, and must not be persisted merely because they were
4482
+ * seen.
4483
+ */
4484
+ #coordinatorToolObservations = new WeakMap<object, CoordinatorToolObservation>();
4485
+
4486
+ /**
4487
+ * Capture what is true at the SYNCHRONOUS agent-event boundary, before any async work.
4488
+ *
4489
+ * The label comes from the tool OBJECT the producer bound to this exact event when it
4490
+ * dispatched the call — never from re-resolving `event.toolName` here. `agent.state.tools`
4491
+ * is mutable: a tool refresh or MCP reload between dispatch and this observation would
4492
+ * otherwise attribute the call to whatever happens to hold its wire name now, publishing
4493
+ * a built-in label for a colliding custom tool that actually ran, or the reverse.
4494
+ *
4495
+ * The clock is captured here for the same reason: it keeps moving while subscribers run.
4496
+ *
4497
+ * A tool END is deliberately NOT labelled: its label comes from the correlation its own
4498
+ * START recorded, so one call cannot open under one label and close under another.
4499
+ *
4500
+ * A pairing-only event is not observed at all. The loop emits a start/end pair for a
4501
+ * call it skipped or aborted before dispatch so the stream keeps its shape, but this
4502
+ * snapshot is a claim about what is RUNNING — and nothing ran. Observing it would put a
4503
+ * tool into `active_tools` for the window between the two synthetic writes.
4504
+ */
4505
+ #observeCoordinatorToolEvent(event: AgentEvent): void {
4506
+ if (event.type !== "tool_execution_start" && event.type !== "tool_execution_end") return;
4507
+ if (isNonDispatchedToolEvent(event)) return;
4508
+ const label =
4509
+ event.type === "tool_execution_start"
4510
+ ? canonicalCoordinatorToolLabel(dispatchedToolIdentity(event), tool =>
4511
+ this.#builtinToolIdentities.has(tool),
4512
+ )
4513
+ : UNPROVEN_TOOL_LABEL;
4514
+ this.#coordinatorToolObservations.set(event, Object.freeze({ label, observedAt: new Date().toISOString() }));
4515
+ }
4516
+
4517
+ #canonicalMessageAdmissionTail: CanonicalMessageAdmissionSlot = { promise: Promise.resolve(), released: true };
4518
+
4519
+ #reserveCanonicalMessageAdmission(event: AgentEvent): CanonicalMessageAdmission | undefined {
4520
+ if (event.type !== "message_end") return undefined;
4521
+ const predecessor = this.#canonicalMessageAdmissionTail;
4522
+ const settled = Promise.withResolvers<void>();
4523
+ const slot: CanonicalMessageAdmissionSlot = { promise: settled.promise, released: false };
4524
+ let released = false;
4525
+ const release = () => {
4526
+ if (released) return;
4527
+ released = true;
4528
+ slot.released = true;
4529
+ settled.resolve();
4530
+ };
4531
+ // The reservation is owned by this emission's handler: keying it by the
4532
+ // event object would let a replayed/bridged duplicate emission overwrite
4533
+ // it and leave the first handler awaiting a promise only its own handler
4534
+ // will ever release.
4535
+ this.#canonicalMessageAdmissionTail = slot;
4536
+ return { predecessor, release };
4537
+ }
4538
+
3811
4539
  #trackAgentEvent = (event: AgentEvent): Promise<void> => {
4540
+ // First statement of the listener: the observation must precede every claim,
4541
+ // reservation, and async hop this handler performs.
4542
+ this.#observeCoordinatorToolEvent(event);
4543
+ // Reserve canonical message order synchronously. Agent listeners are not
4544
+ // awaited, so a tool-result spill may yield while a later continuation
4545
+ // otherwise overtakes it in persisted/display context.
4546
+ const canonicalAdmission = this.#reserveCanonicalMessageAdmission(event);
3812
4547
  const terminalOwner = event.type === "agent_end" ? getAgentTerminalOwnerContext(event) : undefined;
3813
4548
  const maintenanceCheckpoint =
3814
4549
  event.type === "agent_end" && event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted";
@@ -3862,14 +4597,15 @@ export class AgentSession {
3862
4597
  this.#postPromptLeases.set(eventLease.resourceRunId, eventLease);
3863
4598
  if (eventLease) {
3864
4599
  await this.#runResourceLeaseContext.run(eventLease, () =>
3865
- this.#handleAgentEvent(event, activePromptHandle),
4600
+ this.#handleAgentEvent(event, activePromptHandle, canonicalAdmission),
3866
4601
  );
3867
4602
  } else {
3868
- await this.#handleAgentEvent(event, activePromptHandle);
4603
+ await this.#handleAgentEvent(event, activePromptHandle, canonicalAdmission);
3869
4604
  }
3870
4605
  } catch (error) {
3871
4606
  logger.warn("Agent event handler failed", { event: event.type, error: String(error) });
3872
4607
  } finally {
4608
+ canonicalAdmission?.release();
3873
4609
  if (eventLease) {
3874
4610
  const pendingAgentEnd =
3875
4611
  event.type === "agent_end" && !maintenanceCheckpoint && this.#pendingAgentEndEmit === event
@@ -3892,13 +4628,43 @@ export class AgentSession {
3892
4628
  return handler;
3893
4629
  };
3894
4630
 
3895
- async #persistRuntimeStateInBackground(event: AgentSessionEvent): Promise<void> {
4631
+ /** Serializes sidecar writes in publication order, independent of write latency. */
4632
+ #coordinatorPersistQueue: Promise<void> = Promise.resolve();
4633
+
4634
+ /**
4635
+ * Reserve this event's place in the sidecar write order.
4636
+ *
4637
+ * Called immediately BEFORE local delivery so a synchronous subscriber that re-enters
4638
+ * the session cannot get its own, later event persisted first. The returned promise is
4639
+ * only awaited where durability must precede delivery (terminal `agent_end`).
4640
+ *
4641
+ * A pairing-only tool event takes no place in that order at all — neither its start nor
4642
+ * its end. It describes a call that was never dispatched, and this file is read as the
4643
+ * answer to "what is this session doing right now".
4644
+ */
4645
+ #queueCoordinatorRuntimeStatePersist(event: AgentSessionEvent): Promise<void> {
4646
+ if (isNonDispatchedToolEvent(event)) return Promise.resolve();
4647
+ const observation = this.#coordinatorToolObservations.get(event);
4648
+ const run = () => this.#persistRuntimeStateInBackground(event, observation);
4649
+ const queued = this.#coordinatorPersistQueue.then(run, run);
4650
+ this.#coordinatorPersistQueue = queued.catch(() => {});
4651
+ return queued;
4652
+ }
4653
+
4654
+ async #persistRuntimeStateInBackground(
4655
+ event: AgentSessionEvent,
4656
+ observation: CoordinatorToolObservation | undefined,
4657
+ ): Promise<void> {
3896
4658
  try {
3897
- await persistCoordinatorRuntimeStateFromEvent(event, {
3898
- sessionId: this.sessionId,
3899
- cwd: this.sessionManager.getCwd(),
3900
- sessionFile: this.sessionManager.getSessionFile(),
3901
- });
4659
+ await persistCoordinatorRuntimeStateFromEvent(
4660
+ event,
4661
+ {
4662
+ sessionId: this.sessionId,
4663
+ cwd: this.sessionManager.getCwd(),
4664
+ sessionFile: this.sessionManager.getSessionFile(),
4665
+ },
4666
+ observation,
4667
+ );
3902
4668
  } catch {
3903
4669
  logger.warn("Failed to persist coordinator runtime state", { event: event.type });
3904
4670
  }
@@ -3946,7 +4712,6 @@ export class AgentSession {
3946
4712
  if (event.type === "agent_end" && event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted")
3947
4713
  return;
3948
4714
 
3949
- const persistRuntimeState = () => this.#persistRuntimeStateInBackground(event);
3950
4715
  // Hold agent_end until the prompt's finally and all earlier async event work
3951
4716
  // have unwound. Subscribers treat this event as the ready signal; flushing it
3952
4717
  // from abort while either barrier is active permits a successor to race the
@@ -3959,7 +4724,7 @@ export class AgentSession {
3959
4724
  if (event.type === "agent_end") {
3960
4725
  // Start the durable terminal write before synchronous subscribers can
3961
4726
  // re-enter prompt(), so a successor's running transition serializes after it.
3962
- await persistRuntimeState();
4727
+ await this.#queueCoordinatorRuntimeStatePersist(event);
3963
4728
  this.#emit(event);
3964
4729
  await this.#emitExtensionEvent(event);
3965
4730
  return;
@@ -3969,13 +4734,22 @@ export class AgentSession {
3969
4734
  // auto-continuation gates, goal reminders, and tests all observe these events
3970
4735
  // synchronously. Coordinator sidecar writes and extension hooks are secondary
3971
4736
  // sinks, so they must not delay or suppress local delivery.
4737
+ //
4738
+ // The sidecar write is ENQUEUED before delivery and awaited by nobody: a
4739
+ // synchronous subscriber that re-enters the session and emits its own event still
4740
+ // lands behind this one, while nothing about that subscriber's latency reaches the
4741
+ // snapshot.
4742
+ void this.#queueCoordinatorRuntimeStatePersist(event);
3972
4743
  this.#emit(event);
3973
- void persistRuntimeState();
3974
4744
  await this.#emitExtensionEvent(event);
3975
4745
  }
3976
4746
 
3977
4747
  // Track last assistant message for auto-compaction check
3978
4748
  #lastAssistantMessage: AssistantMessage | undefined = undefined;
4749
+ // Admission slot of the last message_end per assistant message, so the
4750
+ // agent_end handler can join the terminal's canonical admission before any
4751
+ // post-turn write reaches the branch.
4752
+ #lastAssistantAdmissionByMessage = new WeakMap<AssistantMessage, CanonicalMessageAdmission | undefined>();
3979
4753
  // Provider context construction must wait for this chain. Agent event listeners
3980
4754
  // are synchronous dispatch only; their async work cannot otherwise gate the
3981
4755
  // next tool-result provider request.
@@ -4043,7 +4817,11 @@ export class AgentSession {
4043
4817
  }
4044
4818
 
4045
4819
  /** Internal handler for agent events - shared by subscribe and reconnect */
4046
- #handleAgentEvent = async (event: AgentEvent, activePromptHandle?: string): Promise<void> => {
4820
+ #handleAgentEvent = async (
4821
+ event: AgentEvent,
4822
+ activePromptHandle?: string,
4823
+ canonicalAdmission?: CanonicalMessageAdmission,
4824
+ ): Promise<void> => {
4047
4825
  const attemptScope = (event as AgentEvent & { scope?: AttemptScope }).scope;
4048
4826
 
4049
4827
  if (
@@ -4190,10 +4968,31 @@ export class AgentSession {
4190
4968
  this.#silentAbortPending = false;
4191
4969
  }
4192
4970
 
4193
- // Canonical persistence must happen synchronously before listener work can
4194
- // await: the EventStream FIFO drain then guarantees tool results and every
4195
- // steering message are in the branch before a maintenance rewrite starts.
4971
+ // Canonical persistence follows synchronous message_end reservation order.
4972
+ // Only the admission predecessor and this event's own pre-admission work are
4973
+ // inside the lane; release before extension delivery and unrelated post-work.
4974
+ // An already-released predecessor must not cost a microtask: external emitters
4975
+ // and tests rely on canonical append being visible synchronously after
4976
+ // emitExternalEvent returns whenever no admission is actually contended.
4977
+ // Track the terminal assistant synchronously, before any admission wait:
4978
+ // externally emitted terminals (host bridges, replays, tests) dispatch
4979
+ // agent_end immediately after message_end, and the agent_end handler's
4980
+ // post-turn read must see THIS stop even when this admission is still
4981
+ // parked behind a contended predecessor — otherwise post-turn logic
4982
+ // (deep-interview continuation, compaction, retry classification) runs
4983
+ // against the previous turn's assistant. The per-message admission slot
4984
+ // also lets agent_end processing wait for this admission to finish, so
4985
+ // post-turn writes (continuation reminders, compaction rewrites) never
4986
+ // reorder ahead of the branch entries they respond to.
4987
+ if (event.type === "message_end" && event.message.role === "assistant") {
4988
+ this.#lastAssistantMessage = event.message;
4989
+ this.#lastAssistantAdmissionByMessage.set(event.message, canonicalAdmission);
4990
+ }
4991
+
4196
4992
  if (event.type === "message_end") {
4993
+ if (canonicalAdmission && !canonicalAdmission.predecessor.released) {
4994
+ await canonicalAdmission.predecessor.promise;
4995
+ }
4197
4996
  if (
4198
4997
  (event.message.role === "hookMessage" || event.message.role === "custom") &&
4199
4998
  !(event.message.role === "custom" && event.message.customType === "hindsight-recall")
@@ -4224,6 +5023,47 @@ export class AgentSession {
4224
5023
  try {
4225
5024
  this.sessionManager.appendMessage(event.message);
4226
5025
  } catch (error) {
5026
+ // Typed near-limit append (#4566): the transcript hit the managed
5027
+ // per-file cap and even the live-entry rewrite could not hold this
5028
+ // entry. The edit/effect itself may already be committed; surface a
5029
+ // structured tool-result outcome stating that and the exact
5030
+ // continuation path instead of a generic fatal abort. The typed
5031
+ // error already performed deterministic recovery, so the session is
5032
+ // not poisoned and the turn ends with actionable state.
5033
+ if (error instanceof SessionNearLimitAppendError) {
5034
+ this.agent.abort();
5035
+ if (event.message.role === "toolResult") {
5036
+ event.message.isError = true;
5037
+ const committed = error.entryRetained
5038
+ ? "The edit committed and its receipt is retained in the live session; it will persist on the next successful write."
5039
+ : "The edit committed on disk but its receipt could not be retained in the live session.";
5040
+ event.message.content = [
5041
+ {
5042
+ type: "text",
5043
+ text: [
5044
+ "Session transcript reached the managed per-file limit; this result could not be recorded durably.",
5045
+ committed,
5046
+ "Continue by compacting the session (`/compact`) or exporting to a fresh session (`gjc export <session-file>`); re-verify the edited file before relying on it.",
5047
+ ].join("\n"),
5048
+ },
5049
+ ];
5050
+ event.message.details = {
5051
+ ...(event.message.details && typeof event.message.details === "object"
5052
+ ? event.message.details
5053
+ : {}),
5054
+ failureKind: "persistence",
5055
+ nearLimitAppend: {
5056
+ code: error.code,
5057
+ entryBytes: error.entryBytes,
5058
+ liveBytes: error.liveBytes,
5059
+ capBytes: error.capBytes,
5060
+ entryRetained: error.entryRetained,
5061
+ },
5062
+ };
5063
+ this.agent.touchContext();
5064
+ }
5065
+ return;
5066
+ }
4227
5067
  if (
4228
5068
  event.message.role !== "toolResult" ||
4229
5069
  event.message.toolName !== "todo_write" ||
@@ -4289,6 +5129,7 @@ export class AgentSession {
4289
5129
  }
4290
5130
  }
4291
5131
  }
5132
+ canonicalAdmission?.release();
4292
5133
  }
4293
5134
 
4294
5135
  // Deobfuscate assistant message content for display emission — the LLM echoes back
@@ -4486,6 +5327,7 @@ export class AgentSession {
4486
5327
 
4487
5328
  if (
4488
5329
  event.type === "message_update" &&
5330
+ !this.#isProvisionalStreamingToolEvent(event) &&
4489
5331
  (event.assistantMessageEvent.type === "toolcall_start" ||
4490
5332
  event.assistantMessageEvent.type === "toolcall_delta" ||
4491
5333
  event.assistantMessageEvent.type === "toolcall_end")
@@ -4495,6 +5337,7 @@ export class AgentSession {
4495
5337
 
4496
5338
  if (
4497
5339
  event.type === "message_update" &&
5340
+ !this.#isProvisionalStreamingToolEvent(event) &&
4498
5341
  (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
4499
5342
  ) {
4500
5343
  this.#maybeAbortStreamingEdit(event, this.#promptGeneration);
@@ -4506,9 +5349,9 @@ export class AgentSession {
4506
5349
  this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
4507
5350
  }
4508
5351
 
4509
- // Track assistant message for auto-compaction (checked on agent_end)
5352
+ // (#lastAssistantMessage is captured synchronously before the admission
5353
+ // wait above; the block below handles assistant side effects only.)
4510
5354
  if (event.message.role === "assistant") {
4511
- this.#lastAssistantMessage = event.message;
4512
5355
  const assistantMsg = event.message as AssistantMessage;
4513
5356
  const currentGrantsAnthropicPriority =
4514
5357
  this.serviceTier === "priority" || this.serviceTier === "claude-only";
@@ -4558,6 +5401,9 @@ export class AgentSession {
4558
5401
  // #resolveRetry() is idempotent, so the later tail call is a no-op.
4559
5402
  this.#resolveRetry();
4560
5403
  }
5404
+ if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") {
5405
+ this.#providerRetryMaxAttempts = undefined;
5406
+ }
4561
5407
  }
4562
5408
 
4563
5409
  if (event.message.role === "toolResult") {
@@ -4631,6 +5477,13 @@ export class AgentSession {
4631
5477
  }
4632
5478
  }
4633
5479
 
5480
+ if (
5481
+ event.type === "agent_end" &&
5482
+ !(event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted")
5483
+ ) {
5484
+ this.#releaseDeferredSdkFollowUps();
5485
+ }
5486
+
4634
5487
  // Check auto-retry and auto-compaction after agent completes
4635
5488
  if (event.type === "agent_end") {
4636
5489
  // Cooperative mid-run maintenance interruption (issue #2035). The loop
@@ -4685,6 +5538,16 @@ export class AgentSession {
4685
5538
  .find((message): message is AssistantMessage => message.role === "assistant");
4686
5539
  const msg = this.#lastAssistantMessage ?? fallbackAssistant;
4687
5540
  this.#lastAssistantMessage = undefined;
5541
+ // Join the terminal's canonical admission before any post-turn write:
5542
+ // an externally emitted terminal dispatches agent_end while its own
5543
+ // admission may still be parked behind a contended predecessor, and a
5544
+ // continuation reminder or compaction rewrite that runs first would
5545
+ // persist ahead of the branch entries it responds to.
5546
+ const terminalAdmission = msg ? this.#lastAssistantAdmissionByMessage.get(msg) : undefined;
5547
+ if (msg) this.#lastAssistantAdmissionByMessage.delete(msg);
5548
+ if (terminalAdmission && !terminalAdmission.predecessor.released) {
5549
+ await terminalAdmission.predecessor.promise;
5550
+ }
4688
5551
  if (!msg) {
4689
5552
  this.#lastSuccessfulYieldToolCallId = undefined;
4690
5553
  this.#resolveRetry();
@@ -4813,8 +5676,14 @@ export class AgentSession {
4813
5676
  this.#postPromptTasksPromise = undefined;
4814
5677
  }
4815
5678
 
4816
- #trackPostPromptTask(task: Promise<void>, lease?: RunResourceProducerLease, leaseTask: Promise<void> = task): void {
5679
+ #trackPostPromptTask(
5680
+ task: Promise<void>,
5681
+ selectionFenceGeneration: number,
5682
+ lease?: RunResourceProducerLease,
5683
+ leaseTask: Promise<void> = task,
5684
+ ): void {
4817
5685
  this.#postPromptTasks.add(task);
5686
+ this.#postPromptTaskSelectionFenceGenerations.set(task, selectionFenceGeneration);
4818
5687
  this.#ensurePostPromptTasksPromise();
4819
5688
  if (lease) {
4820
5689
  lease.track("post_prompt", "agent-session", leaseTask);
@@ -4824,6 +5693,7 @@ export class AgentSession {
4824
5693
  .catch(() => {})
4825
5694
  .finally(() => {
4826
5695
  this.#postPromptTasks.delete(task);
5696
+ this.#postPromptTaskSelectionFenceGenerations.delete(task);
4827
5697
  if (this.#postPromptTasks.size === 0) this.#resolvePostPromptTasks();
4828
5698
  });
4829
5699
  }
@@ -4836,8 +5706,14 @@ export class AgentSession {
4836
5706
  onSkip?: () => void;
4837
5707
  resourceRunId?: string;
4838
5708
  leaseTask?: Promise<void>;
5709
+ selectionFenceGeneration?: number;
4839
5710
  },
4840
5711
  ): Promise<void> {
5712
+ const selectionFenceGeneration =
5713
+ options?.selectionFenceGeneration ??
5714
+ this.#selectionFenceGenerationContext.getStore() ??
5715
+ this.#sessionAdmissionContext.getStore()?.selectionFenceGeneration ??
5716
+ this.#selectionFenceGeneration;
4841
5717
  const delayMs = options?.delayMs ?? 0;
4842
5718
  const resourceRunId = options?.resourceRunId;
4843
5719
  const contextualLease = this.#runResourceLeaseContext.getStore();
@@ -4875,12 +5751,17 @@ export class AgentSession {
4875
5751
  options.onSkip?.();
4876
5752
  return;
4877
5753
  }
4878
- await task(signal);
5754
+ await this.#selectionFenceGenerationContext.run(selectionFenceGeneration, () => task(signal));
4879
5755
  };
4880
5756
  const scheduled = reservation?.ok
4881
5757
  ? this.#runResourceLeaseContext.run(reservation.lease, runScheduled)
4882
5758
  : runScheduled();
4883
- this.#trackPostPromptTask(scheduled, reservation?.ok ? reservation.lease : undefined, options?.leaseTask);
5759
+ this.#trackPostPromptTask(
5760
+ scheduled,
5761
+ selectionFenceGeneration,
5762
+ reservation?.ok ? reservation.lease : undefined,
5763
+ options?.leaseTask,
5764
+ );
4884
5765
  return scheduled;
4885
5766
  }
4886
5767
 
@@ -4894,9 +5775,10 @@ export class AgentSession {
4894
5775
  skipCompactionCheck?: boolean;
4895
5776
  suppressPredecessorAgentEnd?: boolean;
4896
5777
  shouldContinue?: () => boolean;
4897
- onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => void;
5778
+ onSkip?: (
5779
+ reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn",
5780
+ ) => void;
4898
5781
  allowDuringCancelAndSubmit?: boolean;
4899
- rescheduleOnBusy?: boolean;
4900
5782
  onError?: (error: unknown) => void;
4901
5783
  resourceRunId?: string;
4902
5784
  maintenanceContinuation?: boolean;
@@ -4905,14 +5787,63 @@ export class AgentSession {
4905
5787
  // queued turn without replaying the non-assistant tail. Every other
4906
5788
  // scheduled continuation keeps continue() semantics.
4907
5789
  continueQueuedOnly?: boolean;
5790
+ /** Reschedule with capped exponential backoff when the continuation hits AgentBusyError. */
5791
+ rescheduleOnBusy?: boolean;
5792
+ /** Called when the scheduled continuation accepts its run (before agent_start). */
5793
+ onRunAccepted?: (handle: AttemptRunHandle) => void;
5794
+ /** Internal causal generation retained when a later continuation is deferred behind selection. */
5795
+ selectionFenceGeneration?: number;
5796
+ /** Internal predecessor publication hold retained across selection-fence deferral. */
5797
+ predecessorAgentEndHold?: symbol;
5798
+ /** Internal predecessor terminal event sequestered while waiting behind selection. */
5799
+ deferredPredecessorAgentEnd?: AgentSessionEvent;
4908
5800
  }): Promise<void> {
4909
- const predecessorAgentEndHold = options?.suppressPredecessorAgentEnd
4910
- ? this.#reserveDeferredAgentEndForContinuation()
4911
- : undefined;
4912
- let terminalized = false;
4913
- const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => {
4914
- if (terminalized) return;
4915
- terminalized = true;
5801
+ const continuationAdmission = this.#captureScheduledContinuationAdmission();
5802
+ const selectionFenceGeneration =
5803
+ options?.selectionFenceGeneration ??
5804
+ this.#selectionFenceGenerationContext.getStore() ??
5805
+ this.#sessionAdmissionContext.getStore()?.selectionFenceGeneration ??
5806
+ continuationAdmission?.entry.selectionFenceGeneration ??
5807
+ this.#selectionFenceGeneration;
5808
+ if (this.#pendingSelectionFences > 0 && selectionFenceGeneration === this.#selectionFenceGeneration) {
5809
+ const deferredPredecessorAgentEnd =
5810
+ options?.deferredPredecessorAgentEnd ??
5811
+ (options?.suppressPredecessorAgentEnd
5812
+ ? this.#claimDeferredAgentEndForContinuation(this.#reserveDeferredAgentEndForContinuation())
5813
+ : undefined);
5814
+ const precedingSelectionFence = this.#selectionFenceTail;
5815
+ const deferredPromptGeneration = options?.generation ?? this.#promptGeneration;
5816
+ const deferredScheduling = precedingSelectionFence.then(() => {
5817
+ try {
5818
+ this.#scheduleAgentContinue({
5819
+ ...options,
5820
+ generation: deferredPromptGeneration,
5821
+ selectionFenceGeneration,
5822
+ deferredPredecessorAgentEnd,
5823
+ });
5824
+ } finally {
5825
+ // The recursive call synchronously re-reserved its settlement
5826
+ // markers (predecessor hold / post-prompt task); end the limbo
5827
+ // window so the counter never spans the continuation run itself.
5828
+ this.#endSelectionFenceDeferralTracking(selectionFenceGeneration);
5829
+ }
5830
+ });
5831
+ this.#trackSelectionFenceDeferredContinuation(selectionFenceGeneration, deferredScheduling);
5832
+ return Promise.resolve();
5833
+ }
5834
+ const predecessorAgentEndHold =
5835
+ options?.predecessorAgentEndHold ??
5836
+ (options?.deferredPredecessorAgentEnd
5837
+ ? this.#restoreAndReserveDeferredAgentEndForContinuation(options.deferredPredecessorAgentEnd)
5838
+ : options?.suppressPredecessorAgentEnd
5839
+ ? this.#reserveDeferredAgentEndForContinuation()
5840
+ : undefined);
5841
+ let terminalized = false;
5842
+ const skip = (
5843
+ reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress" | "terminal_turn",
5844
+ ) => {
5845
+ if (terminalized) return;
5846
+ terminalized = true;
4916
5847
  this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
4917
5848
  options?.onSkip?.(reason);
4918
5849
  };
@@ -4932,126 +5863,161 @@ export class AgentSession {
4932
5863
  const settleLease = () => leaseSettlement.resolve();
4933
5864
  return this.#schedulePostPromptTask(
4934
5865
  async scheduledSignal => {
4935
- const canContinue = (): boolean => {
4936
- if (scheduledSignal.aborted || this.#isDisposed) {
4937
- skip("aborted_signal");
4938
- return false;
4939
- }
4940
- if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4941
- skip("generation_changed");
4942
- return false;
4943
- }
4944
- if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) {
4945
- skip("queue_drained");
4946
- return false;
4947
- }
4948
- if (options?.shouldContinue && !options.shouldContinue()) {
4949
- skip("queue_drained");
4950
- return false;
4951
- }
4952
- return true;
4953
- };
4954
- if (!canContinue()) {
4955
- settleLease();
4956
- return;
4957
- }
4958
- await this.#awaitStartupTurnBarrier();
4959
- if (!canContinue()) {
4960
- settleLease();
4961
- return;
4962
- }
4963
5866
  try {
4964
- if (!options?.skipCompactionCheck) {
4965
- await this.#checkEstimatedContextBeforePrompt();
4966
- if (!canContinue()) return;
4967
- }
4968
- if (scheduledSignal.aborted) {
4969
- skip("aborted_signal");
4970
- return;
4971
- }
4972
- if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4973
- skip("generation_changed");
4974
- return;
4975
- }
4976
- if (options?.shouldContinue && !options.shouldContinue()) {
4977
- skip("queue_drained");
4978
- return;
4979
- }
4980
- // A continuation scheduled before a handoff engaged must not start a
4981
- // turn against the session being handed off (or the restored
4982
- // predecessor). rearmIdle / normal delivery resumes after the fence.
4983
- if (this.#handoffTransitionActive) {
4984
- skip("handoff_in_progress");
4985
- return;
4986
- }
4987
- const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
4988
- let predecessorAccepted = false;
4989
- const releasePredecessor = () => {
4990
- if (predecessorAccepted) return;
4991
- predecessorAccepted = true;
4992
- this.#releaseDeferredAgentEndLease(predecessorAgentEnd);
4993
- };
4994
- const hasQueuedMessages = this.agent.hasQueuedMessages();
4995
- const startsQueuedSuccessor =
4996
- hasQueuedMessages &&
4997
- (options?.continueQueuedOnly === true || this.agent.state.messages.at(-1)?.role === "assistant");
4998
- const continueQueued = options?.continueQueuedOnly
4999
- ? this.agent.continueQueuedMessages.bind(this.agent)
5000
- : this.agent.continue.bind(this.agent);
5001
- try {
5002
- await continueQueued({
5003
- ...this.#managedFallbackPromptOptions(),
5004
- maintenanceContinuation: options?.maintenanceContinuation,
5005
- // Reset only after continue() has claimed the queued turn. Skipped or stale
5006
- // continuations retain predecessor accounting, and resetAttemptBudget keeps
5007
- // the sticky fallback cursor unchanged.
5008
- onRunAccepted: (handle: AttemptRunHandle) => {
5009
- this.#acceptRunHandle(handle);
5010
- settleLease();
5011
- releasePredecessor();
5012
- if (startsQueuedSuccessor) {
5013
- this.#defaultFallbackChain().resetAttemptBudget();
5014
- this.#overflowMaintenanceAttempts = 0;
5867
+ await this.#withSessionAdmission(
5868
+ "prompt",
5869
+ async () => {
5870
+ const canContinue = (): boolean => {
5871
+ if (scheduledSignal.aborted || this.#isDisposed) {
5872
+ skip("aborted_signal");
5873
+ return false;
5015
5874
  }
5016
- },
5017
- });
5018
- releasePredecessor();
5019
- } catch (error) {
5020
- if (
5021
- options?.rescheduleOnBusy &&
5022
- this.#isAgentBusyError(error) &&
5023
- !predecessorAccepted &&
5024
- !terminalized &&
5025
- canContinue()
5026
- ) {
5027
- if (busyReschedules < AGENT_CONTINUE_BUSY_MAX_RESCHEDULES) {
5028
- busyReschedules += 1;
5029
- const nextDelayMs = agentContinueBusyRescheduleDelayMs(busyReschedules);
5030
- this.#retainDeferredAgentEndAfterContinuationBusy(
5031
- predecessorAgentEndHold,
5032
- predecessorAgentEnd,
5033
- );
5034
- logger.debug("agent.continue busy after scheduling; rescheduling", {
5035
- error: error.message,
5036
- attempt: busyReschedules,
5037
- delayMs: nextDelayMs,
5038
- });
5039
- void scheduleAttempt(nextDelayMs);
5875
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
5876
+ skip("generation_changed");
5877
+ return false;
5878
+ }
5879
+ if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) {
5880
+ skip("queue_drained");
5881
+ return false;
5882
+ }
5883
+ if (options?.shouldContinue && !options.shouldContinue()) {
5884
+ skip("queue_drained");
5885
+ return false;
5886
+ }
5887
+ return true;
5888
+ };
5889
+ if (!canContinue()) {
5890
+ settleLease();
5040
5891
  return;
5041
5892
  }
5042
- logger.warn("agent.continue busy reschedule budget exhausted; giving up", {
5043
- attempts: busyReschedules,
5044
- error: error.message,
5045
- });
5046
- }
5047
- if (!predecessorAccepted)
5048
- this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
5049
- throw error;
5050
- }
5893
+ await this.#awaitStartupTurnBarrier();
5894
+ if (!canContinue()) {
5895
+ settleLease();
5896
+ return;
5897
+ }
5898
+ try {
5899
+ if (!options?.skipCompactionCheck) {
5900
+ await this.#checkEstimatedContextBeforePrompt();
5901
+ if (!canContinue()) return;
5902
+ }
5903
+ if (scheduledSignal.aborted) {
5904
+ skip("aborted_signal");
5905
+ return;
5906
+ }
5907
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
5908
+ skip("generation_changed");
5909
+ return;
5910
+ }
5911
+ if (options?.shouldContinue && !options.shouldContinue()) {
5912
+ skip("queue_drained");
5913
+ return;
5914
+ }
5915
+ // A scheduled same-turn continuation of a terminally aborted turn is
5916
+ // denied at the final synchronous boundary before agent.continue
5917
+ // entry; no await intervenes between this check and method entry.
5918
+ // Owned-completion deliveries are NOT affected (they use followUp).
5919
+ if (this.#isTurnContinuationBlocked()) {
5920
+ skip("terminal_turn");
5921
+ return;
5922
+ }
5923
+ // A continuation scheduled before a handoff engaged must not start a
5924
+ // turn against the session being handed off (or the restored
5925
+ // predecessor). rearmIdle / normal delivery resumes after the fence.
5926
+ if (this.#handoffTransitionActive) {
5927
+ skip("handoff_in_progress");
5928
+ return;
5929
+ }
5930
+ const predecessorAgentEnd =
5931
+ this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
5932
+ let predecessorAccepted = false;
5933
+ const releasePredecessor = () => {
5934
+ if (predecessorAccepted) return;
5935
+ predecessorAccepted = true;
5936
+ this.#releaseDeferredAgentEndLease(predecessorAgentEnd);
5937
+ };
5938
+ const hasQueuedMessages = this.agent.hasQueuedMessages();
5939
+ const startsQueuedSuccessor =
5940
+ hasQueuedMessages &&
5941
+ (options?.continueQueuedOnly === true ||
5942
+ this.agent.state.messages.at(-1)?.role === "assistant");
5943
+ const continueQueued = options?.continueQueuedOnly
5944
+ ? this.agent.continueQueuedMessages.bind(this.agent)
5945
+ : this.agent.continue.bind(this.agent);
5946
+ try {
5947
+ await continueQueued({
5948
+ ...this.#managedFallbackPromptOptions(),
5949
+ maintenanceContinuation: options?.maintenanceContinuation,
5950
+ // Reset only after continue() has claimed the queued turn. Skipped or stale
5951
+ // continuations retain predecessor accounting, and resetAttemptBudget keeps
5952
+ // the sticky fallback cursor unchanged.
5953
+ onRunAccepted: (handle: AttemptRunHandle, acceptance) => {
5954
+ this.#fireQueuedPromotionHooks(acceptance.consumedQueuedMessages);
5955
+ for (const message of acceptance.consumedQueuedMessages) {
5956
+ const sdkRunToken = this.#sdkRunTokensByQueuedMessage.get(message);
5957
+ if (sdkRunToken) {
5958
+ this.#sdkRunTokensByAttemptScope.set(handle.scope, sdkRunToken);
5959
+ break;
5960
+ }
5961
+ }
5962
+ this.#acceptRunHandle(handle);
5963
+ options?.onRunAccepted?.(handle);
5964
+ settleLease();
5965
+ releasePredecessor();
5966
+ if (startsQueuedSuccessor) {
5967
+ this.#defaultFallbackChain().resetAttemptBudget();
5968
+ this.#overflowMaintenanceAttempts = 0;
5969
+ }
5970
+ },
5971
+ });
5972
+ releasePredecessor();
5973
+ } catch (error) {
5974
+ if (
5975
+ options?.rescheduleOnBusy &&
5976
+ this.#isAgentBusyError(error) &&
5977
+ !predecessorAccepted &&
5978
+ !terminalized &&
5979
+ canContinue()
5980
+ ) {
5981
+ if (busyReschedules < AGENT_CONTINUE_BUSY_MAX_RESCHEDULES) {
5982
+ busyReschedules += 1;
5983
+ const nextDelayMs = agentContinueBusyRescheduleDelayMs(busyReschedules);
5984
+ this.#retainDeferredAgentEndAfterContinuationBusy(
5985
+ predecessorAgentEndHold,
5986
+ predecessorAgentEnd,
5987
+ );
5988
+ logger.debug("agent.continue busy after scheduling; rescheduling", {
5989
+ error: error.message,
5990
+ attempt: busyReschedules,
5991
+ delayMs: nextDelayMs,
5992
+ });
5993
+ void scheduleAttempt(nextDelayMs);
5994
+ return;
5995
+ }
5996
+ logger.warn("agent.continue busy reschedule budget exhausted; giving up", {
5997
+ attempts: busyReschedules,
5998
+ error: error.message,
5999
+ });
6000
+ }
6001
+ if (!predecessorAccepted)
6002
+ this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
6003
+ throw error;
6004
+ }
6005
+ } catch (error) {
6006
+ fail(error);
6007
+ } finally {
6008
+ settleLease();
6009
+ }
6010
+ },
6011
+ scheduledSignal,
6012
+ continuationAdmission,
6013
+ {
6014
+ bypassSelectionFenceGeneration: selectionFenceGeneration,
6015
+ allowPromptContinuationReentry: true,
6016
+ },
6017
+ );
5051
6018
  } catch (error) {
5052
- fail(error);
5053
- } finally {
5054
- settleLease();
6019
+ if (scheduledSignal.aborted) skip("aborted_signal");
6020
+ else fail(error);
5055
6021
  }
5056
6022
  },
5057
6023
  {
@@ -5062,12 +6028,12 @@ export class AgentSession {
5062
6028
  },
5063
6029
  resourceRunId: options?.resourceRunId,
5064
6030
  leaseTask: leaseSettlement.promise,
6031
+ selectionFenceGeneration,
5065
6032
  },
5066
6033
  );
5067
6034
  };
5068
6035
  return scheduleAttempt();
5069
6036
  }
5070
-
5071
6037
  #logCompactionContinuationSkipped(
5072
6038
  source: "auto_continue_prompt" | "queued_continue" | "overflow_retry",
5073
6039
  reason: string,
@@ -5128,7 +6094,6 @@ export class AgentSession {
5128
6094
  generation,
5129
6095
  suppressPredecessorAgentEnd: true,
5130
6096
  resourceRunId,
5131
- rescheduleOnBusy: true,
5132
6097
  onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
5133
6098
  onError: error => this.#logCompactionContinuationError("overflow_retry", error),
5134
6099
  });
@@ -5145,8 +6110,13 @@ export class AgentSession {
5145
6110
  return false;
5146
6111
  }
5147
6112
 
5148
- #scheduleAutoContinuePrompt(generation: number, requireUnfinishedWork = true, resourceRunId?: string): void {
5149
- const predecessorAgentEndHold = this.#reserveDeferredAgentEndForContinuation();
6113
+ #scheduleAutoContinuePrompt(
6114
+ generation: number,
6115
+ requireUnfinishedWork = true,
6116
+ resourceRunId?: string,
6117
+ deferredSelectionFenceGeneration?: number,
6118
+ deferredPredecessorAgentEnd?: AgentSessionEvent,
6119
+ ): void {
5150
6120
  const scheduledGeneration = generation;
5151
6121
  const continuationAuthorized = async (
5152
6122
  signal: AbortSignal,
@@ -5164,6 +6134,13 @@ export class AgentSession {
5164
6134
  );
5165
6135
  return false;
5166
6136
  }
6137
+ // A same-turn auto-continue of a terminally aborted turn is denied
6138
+ // (corrected turn semantics); owned-completion deliveries are not
6139
+ // affected — they flow through followUp as fresh turns.
6140
+ if (this.#isTurnContinuationBlocked()) {
6141
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "terminal_turn");
6142
+ return false;
6143
+ }
5167
6144
  const authorized = requireUnfinishedWork
5168
6145
  ? this.#hasUnfinishedWork(snapshot) || hasPendingNextTurnMessages
5169
6146
  : snapshot.queuedMessages ||
@@ -5173,81 +6150,95 @@ export class AgentSession {
5173
6150
  if (!authorized) this.emitNotice("info", "Auto-continue skipped: no unfinished work detected");
5174
6151
  return authorized;
5175
6152
  };
5176
- let busyReschedules = 0;
5177
- const scheduleAttempt = (delayMs = 0) => {
5178
- let rescheduled = false;
5179
- void this.#schedulePostPromptTask(
5180
- async signal => {
5181
- try {
5182
- await Promise.resolve();
5183
- if (signal.aborted) {
5184
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
5185
- return;
5186
- }
5187
- if (this.#promptGeneration !== scheduledGeneration) {
5188
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
5189
- return;
5190
- }
5191
- if (!(await continuationAuthorized(signal))) return;
5192
- await this.#promptWithMessage(
5193
- {
5194
- role: "developer",
5195
- content: [{ type: "text", text: autoContinuePrompt }],
5196
- attribution: "agent",
5197
- timestamp: Date.now(),
5198
- },
5199
- autoContinuePrompt,
5200
- {
5201
- skipPostPromptRecoveryWait: true,
5202
- skipCompactionCheck: true,
5203
- predecessorAgentEndHold,
5204
- onFinalPreflight: ({ hasPendingNextTurnMessages }) =>
5205
- continuationAuthorized(signal, hasPendingNextTurnMessages),
5206
- },
5207
- );
5208
- } catch (error) {
5209
- if (signal.aborted) {
5210
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
5211
- return;
5212
- }
5213
- if (this.#isAgentBusyError(error) && this.#promptGeneration === scheduledGeneration) {
5214
- if (busyReschedules < AGENT_CONTINUE_BUSY_MAX_RESCHEDULES) {
5215
- busyReschedules += 1;
5216
- const nextDelayMs = agentContinueBusyRescheduleDelayMs(busyReschedules);
5217
- logger.debug("Auto-compaction continuation busy; rescheduling", {
5218
- source: "auto_continue_prompt",
5219
- reason: "queue_drained",
5220
- error: error.message,
5221
- attempt: busyReschedules,
5222
- delayMs: nextDelayMs,
5223
- });
5224
- rescheduled = true;
5225
- scheduleAttempt(nextDelayMs);
6153
+ const continuationAdmission = this.#captureScheduledContinuationAdmission();
6154
+ const selectionFenceGeneration =
6155
+ deferredSelectionFenceGeneration ??
6156
+ this.#selectionFenceGenerationContext.getStore() ??
6157
+ this.#sessionAdmissionContext.getStore()?.selectionFenceGeneration ??
6158
+ continuationAdmission?.entry.selectionFenceGeneration ??
6159
+ this.#selectionFenceGeneration;
6160
+ if (this.#pendingSelectionFences > 0 && selectionFenceGeneration === this.#selectionFenceGeneration) {
6161
+ const predecessorAgentEnd =
6162
+ deferredPredecessorAgentEnd ??
6163
+ this.#claimDeferredAgentEndForContinuation(this.#reserveDeferredAgentEndForContinuation());
6164
+ const precedingSelectionFence = this.#selectionFenceTail;
6165
+ const deferredScheduling = precedingSelectionFence.then(() => {
6166
+ try {
6167
+ this.#scheduleAutoContinuePrompt(
6168
+ generation,
6169
+ requireUnfinishedWork,
6170
+ resourceRunId,
6171
+ selectionFenceGeneration,
6172
+ predecessorAgentEnd,
6173
+ );
6174
+ } finally {
6175
+ this.#endSelectionFenceDeferralTracking(selectionFenceGeneration);
6176
+ }
6177
+ });
6178
+ this.#trackSelectionFenceDeferredContinuation(selectionFenceGeneration, deferredScheduling);
6179
+ return;
6180
+ }
6181
+ const predecessorAgentEndHold = deferredPredecessorAgentEnd
6182
+ ? this.#restoreAndReserveDeferredAgentEndForContinuation(deferredPredecessorAgentEnd)
6183
+ : this.#reserveDeferredAgentEndForContinuation();
6184
+ void this.#schedulePostPromptTask(
6185
+ async signal => {
6186
+ try {
6187
+ await this.#withSessionAdmission(
6188
+ "prompt",
6189
+ async () => {
6190
+ await Promise.resolve();
6191
+ if (signal.aborted) {
6192
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
5226
6193
  return;
5227
6194
  }
5228
- logger.warn("Auto-compaction continuation busy reschedule budget exhausted; giving up", {
5229
- source: "auto_continue_prompt",
5230
- attempts: busyReschedules,
5231
- error: error.message,
5232
- });
5233
- }
5234
- this.#logCompactionContinuationError("auto_continue_prompt", error);
5235
- } finally {
5236
- if (!rescheduled) this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
5237
- }
5238
- },
5239
- {
5240
- delayMs,
5241
- generation: scheduledGeneration,
5242
- resourceRunId,
5243
- onSkip: () => {
6195
+ if (this.#promptGeneration !== scheduledGeneration) {
6196
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
6197
+ return;
6198
+ }
6199
+ if (!(await continuationAuthorized(signal))) return;
6200
+ await this.#promptWithMessage(
6201
+ {
6202
+ role: "developer",
6203
+ content: [{ type: "text", text: autoContinuePrompt }],
6204
+ attribution: "agent",
6205
+ timestamp: Date.now(),
6206
+ },
6207
+ autoContinuePrompt,
6208
+ {
6209
+ skipPostPromptRecoveryWait: true,
6210
+ skipCompactionCheck: true,
6211
+ predecessorAgentEndHold,
6212
+ onFinalPreflight: ({ hasPendingNextTurnMessages }) =>
6213
+ continuationAuthorized(signal, hasPendingNextTurnMessages),
6214
+ },
6215
+ );
6216
+ },
6217
+ signal,
6218
+ continuationAdmission,
6219
+ { bypassSelectionFenceGeneration: selectionFenceGeneration },
6220
+ );
6221
+ } catch (error) {
6222
+ if (signal.aborted) {
5244
6223
  this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
5245
- this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
5246
- },
6224
+ return;
6225
+ }
6226
+ this.#logCompactionContinuationError("auto_continue_prompt", error);
6227
+ } finally {
6228
+ this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
6229
+ }
6230
+ },
6231
+ {
6232
+ delayMs: 0,
6233
+ generation: scheduledGeneration,
6234
+ resourceRunId,
6235
+ selectionFenceGeneration,
6236
+ onSkip: () => {
6237
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
6238
+ this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
5247
6239
  },
5248
- );
5249
- };
5250
- scheduleAttempt();
6240
+ },
6241
+ );
5251
6242
  }
5252
6243
 
5253
6244
  async #cancelPostPromptTasks(): Promise<void> {
@@ -5273,6 +6264,7 @@ export class AgentSession {
5273
6264
  this.#postPromptTasksAbortController.abort();
5274
6265
  this.#postPromptTasksAbortController = new AbortController();
5275
6266
  this.#postPromptTasks.clear();
6267
+ this.#postPromptTaskSelectionFenceGenerations.clear();
5276
6268
  this.#releaseDeferredAgentEndContinuations();
5277
6269
  this.#resolveTtsrResume();
5278
6270
  this.#resolvePostPromptTasks();
@@ -5309,6 +6301,19 @@ export class AgentSession {
5309
6301
  }
5310
6302
  }
5311
6303
 
6304
+ /**
6305
+ * A selection's scoped idle drain must not wait on work parked behind its
6306
+ * own fence, but it still needs to yield to post-prompt work that predates
6307
+ * that fence. Otherwise the drain can repeatedly await already-settled
6308
+ * session work while a timer-backed predecessor task is starved.
6309
+ */
6310
+ async #waitForPostPromptTasksBeforeSelectionFence(selectionFenceGeneration: number): Promise<void> {
6311
+ const precedingTasks = [...this.#postPromptTasks].filter(
6312
+ task => (this.#postPromptTaskSelectionFenceGenerations.get(task) ?? 0) < selectionFenceGeneration,
6313
+ );
6314
+ if (precedingTasks.length > 0) await Promise.allSettled(precedingTasks);
6315
+ }
6316
+
5312
6317
  /** Get TTSR injection payload and clear pending injections. */
5313
6318
  #getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
5314
6319
  if (this.#pendingTtsrInjections.length === 0) return undefined;
@@ -5667,6 +6672,13 @@ export class AgentSession {
5667
6672
  }
5668
6673
  }
5669
6674
 
6675
+ #isProvisionalStreamingToolEvent(event: AgentEvent): boolean {
6676
+ if (event.type !== "message_update") return false;
6677
+ const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
6678
+ const block = event.message.role === "assistant" ? event.message.content[contentIndex] : undefined;
6679
+ return block?.type === "toolCall" && this.#provisionalStreamingToolCallIds.has(block.id);
6680
+ }
6681
+
5670
6682
  #streamingEditPrecachePending = new Set<string>();
5671
6683
  async #preCacheFileAsync(resolvedPath: string): Promise<void> {
5672
6684
  if (this.#streamingEditFileCache.has(resolvedPath)) return;
@@ -6013,7 +7025,16 @@ export class AgentSession {
6013
7025
  try {
6014
7026
  if (event.type === "agent_start") {
6015
7027
  this.#turnIndex = 0;
6016
- await this.#extensionRunner.emit({ type: "agent_start" }, undefined, deliveryScope);
7028
+ await this.#extensionRunner.emit(
7029
+ {
7030
+ type: "agent_start",
7031
+ ...(deliveryScope
7032
+ ? { sdkRunToken: this.#sdkRunTokensByAttemptScope.get(deliveryScope as AttemptScope) }
7033
+ : {}),
7034
+ },
7035
+ undefined,
7036
+ deliveryScope,
7037
+ );
6017
7038
  } else if (event.type === "agent_end") {
6018
7039
  await this.#extensionRunner.emit(
6019
7040
  {
@@ -6251,6 +7272,81 @@ export class AgentSession {
6251
7272
  );
6252
7273
  }
6253
7274
 
7275
+ /**
7276
+ * Re-register the session's AsyncJobManager under the successor endpoint
7277
+ * id after a committed session-identity transition (newSession, fork,
7278
+ * handoff, branch, switchSession). Tool-side lineage bindings made after
7279
+ * the transition use the successor id; the process-global endpoint
7280
+ * registry must follow, otherwise AsyncJobManager.endpointIdOf() keeps
7281
+ * resolving the predecessor and a queued subagent resume can neither
7282
+ * resolve its lineage nor register its owned tuple (review thread P1).
7283
+ */
7284
+ #asyncJobEndpointId(sessionId: string, sessionFile: string | undefined): string {
7285
+ return deriveAsyncJobEndpointId(this.#asyncJobProviderSessionId, sessionId, sessionFile);
7286
+ }
7287
+
7288
+ #assertJobManagerEndpointAdmission(successorSessionId: string, successorSessionFile: string | undefined): void {
7289
+ const successorEndpointId = this.#asyncJobEndpointId(successorSessionId, successorSessionFile);
7290
+ const ownManager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
7291
+ const successorOwner = AsyncJobManager.forEndpoint(successorEndpointId);
7292
+ if (ownManager && successorOwner !== undefined && successorOwner !== ownManager) {
7293
+ throw new Error(
7294
+ `Session identity transition rejected: endpoint "${successorEndpointId}" is owned by another live session's job manager.`,
7295
+ );
7296
+ }
7297
+ }
7298
+
7299
+ #rekeyJobManagerForSessionIdentity(
7300
+ previousSessionId: string,
7301
+ previousSessionFile: string | undefined,
7302
+ options: { retirePredecessorRegistrations?: boolean } = {},
7303
+ ): void {
7304
+ const previousEndpointId = this.#asyncJobEndpointId(previousSessionId, previousSessionFile);
7305
+ const currentEndpointId = this.#asyncJobEndpointId(
7306
+ this.sessionManager.getSessionId(),
7307
+ this.sessionManager.getSessionFile(),
7308
+ );
7309
+ const predecessorOwner = AsyncJobManager.forEndpoint(previousEndpointId);
7310
+ // Rekey and retire ONLY when the predecessor key belongs to THIS
7311
+ // session's manager (the session-owned manager, else the process-global
7312
+ // fallback the session uses). A predecessor key held by a FOREIGN
7313
+ // manager means another live session currently carries this id —
7314
+ // neither the mapping nor the tuples keyed under it are ours to move
7315
+ // or retire (review thread P1).
7316
+ const ownManager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
7317
+ if (predecessorOwner !== undefined && predecessorOwner !== ownManager) return;
7318
+ if (predecessorOwner !== undefined) {
7319
+ const rekeyed = AsyncJobManager.rekeyForEndpoint(previousEndpointId, currentEndpointId, predecessorOwner);
7320
+ if (!rekeyed) {
7321
+ // The successor endpoint is owned by a FOREIGN live manager. A
7322
+ // silent no-op would leave THIS manager registered under the
7323
+ // predecessor while tools resolve the successor to the foreign
7324
+ // manager — jobs queried or registered in the wrong session and
7325
+ // owned aborts losing their causal set — and retiring the
7326
+ // predecessor tuples afterwards would destroy the registration
7327
+ // evidence of this session's still-live owned work. Fail the
7328
+ // transition: every caller routes throws into the existing
7329
+ // rollback (discardPreparedNewSessionAfterFailure /
7330
+ // restoreState) BEFORE any predecessor state is retired
7331
+ // (review thread P1).
7332
+ throw new Error(
7333
+ `Session identity transition rejected: endpoint "${currentEndpointId}" is owned by another live session's job manager.`,
7334
+ );
7335
+ }
7336
+ }
7337
+ // Owned registrations created before the transition are keyed by the
7338
+ // PREDECESSOR endpoint. The transitions settle/cancel the session's
7339
+ // owned jobs before committing identity, so no live delivery boundary
7340
+ // depends on them; leaving them behind leaks tuples that disposal (now
7341
+ // keyed by the successor) can never retire, saturating the global
7342
+ // registry across repeated transitions. Retire them as part of the
7343
+ // rekey (review thread P2). A same-id transition (no-op rekey) keeps
7344
+ // the session's own live tuples.
7345
+ if (previousEndpointId !== currentEndpointId && options.retirePredecessorRegistrations !== false) {
7346
+ retireOwnedRegistrationsForEndpoint(previousEndpointId);
7347
+ }
7348
+ }
7349
+
6254
7350
  #rekeyHindsightMemoryForCurrentSessionId(): void {
6255
7351
  if (resolveMemoryBackendId(this.settings) !== "hindsight") return;
6256
7352
 
@@ -6322,7 +7418,13 @@ export class AgentSession {
6322
7418
  await admissionClosed;
6323
7419
  await this.#agentEndPublicationPromise;
6324
7420
  await this.#queuedExtensionEvents;
6325
- this.#workflowGateEmitter?.fence?.();
7421
+ // Drain the sidecar write order for the same reason the two queues above are
7422
+ // drained: each entry writes under the native identity-bound state-file lock, so a
7423
+ // still-queued write would run after the session that owns it is gone — releasing
7424
+ // a lock whose owner no longer exists, and under `bun test --isolate` calling into
7425
+ // the addon after the runtime tore down the context it was scheduled in. The chain
7426
+ // is already failure-absorbing, so this only waits.
7427
+ await this.#coordinatorPersistQueue;
6326
7428
  this.#pendingBackgroundExchanges = [];
6327
7429
  this.yieldQueue.clear();
6328
7430
 
@@ -6334,6 +7436,7 @@ export class AgentSession {
6334
7436
  } catch (error) {
6335
7437
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6336
7438
  }
7439
+ this.#workflowGateEmitter?.fence?.();
6337
7440
  this.#workflowGateEmitter = undefined;
6338
7441
  this.#notifyWorkflowGateEmitterChanged(this.sessionId, undefined);
6339
7442
  await this.#flushWorkerIntegrationAttempt();
@@ -6345,7 +7448,7 @@ export class AgentSession {
6345
7448
  this.#cancelOwnAsyncJobs();
6346
7449
  await Promise.allSettled(this.#deferredOwnerShutdownFinalizations);
6347
7450
  const ownedAsyncManager = this.#ownedAsyncJobManager;
6348
- if (ownedAsyncManager) {
7451
+ if (ownedAsyncManager && this.#disposeAsyncJobManager) {
6349
7452
  const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
6350
7453
  const deliveryState = ownedAsyncManager.getDeliveryState();
6351
7454
  if (drained === false && deliveryState) {
@@ -6400,6 +7503,7 @@ export class AgentSession {
6400
7503
  await this.memoryBackend.dispose();
6401
7504
  if (this.#workspaceTreeService) await this.#workspaceTreeService.dispose();
6402
7505
  if (this.#networkPrewarmService) await this.#networkPrewarmService.dispose();
7506
+ this.#modelRegistry.authStorage?.releaseCredentialScope(this.credentialSessionId);
6403
7507
  await this.sessionManager.close();
6404
7508
  this.#closeAllProviderSessions("dispose");
6405
7509
  const hindsightState = this.getHindsightSessionState();
@@ -6514,24 +7618,58 @@ export class AgentSession {
6514
7618
  }
6515
7619
 
6516
7620
  /** Wait until streaming and session settlement work are fully settled. */
6517
- async waitForIdle(): Promise<void> {
7621
+ /**
7622
+ * Wait until streaming and session settlement work are fully settled.
7623
+ *
7624
+ * The internal-only `ignoreSelectionFenceGeneration` is used exclusively by
7625
+ * `setDefaultModelSelection`'s mid-selection drain: the selection must not
7626
+ * wait on work parked behind its own (or a later) fence — its agent run,
7627
+ * recovery, and deferrals — because that work is waiting on the selection
7628
+ * itself (#4519). External callers observe everything.
7629
+ */
7630
+ async waitForIdle(ignoreSelectionFenceGeneration?: number): Promise<void> {
6518
7631
  while (true) {
6519
- await this.agent.waitForIdle();
6520
- await this.#waitForPostPromptRecovery();
6521
- await this.#waitForSessionSettlement();
7632
+ if (ignoreSelectionFenceGeneration === undefined) {
7633
+ await this.agent.waitForIdle();
7634
+ await this.#waitForPostPromptRecovery();
7635
+ } else {
7636
+ await this.#waitForPostPromptTasksBeforeSelectionFence(ignoreSelectionFenceGeneration);
7637
+ }
7638
+ await this.#waitForSessionSettlement(ignoreSelectionFenceGeneration);
6522
7639
  if (
6523
7640
  !this.agent.state.isStreaming &&
6524
7641
  !this.#retryPromise &&
6525
7642
  !this.#ttsrResumePromise &&
6526
7643
  !this.#postPromptTasksPromise &&
6527
- !this.#isSessionSettlementPending()
7644
+ !this.#isSessionSettlementPending(ignoreSelectionFenceGeneration)
6528
7645
  )
6529
7646
  return;
6530
7647
  }
6531
7648
  }
6532
7649
 
7650
+ /**
7651
+ * Deterministically await every in-flight agent-event handler (including the
7652
+ * synchronous canonical persistence each one performs) plus the durable
7653
+ * transcript flush, without waiting for a prompt/turn lifecycle.
7654
+ *
7655
+ * This is the explicit signal replacement for real-time settle sleeps after
7656
+ * externally emitted events (`agent.emitExternalEvent`): the dispatcher
7657
+ * increments `#agentEventHandlersInFlight` synchronously before this call can
7658
+ * observe it, and each handler decrements it in its `finally`, so the
7659
+ * settlement promise below resolves exactly when the last handler — and
7660
+ * therefore its `sessionManager.appendMessage`/`_persist` work — has finished.
7661
+ * `flush()` then queues deterministically behind the SessionManager persist
7662
+ * queue, so every seeded entry is canonical AND on disk before the caller
7663
+ * proceeds.
7664
+ */
7665
+ async awaitSessionSettlement(): Promise<void> {
7666
+ await this.awaitPendingContextTransformations();
7667
+ await this.#waitForSessionSettlement();
7668
+ await this.sessionManager.flush();
7669
+ }
7670
+
6533
7671
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
6534
- const manager = AsyncJobManager.instance();
7672
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
6535
7673
  if (!manager) return false;
6536
7674
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
6537
7675
  const before = manager.getDeliveryState(ownerFilter);
@@ -6555,7 +7693,7 @@ export class AgentSession {
6555
7693
  * any remaining queued/delivering work is observable and can block mutation.
6556
7694
  */
6557
7695
  getAsyncDeliveryStateForAcp(): { queued: number; delivering: boolean } {
6558
- const manager = AsyncJobManager.instance();
7696
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
6559
7697
  if (!manager) return { queued: 0, delivering: false };
6560
7698
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
6561
7699
  return manager.getDeliveryState(ownerFilter);
@@ -7113,9 +8251,15 @@ export class AgentSession {
7113
8251
  activeSkillState: this.getActiveSkillState(),
7114
8252
  sessionId: this.sessionManager.getSessionId(),
7115
8253
  }),
8254
+ () => this.getSessionAgentDir(),
7116
8255
  ),
7117
8256
  ),
7118
8257
  );
8258
+ // The object published into `agent.state.tools` — and therefore the object the
8259
+ // agent loop actually dispatches to — is this guard wrapper, not the registry
8260
+ // entry. A wrapper built from a proven built-in inherits that proof; one built
8261
+ // from a custom, MCP, or extension tool inherits nothing.
8262
+ if (this.#builtinToolIdentities.has(tool)) this.#builtinToolIdentities.add(wrapped);
7119
8263
  if (!wrappersByVersion) {
7120
8264
  wrappersByVersion = new Map();
7121
8265
  this.#guardedToolWrapperCache.set(tool, wrappersByVersion);
@@ -7241,6 +8385,9 @@ export class AgentSession {
7241
8385
  const refreshedTool = await this.#reloadSshTool();
7242
8386
  if (refreshedTool) {
7243
8387
  this.#toolRegistry.set(refreshedTool.name, refreshedTool);
8388
+ // A reloaded built-in is a NEW object. Without this it would replace the proven
8389
+ // one and every subsequent `ssh` call would be reported as `custom`.
8390
+ this.#builtinToolIdentities.add(refreshedTool);
7244
8391
  } else {
7245
8392
  this.#toolRegistry.delete("ssh");
7246
8393
  this.#selectedDiscoveredToolNames.delete("ssh");
@@ -7842,6 +8989,36 @@ export class AgentSession {
7842
8989
  return this.#credentialSessionId ?? this.sessionId;
7843
8990
  }
7844
8991
 
8992
+ /** Pin one OAuth credential for this session scope and persist the minimal intent. */
8993
+ async setCredentialPin(provider: string, selector: AuthCredentialSelector): Promise<void> {
8994
+ const scopeId = this.credentialSessionId;
8995
+ const authStorage = this.#modelRegistry.authStorage;
8996
+ const target = authStorage.resolveOAuthPinTarget(provider, selector);
8997
+ authStorage.setSessionCredentialSelector(scopeId, provider, target.canonicalSelector);
8998
+ if (target.canonicalSelector.kind === "id" && !this.#credentialStoreIdentity) return;
8999
+ this.sessionManager.appendCustomEntry("auth-credential-pin", {
9000
+ v: 1,
9001
+ scopeId,
9002
+ provider,
9003
+ pin: target.canonicalSelector,
9004
+ ...(target.canonicalSelector.kind === "id" && this.#credentialStoreIdentity
9005
+ ? { credentialStoreIdentity: this.#credentialStoreIdentity }
9006
+ : {}),
9007
+ });
9008
+ }
9009
+
9010
+ /** Mask persistent/global selection for this session and restore AUTO ranking. */
9011
+ async setCredentialAuto(provider: string): Promise<void> {
9012
+ const scopeId = this.credentialSessionId;
9013
+ this.#modelRegistry.authStorage.setSessionCredentialAuto(provider, scopeId);
9014
+ this.sessionManager.appendCustomEntry("auth-credential-pin", {
9015
+ v: 1,
9016
+ scopeId,
9017
+ provider,
9018
+ pin: { auto: true },
9019
+ });
9020
+ }
9021
+
7845
9022
  /** Current session display name, if set */
7846
9023
  get sessionName(): string | undefined {
7847
9024
  return this.sessionManager.getSessionName();
@@ -7895,8 +9072,12 @@ export class AgentSession {
7895
9072
  async invokeSkill(
7896
9073
  name: string,
7897
9074
  args = "",
7898
- options?: Pick<PromptOptions, "onPreflightAccepted" | "onPreflightAcceptCommit" | "onSkillPrepared">,
9075
+ options?: Pick<
9076
+ PromptOptions,
9077
+ "onPreflightAccepted" | "onPreflightAcceptCommit" | "onSkillPrepared" | "preflightSignal"
9078
+ >,
7899
9079
  ): Promise<{ name: string; path: string; args?: string; lineCount?: number }> {
9080
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
7900
9081
  const skillName = name.trim();
7901
9082
  if (!skillName) throw Object.assign(new Error("skill.invoke requires a skill name."), { code: "invalid_input" });
7902
9083
  if (typeof args !== "string")
@@ -7908,18 +9089,24 @@ export class AgentSession {
7908
9089
  throw Object.assign(new Error(`Skill ${skillName} was not found.${availableHint}`), { code: "invalid_input" });
7909
9090
  }
7910
9091
  const deepInterviewUserIntentEpoch = this.#claimDeepInterviewUserIntent();
7911
- const activation = await resolveSubskillActivationForSkillInvocation({
7912
- cwd: this.sessionManager.getCwd(),
7913
- sessionId: this.sessionId,
7914
- skillName: skill.name,
7915
- args,
7916
- });
7917
- const built = await buildSkillPromptMessage(skill, activation.cleanedArgs, {
7918
- subskillActivation: activation.activation,
7919
- subskillActivationSet: activation.activeSubskillsToPersist,
7920
- cwd: this.sessionManager.getCwd(),
7921
- sessionId: this.sessionId,
7922
- });
9092
+ const activation = await awaitPromptInvocationPreflight(
9093
+ resolveSubskillActivationForSkillInvocation({
9094
+ cwd: this.sessionManager.getCwd(),
9095
+ sessionId: this.sessionId,
9096
+ skillName: skill.name,
9097
+ args,
9098
+ }),
9099
+ options?.preflightSignal,
9100
+ );
9101
+ const built = await awaitPromptInvocationPreflight(
9102
+ buildSkillPromptMessage(skill, activation.cleanedArgs, {
9103
+ subskillActivation: activation.activation,
9104
+ subskillActivationSet: activation.activeSubskillsToPersist,
9105
+ cwd: this.sessionManager.getCwd(),
9106
+ sessionId: this.sessionId,
9107
+ }),
9108
+ options?.preflightSignal,
9109
+ );
7923
9110
  const skillPromptMessage = {
7924
9111
  customType: SKILL_PROMPT_MESSAGE_TYPE,
7925
9112
  content: built.message,
@@ -7928,6 +9115,7 @@ export class AgentSession {
7928
9115
  attribution: "user" as const,
7929
9116
  };
7930
9117
  this.#deepInterviewPreclaimedCustomInputEpochs.set(skillPromptMessage, deepInterviewUserIntentEpoch);
9118
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
7931
9119
  options?.onSkillPrepared?.({
7932
9120
  name: skill.name,
7933
9121
  path: skill.filePath,
@@ -8161,6 +9349,11 @@ export class AgentSession {
8161
9349
  const wrappedTool = wrapToolWithMetaNotice(createdAskTool as unknown as AgentTool);
8162
9350
  askTool = this.#extensionRunner ? new ExtensionToolWrapper(wrappedTool, this.#extensionRunner) : wrappedTool;
8163
9351
  this.#toolRegistry.set(askTool.name, askTool);
9352
+ // Built here from the built-in `ask` descriptor rather than by the session
9353
+ // builder, so its provenance has to be recorded here too. Only this
9354
+ // construction path qualifies: an `ask` already in the registry is left alone,
9355
+ // because whatever put it there owns its provenance.
9356
+ this.#builtinToolIdentities.add(askTool);
8164
9357
  }
8165
9358
 
8166
9359
  try {
@@ -8609,6 +9802,8 @@ export class AgentSession {
8609
9802
  */
8610
9803
  async prompt(text: string, options?: PromptOptions): Promise<void> {
8611
9804
  this.#assertRecoveryHydrationPromoted();
9805
+ const owner = this.#sessionAdmissionContext.getStore();
9806
+ if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
8612
9807
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
8613
9808
 
8614
9809
  if (expandPromptTemplates && text.startsWith("/skill:") && !options?.images?.length) {
@@ -8620,12 +9815,13 @@ export class AgentSession {
8620
9815
  await this.invokeSkill(
8621
9816
  invocation.skill.name,
8622
9817
  invocation.args,
8623
- options?.onPreflightAccepted || options?.onPreflightAcceptCommit
9818
+ options?.onPreflightAccepted || options?.onPreflightAcceptCommit || options?.preflightSignal
8624
9819
  ? {
8625
9820
  ...(options.onPreflightAccepted ? { onPreflightAccepted: options.onPreflightAccepted } : {}),
8626
9821
  ...(options.onPreflightAcceptCommit
8627
9822
  ? { onPreflightAcceptCommit: options.onPreflightAcceptCommit }
8628
9823
  : {}),
9824
+ ...(options.preflightSignal ? { preflightSignal: options.preflightSignal } : {}),
8629
9825
  }
8630
9826
  : undefined,
8631
9827
  );
@@ -8662,6 +9858,13 @@ export class AgentSession {
8662
9858
  assertImagePlaceholdersHavePayload(expandedText, options?.images);
8663
9859
  const workflowIntentDiff = options?.synthetic ? null : buildWorkflowIntentDiff(expandedText);
8664
9860
  const claimsGenuineUserIntent = !options?.synthetic && options?.attribution !== "agent";
9861
+ const admissionGeneration = this.#promptGeneration;
9862
+ const admissionSignal = options?.preflightSignal
9863
+ ? AbortSignal.any([this.#promptPreflightAbortController.signal, options.preflightSignal])
9864
+ : this.#promptPreflightAbortController.signal;
9865
+ if (this.#pendingSelectionFences > 0) {
9866
+ await awaitPromptInvocationPreflight(this.#selectionFenceTail, admissionSignal);
9867
+ }
8665
9868
  const deepInterviewUserIntentEpoch =
8666
9869
  claimsGenuineUserIntent && !this.isStreaming ? this.#claimDeepInterviewUserIntent() : undefined;
8667
9870
 
@@ -8686,64 +9889,67 @@ export class AgentSession {
8686
9889
  return;
8687
9890
  }
8688
9891
 
8689
- const admissionGeneration = this.#promptGeneration;
8690
- const admissionSignal = this.#promptPreflightAbortController.signal;
8691
- await this.#withSessionAdmission("prompt", async admission => {
8692
- this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
8693
- if (workflowIntentDiff) {
8694
- this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
8695
- }
9892
+ await this.#withSessionAdmission(
9893
+ "prompt",
9894
+ async admission => {
9895
+ this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
9896
+ if (workflowIntentDiff) {
9897
+ this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
9898
+ }
8696
9899
 
8697
- // Skip eager todo prelude when the user has already queued a directive
8698
- const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
8699
- const eagerTodoPrelude =
8700
- !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
9900
+ // Skip eager todo prelude when the user has already queued a directive
9901
+ const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
9902
+ const eagerTodoPrelude =
9903
+ !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
8701
9904
 
8702
- const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
8703
- if (options?.images) {
8704
- userContent.push(...options.images);
8705
- }
8706
-
8707
- const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
8708
- const message = options?.synthetic
8709
- ? {
8710
- role: "developer" as const,
8711
- content: userContent,
8712
- attribution: promptAttribution,
8713
- timestamp: Date.now(),
8714
- }
8715
- : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
8716
- if (deepInterviewUserIntentEpoch !== undefined)
8717
- this.#deepInterviewGenuineUserMessageEpochs.set(message, deepInterviewUserIntentEpoch);
8718
- await this.refreshGjcSubskillTools();
9905
+ const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
9906
+ if (options?.images) {
9907
+ userContent.push(...options.images);
9908
+ }
8719
9909
 
8720
- if (eagerTodoPrelude?.toolChoice) {
8721
- this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
8722
- label: "eager-todo",
8723
- });
8724
- }
9910
+ const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
9911
+ const message = options?.synthetic
9912
+ ? {
9913
+ role: "developer" as const,
9914
+ content: userContent,
9915
+ attribution: promptAttribution,
9916
+ timestamp: Date.now(),
9917
+ }
9918
+ : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
9919
+ if (deepInterviewUserIntentEpoch !== undefined)
9920
+ this.#deepInterviewGenuineUserMessageEpochs.set(message, deepInterviewUserIntentEpoch);
9921
+ await this.refreshGjcSubskillTools();
9922
+
9923
+ if (eagerTodoPrelude?.toolChoice) {
9924
+ this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
9925
+ label: "eager-todo",
9926
+ });
9927
+ }
8725
9928
 
8726
- try {
8727
- await this.#promptWithMessage(message, expandedText, {
8728
- ...options,
8729
- prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
8730
- admissionLease: admission,
8731
- resetRetryReplaySafety: true,
8732
- });
8733
- } finally {
8734
- // Clean up residual eager-todo directive if the prompt never consumed it
8735
- // (e.g., compaction aborted, validation failed).
8736
- this.#toolChoiceQueue.removeByLabel("eager-todo");
8737
- }
8738
- if (!options?.synthetic) {
8739
- await this.#enforcePlanModeToolDecision();
8740
- }
8741
- });
9929
+ try {
9930
+ await this.#promptWithMessage(message, expandedText, {
9931
+ ...options,
9932
+ prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
9933
+ admissionLease: admission,
9934
+ resetRetryReplaySafety: true,
9935
+ });
9936
+ } finally {
9937
+ // Clean up residual eager-todo directive if the prompt never consumed it
9938
+ // (e.g., compaction aborted, validation failed).
9939
+ this.#toolChoiceQueue.removeByLabel("eager-todo");
9940
+ }
9941
+ if (!options?.synthetic) {
9942
+ await this.#enforcePlanModeToolDecision();
9943
+ }
9944
+ },
9945
+ options?.preflightSignal,
9946
+ );
8742
9947
  }
8743
9948
 
8744
9949
  async #syncSkillPromptActiveState(
8745
9950
  message: Pick<CustomMessage<unknown>, "customType" | "details">,
8746
9951
  active: boolean,
9952
+ persistActiveState = true,
8747
9953
  ): Promise<void> {
8748
9954
  if (message.customType !== SKILL_PROMPT_MESSAGE_TYPE) return;
8749
9955
  const details = message.details;
@@ -8767,8 +9973,12 @@ export class AgentSession {
8767
9973
  // initial mode-state + active row only when the skill is not already
8768
9974
  // active, so the mutation guard and Stop hook engage immediately instead
8769
9975
  // of relying on the skill prompt to run its own state-init steps.
8770
- if (active) {
8771
- await ensureWorkflowSkillActivationState({ cwd: this.sessionManager.getCwd(), skill, sessionId });
9976
+ if (active && persistActiveState) {
9977
+ await ensureWorkflowSkillActivationState({
9978
+ cwd: this.sessionManager.getCwd(),
9979
+ skill,
9980
+ sessionId,
9981
+ });
8772
9982
  const subskillDetails = details as {
8773
9983
  subskillActivation?: LoadedSubskillActivation;
8774
9984
  subskillActivationSet?: LoadedSubskillActivation[];
@@ -8802,9 +10012,10 @@ export class AgentSession {
8802
10012
  async #syncSkillPromptActiveStateSafely(
8803
10013
  message: Pick<CustomMessage<unknown>, "customType" | "details">,
8804
10014
  active: boolean,
10015
+ persistActiveState = true,
8805
10016
  ): Promise<void> {
8806
10017
  try {
8807
- await this.#syncSkillPromptActiveState(message, active);
10018
+ await this.#syncSkillPromptActiveState(message, active, persistActiveState);
8808
10019
  } catch {
8809
10020
  // Skill HUD state is observational; a filesystem write failure must not
8810
10021
  // interrupt the prompt turn it is visualizing. The native Stop hook still
@@ -8812,13 +10023,48 @@ export class AgentSession {
8812
10023
  }
8813
10024
  }
8814
10025
 
10026
+ async #seedSkillPromptActiveStateSafely(
10027
+ message: Pick<CustomMessage<unknown>, "customType" | "details">,
10028
+ ): Promise<WorkflowSkillActivationSeed | undefined> {
10029
+ if (message.customType !== SKILL_PROMPT_MESSAGE_TYPE) return undefined;
10030
+ const details = message.details;
10031
+ if (!details || typeof details !== "object") return undefined;
10032
+ const name = (details as { name?: unknown }).name;
10033
+ if (typeof name !== "string" || !name.trim()) return undefined;
10034
+ try {
10035
+ const subskillDetails = details as {
10036
+ subskillActivation?: LoadedSubskillActivation;
10037
+ subskillActivationSet?: LoadedSubskillActivation[];
10038
+ };
10039
+ const activations = subskillDetails.subskillActivationSet?.length
10040
+ ? subskillDetails.subskillActivationSet
10041
+ : subskillDetails.subskillActivation
10042
+ ? [subskillDetails.subskillActivation]
10043
+ : [];
10044
+ return await ensureWorkflowSkillActivationSeed({
10045
+ cwd: this.sessionManager.getCwd(),
10046
+ skill: name.trim(),
10047
+ sessionId: this.sessionManager.getSessionId(),
10048
+ activeSubskills: activations.length ? activations.map(toActiveSubskillEntry) : undefined,
10049
+ });
10050
+ } catch {
10051
+ return undefined;
10052
+ }
10053
+ }
10054
+
8815
10055
  async promptCustomMessage<T = unknown>(
8816
10056
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
8817
10057
  options?: Pick<
8818
10058
  PromptOptions,
8819
- "streamingBehavior" | "toolChoice" | "followUpQueuePolicy" | "onPreflightAccepted" | "onPreflightAcceptCommit"
10059
+ | "streamingBehavior"
10060
+ | "toolChoice"
10061
+ | "followUpQueuePolicy"
10062
+ | "onPreflightAccepted"
10063
+ | "onPreflightAcceptCommit"
10064
+ | "preflightSignal"
8820
10065
  >,
8821
10066
  ): Promise<void> {
10067
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
8822
10068
  const textContent =
8823
10069
  typeof message.content === "string"
8824
10070
  ? message.content
@@ -8849,31 +10095,61 @@ export class AgentSession {
8849
10095
 
8850
10096
  const admissionGeneration = this.#promptGeneration;
8851
10097
  const admissionSignal = this.#promptPreflightAbortController.signal;
8852
- await this.#withSessionAdmission("prompt", async admission => {
8853
- this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
8854
- const customMessage: CustomMessage<T> = {
8855
- role: "custom",
8856
- customType: message.customType,
8857
- content: message.content,
8858
- display: message.display,
8859
- details: message.details,
8860
- attribution: message.attribution ?? "agent",
8861
- timestamp: Date.now(),
8862
- };
8863
- if (deepInterviewUserIntentEpoch !== undefined)
8864
- this.#deepInterviewGenuineUserMessageEpochs.set(customMessage, deepInterviewUserIntentEpoch);
8865
-
8866
- await this.#syncSkillPromptActiveStateSafely(customMessage, true);
8867
- try {
8868
- await this.#promptWithMessage(customMessage, textContent, {
8869
- ...options,
8870
- admissionLease: admission,
8871
- resetRetryReplaySafety: true,
8872
- });
8873
- } finally {
8874
- await this.#syncSkillPromptActiveStateSafely(customMessage, false);
8875
- }
8876
- });
10098
+ await this.#withSessionAdmission(
10099
+ "prompt",
10100
+ async admission => {
10101
+ this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
10102
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
10103
+ const customMessage: CustomMessage<T> = {
10104
+ role: "custom",
10105
+ customType: message.customType,
10106
+ content: message.content,
10107
+ display: message.display,
10108
+ details: message.details,
10109
+ attribution: message.attribution ?? "agent",
10110
+ timestamp: Date.now(),
10111
+ };
10112
+ if (deepInterviewUserIntentEpoch !== undefined)
10113
+ this.#deepInterviewGenuineUserMessageEpochs.set(customMessage, deepInterviewUserIntentEpoch);
10114
+
10115
+ let activationSeed: WorkflowSkillActivationSeed | undefined;
10116
+ let preflightCancelled = false;
10117
+ let durableAcceptanceCompleted = false;
10118
+ const commitAcceptance = async () => {
10119
+ activationSeed = await this.#seedSkillPromptActiveStateSafely(customMessage);
10120
+ await this.#syncSkillPromptActiveStateSafely(customMessage, true, activationSeed?.seeded !== true);
10121
+ if (options?.preflightSignal?.aborted) {
10122
+ await activationSeed?.rollback();
10123
+ throw promptPreflightCancelledError();
10124
+ }
10125
+ if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
10126
+ else options?.onPreflightAccepted?.();
10127
+ durableAcceptanceCompleted = true;
10128
+ if (options?.preflightSignal?.aborted) {
10129
+ await activationSeed?.rollback();
10130
+ throw promptPreflightCancelledError();
10131
+ }
10132
+ };
10133
+ try {
10134
+ await this.#promptWithMessage(customMessage, textContent, {
10135
+ ...options,
10136
+ onPreflightAccepted: undefined,
10137
+ onPreflightAcceptCommit: commitAcceptance,
10138
+ admissionLease: admission,
10139
+ resetRetryReplaySafety: true,
10140
+ });
10141
+ } catch (error) {
10142
+ if (isPromptPreflightCancelledError(error) || !durableAcceptanceCompleted) {
10143
+ preflightCancelled = true;
10144
+ await activationSeed?.rollback();
10145
+ }
10146
+ throw error;
10147
+ } finally {
10148
+ if (!preflightCancelled) await this.#syncSkillPromptActiveStateSafely(customMessage, false);
10149
+ }
10150
+ },
10151
+ options?.preflightSignal,
10152
+ );
8877
10153
  }
8878
10154
 
8879
10155
  async #promptWithMessage(
@@ -8881,7 +10157,12 @@ export class AgentSession {
8881
10157
  expandedText: string,
8882
10158
  options?: Pick<
8883
10159
  PromptOptions,
8884
- "toolChoice" | "images" | "skipCompactionCheck" | "onPreflightAccepted" | "onPreflightAcceptCommit"
10160
+ | "toolChoice"
10161
+ | "images"
10162
+ | "skipCompactionCheck"
10163
+ | "onPreflightAccepted"
10164
+ | "onPreflightAcceptCommit"
10165
+ | "preflightSignal"
8885
10166
  > & {
8886
10167
  prependMessages?: AgentMessage[];
8887
10168
  skipPostPromptRecoveryWait?: boolean;
@@ -8893,16 +10174,59 @@ export class AgentSession {
8893
10174
  },
8894
10175
  ): Promise<void> {
8895
10176
  this.#assertNoHandoffTransition();
8896
- await this.#agentEndPublicationPromise;
10177
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
10178
+ await awaitPromptInvocationPreflight(this.#agentEndPublicationPromise, options?.preflightSignal);
8897
10179
  // Re-check after the publication await: a handoff can engage during that
8898
10180
  // window, and #beginInFlight below would otherwise start a turn against the
8899
10181
  // session being handed off.
8900
10182
  this.#assertNoHandoffTransition();
8901
10183
  this.#beginInFlight();
10184
+ // Discard hidden next-turn successors queued by a PREVIOUS turn that a
10185
+ // terminal abort closed. This must run BEFORE the admission bump below:
10186
+ // once the new root turn advances the epoch, the fence lookup can no
10187
+ // longer find the aborted turn's scope, and the pending messages would
10188
+ // otherwise be injected into this new prompt (review thread P2).
10189
+ if (this.#pendingNextTurnMessages.length > 0 && this.#isTurnContinuationBlocked()) {
10190
+ // Discard only TURN-origin hidden successors of the fenced attempt;
10191
+ // external/background next-turn context (extension triggers,
10192
+ // autoresearch) must survive (review thread P2). AUTHORIZED
10193
+ // owned-completion envelopes are preserved too: a monitor
10194
+ // notification queued via the deferred path (default origin "turn")
10195
+ // is an authorized completion from work left running by a PRIOR
10196
+ // scope:"turn" abort and must still reach followUp/prompt (review
10197
+ // thread P1).
10198
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(
10199
+ entry => entry.origin !== "turn" || ownedCompletionResumeAction(entry.message as never) !== "ordinary",
10200
+ );
10201
+ }
10202
+ // NEW ROOT TURN: advance the attempt epoch before minting the lineage so
10203
+ // consecutive non-aborted turns never share (lineageIdHash, epoch). A
10204
+ // terminal abort of turn B must never capture turn A's left-running
10205
+ // owned work, and turn A's completion must never classify as a resume of
10206
+ // B. Same-turn continuations (auto-continue/retry, resetRetryReplaySafety
10207
+ // unset) keep the turn's epoch and lineage.
10208
+ if (options?.resetRetryReplaySafety) {
10209
+ this.#promptGeneration++;
10210
+ this.#promptPreflightAbortController.abort();
10211
+ this.#promptPreflightAbortController = new AbortController();
10212
+ }
8902
10213
  const predecessorAgentEndHold =
8903
10214
  options?.predecessorAgentEndHold ?? this.#reserveDeferredAgentEndForContinuation();
8904
10215
  const generation = this.#promptGeneration;
8905
- const preflightSignal = this.#promptPreflightAbortController.signal;
10216
+ // Mint the immutable lineage identity for this prompt turn before the
10217
+ // model runs; beforeToolCall attaches this lineage + attempt epoch to
10218
+ // each tool call id so background registrations can be classified by
10219
+ // source later (terminal-abort owned-completion vs turn-continuation).
10220
+ this.#turnLineageIdHash = mintTurnLineageIdHash(
10221
+ this.sessionManager.getSessionId?.() ?? "local",
10222
+ generation,
10223
+ this.#terminalLineageSecret,
10224
+ );
10225
+
10226
+ const sessionPreflightSignal = this.#promptPreflightAbortController.signal;
10227
+ const preflightSignal = options?.preflightSignal
10228
+ ? AbortSignal.any([sessionPreflightSignal, options.preflightSignal])
10229
+ : sessionPreflightSignal;
8906
10230
  const rosterClaim = this.#claimIrcRosterCandidate();
8907
10231
  let hasPendingNextTurnMessages = false;
8908
10232
  let pendingNextTurnMessageCount = 0;
@@ -8949,7 +10273,7 @@ export class AgentSession {
8949
10273
  await this.#checkEstimatedContextBeforePrompt([
8950
10274
  ...(options?.prependMessages ?? []),
8951
10275
  message,
8952
- ...this.#pendingNextTurnMessages,
10276
+ ...this.#pendingNextTurnMessages.map(entry => entry.message),
8953
10277
  ]);
8954
10278
  }
8955
10279
 
@@ -9108,8 +10432,14 @@ export class AgentSession {
9108
10432
  messages.push(message);
9109
10433
 
9110
10434
  // Re-present captured pending next-turn messages (never drained here).
9111
- for (const msg of this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount)) {
9112
- messages.push(msg);
10435
+ // Reclassify deferred envelopes before injecting them into the new
10436
+ // root turn: a monitor notification queued during/after a scope:"owned"
10437
+ // abort must be dropped (and fresh ones allocate a fresh lineage)
10438
+ // even though the deferred queueing path never ran the classifier
10439
+ // (review thread P1).
10440
+ for (const entry of this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount)) {
10441
+ if (ownedCompletionResumeAction(entry.message as never) === "drop") continue;
10442
+ messages.push(entry.message);
9113
10443
  }
9114
10444
  messages.push(...fileMentionMessages);
9115
10445
  if (hindsightRecall) {
@@ -9153,6 +10483,15 @@ export class AgentSession {
9153
10483
  attemptMessages = undefined;
9154
10484
  },
9155
10485
  };
10486
+ // Abort can race asynchronous preflight work. The injection signatures were
10487
+ // consumed while building context, but no prompt was accepted, so reset them.
10488
+ if (this.#isPromptPreflightCancelled(generation, preflightSignal) || options?.preflightSignal?.aborted) {
10489
+ this.#resetInjectedContextSignatures();
10490
+ // Ack-waiting callers are told the preflight never ran; direct callers
10491
+ // (aborted after setup) resolve gracefully as before f24f46ff5.
10492
+ if (options?.onPreflightAccepted || options?.onPreflightAcceptCommit) throw promptPreflightCancelledError();
10493
+ return;
10494
+ }
9156
10495
 
9157
10496
  const agentPromptOptions = {
9158
10497
  ...(options?.toolChoice ? { toolChoice: options.toolChoice } : undefined),
@@ -9168,11 +10507,20 @@ export class AgentSession {
9168
10507
  this.getHindsightSessionState()?.markRecallSnippetInjected(hindsightRecall);
9169
10508
  }
9170
10509
  if (pendingNextTurnMessageCount > 0) {
10510
+ // The captured prefix is consumed here whether its entries were
10511
+ // injected or dropped. Settle every owned envelope at this
10512
+ // consumption boundary; the helper retains registrations whose
10513
+ // jobs are still running, while non-persistent monitor jobs
10514
+ // already cancelled after delivery cannot produce another
10515
+ // settlement (review thread P2).
10516
+ const consumedPrefix = this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount);
10517
+ this.#settleDeliveredOwnedRegistrations(consumedPrefix.map(entry => entry.message));
9171
10518
  this.#pendingNextTurnMessages.splice(0, pendingNextTurnMessageCount);
9172
10519
  }
9173
10520
  if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
9174
10521
  },
9175
10522
  };
10523
+
9176
10524
  await this.#promptAgentWithIdleRetry(preSubmit, agentPromptOptions, predecessorAgentEndHold, {
9177
10525
  signal: preflightSignal,
9178
10526
  resourceRunId: this.#runResourceLeaseContext.getStore()?.resourceRunId,
@@ -9457,15 +10805,21 @@ export class AgentSession {
9457
10805
  async #queueSteer(
9458
10806
  text: string,
9459
10807
  images?: ImageContent[],
9460
- options?: { claimsGenuineUserIntent?: boolean },
10808
+ options?: { claimsGenuineUserIntent?: boolean; onPromoted?: () => void; external?: boolean },
9461
10809
  ): Promise<void> {
9462
10810
  this.#assertNoHandoffTransition();
9463
10811
  assertImagePlaceholdersHavePayload(text, images);
9464
10812
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
9465
- this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText));
10813
+ const displayEntry = this.#createQueuedDisplayEntry(displayText);
10814
+ this.#steeringMessages.push(displayEntry);
9466
10815
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
9467
10816
  if (images && images.length > 0) content.push(...images);
9468
10817
  const message = { role: "user" as const, content, attribution: "user" as const, timestamp: Date.now() };
10818
+ if (options?.external) {
10819
+ this.#externalSteerMessages.add(message);
10820
+ this.#externalSteerAdmissionSeq.set(message, ++this.#steeringAdmissionSeq);
10821
+ if (options.onPromoted) this.#steerPromotionHooks.set(message, options.onPromoted);
10822
+ }
9469
10823
  if (options?.claimsGenuineUserIntent) {
9470
10824
  const epoch = this.#claimDeepInterviewUserIntent();
9471
10825
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
@@ -9482,6 +10836,7 @@ export class AgentSession {
9482
10836
  if (!this.#cancelAndSubmitInProgress && this.#canAutoContinueForSteer()) {
9483
10837
  this.#scheduleAgentContinue({
9484
10838
  shouldContinue: () => this.#canAutoContinueForSteer() && this.agent.hasQueuedSteering(),
10839
+ rescheduleOnBusy: true,
9485
10840
  continueQueuedOnly: true,
9486
10841
  });
9487
10842
  }
@@ -9493,29 +10848,95 @@ export class AgentSession {
9493
10848
  async #queueFollowUp(
9494
10849
  text: string,
9495
10850
  images?: ImageContent[],
9496
- options?: { forceOneAtATime?: boolean; claimsGenuineUserIntent?: boolean },
9497
- ): Promise<void> {
10851
+ options?: {
10852
+ forceOneAtATime?: boolean;
10853
+ claimsGenuineUserIntent?: boolean;
10854
+ onPromoted?: () => void;
10855
+ sdkRunToken?: string;
10856
+ },
10857
+ ): Promise<QueuedFollowUpOwner> {
9498
10858
  this.#assertNoHandoffTransition();
9499
10859
  assertImagePlaceholdersHavePayload(text, images);
9500
10860
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
9501
- this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
10861
+ const queueWasEmpty = !this.agent.hasQueuedMessages();
10862
+ const displayEntry = this.#createQueuedDisplayEntry(displayText);
10863
+ this.#followUpMessages.push(displayEntry);
9502
10864
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
9503
10865
  if (images && images.length > 0) content.push(...images);
9504
10866
  const message = { role: "user" as const, content, attribution: "user" as const, timestamp: Date.now() };
10867
+ this.#externalFollowUps.add(message);
10868
+ if (options?.onPromoted) this.#followUpPromotionHooks.set(message, options.onPromoted);
9505
10869
  if (options?.claimsGenuineUserIntent) {
9506
10870
  const epoch = this.#claimDeepInterviewUserIntent();
9507
10871
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
9508
10872
  }
9509
- this.agent.followUp(message, options?.forceOneAtATime ? { forceOneAtATime: true } : undefined);
9510
- // When fully idle AND the session is in a resumable assistant-ended state,
9511
- // schedule an immediate continue so the queued follow-up is delivered
9512
- // without waiting for the next user turn. We gate on isStreaming (model
9513
- // actively producing), isRetrying (auto-retry backoff is sleeping between
9514
- // attempts, #retryPromise set), and the last message being assistant —
9515
- // agent.continue() only dequeues follow-ups from an assistant-ended state;
9516
- // resuming from user/toolResult state runs an extra model call on the
9517
- // stale prompt before draining the queue.
9518
- this.#scheduleQueuedFollowUpContinuation();
10873
+ if (options?.sdkRunToken) this.#sdkRunTokensByQueuedMessage.set(message, options.sdkRunToken);
10874
+ if (options?.sdkRunToken && (this.agent.state.isStreaming || this.agent.hasQueuedMessages())) {
10875
+ this.#deferredSdkFollowUps.push(message);
10876
+ } else {
10877
+ this.agent.followUp(message, options?.forceOneAtATime ? { forceOneAtATime: true } : undefined);
10878
+ }
10879
+ // When this is the first queued message and the session is in a resumable
10880
+ // assistant-ended state, schedule an immediate continue so it is delivered
10881
+ // without waiting for the next user turn. A later accepted follow-up must
10882
+ // not start unrelated queued work ahead of it, because that work has a
10883
+ // different cancellation and terminal owner.
10884
+ if (queueWasEmpty)
10885
+ this.#scheduleQueuedFollowUpContinuation(() =>
10886
+ this.agent.snapshotFollowUp().some(candidate => candidate === message),
10887
+ );
10888
+ return {
10889
+ cancel: () => {
10890
+ const deferredIndex = this.#deferredSdkFollowUps.indexOf(message);
10891
+ let removed = false;
10892
+ if (deferredIndex !== -1) {
10893
+ this.#deferredSdkFollowUps.splice(deferredIndex, 1);
10894
+ removed = true;
10895
+ } else {
10896
+ removed = this.agent.removeQueuedMessages(candidate => candidate === message).followUp > 0;
10897
+ // This message was already released from the deferred queue; its
10898
+ // scheduled continuation was cancelled before it started. No further
10899
+ // agent_end may arrive to release the next deferred follow-up, so
10900
+ // advance the queue here to keep the next accepted SDK request moving.
10901
+ if (removed) this.#releaseDeferredSdkFollowUps();
10902
+ }
10903
+ if (removed) {
10904
+ this.#followUpMessages = this.#followUpMessages.filter(entry => entry !== displayEntry);
10905
+ this.#deepInterviewGenuineUserMessageEpochs.delete(message);
10906
+ this.#sdkRunTokensByQueuedMessage.delete(message);
10907
+ // A canceled follow-up is never promoted: release its
10908
+ // promotion hook so the callback is not retained for the
10909
+ // session lifetime (review thread P2).
10910
+ this.#followUpPromotionHooks.delete(message);
10911
+ }
10912
+ return removed;
10913
+ },
10914
+ };
10915
+ }
10916
+ #releaseDeferredSdkFollowUps(): void {
10917
+ // A deferred SDK follow-up must become the sole first message at the next
10918
+ // acceptance so its run token is bound to the agent_start. Releasing it
10919
+ // behind still-queued work reproduces the token-less mid-run consumption
10920
+ // hazard, so wait for the queue to drain; the next agent_end retries.
10921
+ if (this.agent.hasQueuedMessages()) return;
10922
+ const message = this.#deferredSdkFollowUps.shift();
10923
+ if (!message) return;
10924
+ this.agent.followUp(message, { forceOneAtATime: true });
10925
+ this.#scheduleAgentContinue({
10926
+ shouldContinue: () => this.#canStartDeferredSdkFollowUp() && this.agent.hasQueuedMessages(),
10927
+ rescheduleOnBusy: true,
10928
+ continueQueuedOnly: true,
10929
+ });
10930
+ }
10931
+ #canStartDeferredSdkFollowUp(): boolean {
10932
+ if (this.agent.state.isStreaming) return false;
10933
+ if (this.isCompacting) return false;
10934
+ if (this.isBashRunning) return false;
10935
+ if (this.isEvalRunning) return false;
10936
+ if (this.isRetrying) return false;
10937
+ const messages = this.agent.state.messages;
10938
+ const last = messages[messages.length - 1];
10939
+ return last?.role === "assistant" || last?.role === "bashExecution" || last?.role === "pythonExecution";
9519
10940
  }
9520
10941
 
9521
10942
  /**
@@ -9531,10 +10952,17 @@ export class AgentSession {
9531
10952
  const last = messages[messages.length - 1];
9532
10953
  return last?.role === "assistant" || last?.role === "bashExecution" || last?.role === "pythonExecution";
9533
10954
  }
9534
- #scheduleQueuedFollowUpContinuation(): void {
9535
- if (!this.#cancelAndSubmitInProgress && this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) {
10955
+ #scheduleQueuedFollowUpContinuation(ownsQueuedMessage: (() => boolean) | undefined = undefined): void {
10956
+ if (
10957
+ !this.#cancelAndSubmitInProgress &&
10958
+ this.#canAutoContinueForFollowUp() &&
10959
+ this.agent.hasQueuedMessages() &&
10960
+ (ownsQueuedMessage?.() ?? true)
10961
+ ) {
9536
10962
  this.#scheduleAgentContinue({
9537
- shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
10963
+ shouldContinue: () =>
10964
+ this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages() && (ownsQueuedMessage?.() ?? true),
10965
+ rescheduleOnBusy: true,
9538
10966
  continueQueuedOnly: true,
9539
10967
  });
9540
10968
  }
@@ -9556,16 +10984,16 @@ export class AgentSession {
9556
10984
  }
9557
10985
 
9558
10986
  queueDeferredMessage(message: CustomMessage): void {
9559
- this.#queueHiddenNextTurnMessage(message, true);
10987
+ this.#queueHiddenNextTurnMessage(message, true, "turn");
9560
10988
  }
9561
10989
 
9562
10990
  queueDeferredMessageForTests(message: CustomMessage, triggerTurn = true): void {
9563
- this.#queueHiddenNextTurnMessage(message, triggerTurn);
10991
+ this.#queueHiddenNextTurnMessage(message, triggerTurn, "turn");
9564
10992
  }
9565
10993
 
9566
10994
  /** Read-only test seam for the hidden next-turn context queue. */
9567
10995
  getPendingNextTurnMessagesForTests(): readonly CustomMessage[] {
9568
- return this.#pendingNextTurnMessages.slice();
10996
+ return this.#pendingNextTurnMessages.map(entry => entry.message);
9569
10997
  }
9570
10998
 
9571
10999
  /** Test-only abort outcome override; undefined retains the production abort race. */
@@ -9573,12 +11001,16 @@ export class AgentSession {
9573
11001
  this.#cancelAndSubmitAbortOutcomeProviderForTests = provider;
9574
11002
  }
9575
11003
 
9576
- #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
11004
+ #queueHiddenNextTurnMessage(
11005
+ message: CustomMessage,
11006
+ triggerTurn: boolean,
11007
+ origin: "turn" | "external" = "external",
11008
+ ): void {
9577
11009
  // A hidden next-turn message queued during a handoff transition would be
9578
11010
  // dropped when the successor clears predecessor queues; reject it as busy so
9579
11011
  // the caller can retry against the settled session.
9580
11012
  this.#assertNoHandoffTransition();
9581
- this.#pendingNextTurnMessages.push(message);
11013
+ this.#pendingNextTurnMessages.push({ message, origin });
9582
11014
  if (!triggerTurn) return;
9583
11015
  const generation = this.#promptGeneration;
9584
11016
  if (this.#scheduledHiddenNextTurnGeneration === generation) {
@@ -9593,6 +11025,27 @@ export class AgentSession {
9593
11025
  if (this.#pendingNextTurnMessages.length === 0) {
9594
11026
  return;
9595
11027
  }
11028
+ // Terminal abort closed this turn's continuation fence: a hidden
11029
+ // next-turn successor queued by the aborted turn must NOT start,
11030
+ // even though the scheduler's generation check still passes
11031
+ // (the abort preserves the epoch so the gate can find the scope).
11032
+ if (this.#isTurnContinuationBlocked()) {
11033
+ // Terminal abort closed this turn's continuation fence:
11034
+ // DISCARD only the turn-origin hidden successors queued by the
11035
+ // aborted turn so they cannot be drained into a later explicit
11036
+ // prompt; external/background next-turn context survives
11037
+ // (review thread P2). AUTHORIZED owned-completion envelopes
11038
+ // survive too: a left-running owned job's completion queued
11039
+ // via sendCustomMessage (default origin "turn") is a promised
11040
+ // resume of the root worker, not a continuation of the aborted
11041
+ // attempt, and #promptQueuedHiddenNextTurnMessages classifies
11042
+ // it fresh (review thread P1).
11043
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(
11044
+ entry =>
11045
+ entry.origin !== "turn" || ownedCompletionResumeAction(entry.message as never) !== "ordinary",
11046
+ );
11047
+ return;
11048
+ }
9596
11049
  try {
9597
11050
  await this.#promptQueuedHiddenNextTurnMessages();
9598
11051
  } catch {
@@ -9617,12 +11070,47 @@ export class AgentSession {
9617
11070
 
9618
11071
  const queuedMessages = [...this.#pendingNextTurnMessages];
9619
11072
  this.#pendingNextTurnMessages = [];
9620
- const message = queuedMessages[queuedMessages.length - 1];
11073
+ // Reclassify deferred envelopes at the drain boundary: a monitor
11074
+ // notification queued via the deferAgentInitiatedTurns branch never ran
11075
+ // ownedCompletionResumeAction at queue time, so a scope:"owned" abort
11076
+ // that landed since may now classify the envelope as drop — drop it and
11077
+ // allocate a fresh lineage for fresh envelopes (review thread P1).
11078
+ const dropped: Array<{ message: CustomMessage }> = [];
11079
+ const reclassified = queuedMessages.filter(entry => {
11080
+ if (ownedCompletionResumeAction(entry.message as never) === "drop") {
11081
+ dropped.push(entry);
11082
+ return false;
11083
+ }
11084
+ return true;
11085
+ });
11086
+ // A reclassified drop (owned scope, terminal job) never reaches a
11087
+ // follow-up/prompt boundary: settle its registrations here so the
11088
+ // terminal tuple does not occupy the global registration and
11089
+ // retained-policy capacities indefinitely (review thread P2).
11090
+ if (dropped.length > 0) {
11091
+ this.#settleDeliveredOwnedRegistrations(dropped.map(entry => entry.message));
11092
+ }
11093
+ if (reclassified.length === 0) {
11094
+ return;
11095
+ }
11096
+ // Allocate a fresh root-turn lineage when the drained batch contains a
11097
+ // fresh owned envelope OR an EXTERNAL hidden trigger: an external
11098
+ // nextTurn message has no owned envelope, so without this it would
11099
+ // reuse the previous turn's identical lineage+epoch and a later
11100
+ // scope:"owned" abort of the external turn could capture the previous
11101
+ // turn's jobs (review thread P1).
11102
+ if (
11103
+ reclassified.some(entry => ownedCompletionResumeAction(entry.message as never) === "fresh") ||
11104
+ reclassified.some(entry => entry.origin === "external")
11105
+ ) {
11106
+ this.#resumeFromOwnedCompletion();
11107
+ }
11108
+ const message = reclassified[reclassified.length - 1]?.message;
9621
11109
  if (!message) {
9622
11110
  return;
9623
11111
  }
9624
11112
 
9625
- const prependMessages = queuedMessages.slice(0, -1);
11113
+ const prependMessages = reclassified.slice(0, -1).map(entry => entry.message);
9626
11114
  const textContent = this.#getCustomMessageTextContent(message);
9627
11115
  await this.#syncSkillPromptActiveStateSafely(message, true);
9628
11116
  try {
@@ -9631,13 +11119,47 @@ export class AgentSession {
9631
11119
  skipPostPromptRecoveryWait: true,
9632
11120
  });
9633
11121
  } catch (error) {
9634
- this.#pendingNextTurnMessages = [...queuedMessages, ...this.#pendingNextTurnMessages];
11122
+ // Requeue only the SURVIVING reclassified entries: a completion
11123
+ // denied by scope:"owned" was settled (dropped) above and must
11124
+ // never reach a later prompt — once its scope and now-unoccupied
11125
+ // policy tombstone are evicted by the bounded registries, the
11126
+ // restored external monitor message would classify as ordinary and
11127
+ // breach the owned abort's zero-delivery guarantee (review thread
11128
+ // P2).
11129
+ this.#pendingNextTurnMessages = [...reclassified, ...this.#pendingNextTurnMessages];
9635
11130
  throw error;
9636
11131
  } finally {
9637
11132
  await this.#syncSkillPromptActiveStateSafely(message, false);
9638
11133
  }
9639
11134
  }
9640
11135
 
11136
+ /** Unregister owned-completion registrations once their delivery is settled
11137
+ * (delivered as a fresh/ordinary turn or dropped by an owned scope) AND the
11138
+ * underlying job is TERMINAL. A monitor notification reuses the still-running
11139
+ * monitor's registration, so removing it after an intermediate notification
11140
+ * would let a later scope:"owned" abort miss the monitor and make subsequent
11141
+ * notifications lose their envelope (review thread P1). */
11142
+ #settleDeliveredOwnedRegistrations(messages: AgentMessage[]): void {
11143
+ // Use the SESSION-OWNED manager first: the process-global instance is
11144
+ // the last-created session, so checking this session's delivery against
11145
+ // another session's manager (which lacks the same local job id) would
11146
+ // see job === undefined and remove a still-live registration (review
11147
+ // thread P1).
11148
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
11149
+ for (const message of messages) {
11150
+ const details = (message as { details?: { ownedCompletions?: OwnedCompletionEnvelope[] } }).details;
11151
+ for (const envelope of details?.ownedCompletions ?? []) {
11152
+ const job = manager?.getJob(envelope.registration.jobId);
11153
+ const status = job?.generation === envelope.registration.jobGeneration ? job?.status : undefined;
11154
+ // Evicted jobs have no live record (job === undefined); terminal
11155
+ // statuses settle the registration.
11156
+ if (job === undefined || status === "completed" || status === "cancelled" || status === "failed") {
11157
+ unregisterOwnedRegistration(envelope.registration);
11158
+ }
11159
+ }
11160
+ }
11161
+ }
11162
+
9641
11163
  #getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
9642
11164
  if (typeof message.content === "string") {
9643
11165
  return message.content;
@@ -9679,6 +11201,13 @@ export class AgentSession {
9679
11201
  triggerTurn?: boolean;
9680
11202
  deliverAs?: "steer" | "followUp" | "nextTurn";
9681
11203
  followUpQueuePolicy?: "respect-mode" | "sequential";
11204
+ /** INTERNAL trusted option: origin of a hidden next-turn message —
11205
+ * "turn" (produced by the current turn's continuation machinery,
11206
+ * purged by a terminal abort) or "external" (background producers,
11207
+ * preserved). Extension sendMessage callers are classified
11208
+ * "external" at the trusted bridge and never supply this bit
11209
+ * (review thread P2). */
11210
+ origin?: "turn" | "external";
9682
11211
  },
9683
11212
  ): Promise<void> {
9684
11213
  this.#assertRecoveryHydrationPromoted();
@@ -9703,7 +11232,7 @@ export class AgentSession {
9703
11232
  // while an (auto-)handoff is unwinding it.
9704
11233
  this.#assertNoHandoffTransition();
9705
11234
  if (options?.deliverAs === "nextTurn") {
9706
- this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
11235
+ this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false, options?.origin ?? "turn");
9707
11236
  return;
9708
11237
  }
9709
11238
 
@@ -9721,9 +11250,13 @@ export class AgentSession {
9721
11250
  if (options?.deliverAs === "nextTurn") {
9722
11251
  if (options?.triggerTurn) {
9723
11252
  if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
9724
- this.#queueHiddenNextTurnMessage(appMessage, false);
11253
+ this.#queueHiddenNextTurnMessage(appMessage, false, options?.origin ?? "turn");
9725
11254
  return;
9726
11255
  }
11256
+ // Every direct idle admission is a NEW ROOT TURN: allocate a fresh
11257
+ // lineage so the turn never remints the previous turn's identical
11258
+ // lineage+epoch (review thread P1).
11259
+ this.#resumeFromOwnedCompletion();
9727
11260
  await this.#syncSkillPromptActiveStateSafely(appMessage, true);
9728
11261
  try {
9729
11262
  await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), {
@@ -9731,6 +11264,11 @@ export class AgentSession {
9731
11264
  });
9732
11265
  } finally {
9733
11266
  await this.#syncSkillPromptActiveStateSafely(appMessage, false);
11267
+ // The direct idle admission bypasses onFollowUpConsumed:
11268
+ // settle any delivered owned-completion envelope so a
11269
+ // terminal registration does not occupy the registry until
11270
+ // saturation (review thread P2).
11271
+ this.#settleDeliveredOwnedRegistrations([appMessage]);
9734
11272
  }
9735
11273
  return;
9736
11274
  }
@@ -9752,6 +11290,27 @@ export class AgentSession {
9752
11290
  this.#queueHiddenNextTurnMessage(appMessage, false);
9753
11291
  return;
9754
11292
  }
11293
+ // Gate owned-completion deliveries at this IDLE boundary too: the
11294
+ // monitor notification envelope must DROP for scope:"owned" (racing
11295
+ // settlement) and allocate a FRESH lineage for scope:"turn" — the
11296
+ // direct prompt path below bypasses the follow-up consumption
11297
+ // classification (review thread P1).
11298
+ const idleResumeAction = ownedCompletionResumeAction(appMessage as never);
11299
+ if (idleResumeAction === "drop") {
11300
+ // The notification is denied (owned scope) but its job may be
11301
+ // terminal: settle the envelope's registrations before
11302
+ // discarding it, otherwise the tuple occupies the global
11303
+ // registration and retained-policy bounds indefinitely
11304
+ // (review thread P2).
11305
+ this.#settleDeliveredOwnedRegistrations([appMessage]);
11306
+ return;
11307
+ }
11308
+ // Every direct idle admission is a NEW ROOT TURN: allocate a fresh
11309
+ // lineage even for ordinary triggers (cron/extension). Without this,
11310
+ // the turn would remint the previous turn's identical lineage+epoch
11311
+ // and a later scope:"owned" abort could capture the earlier turn's
11312
+ // unrelated jobs (review thread P1).
11313
+ this.#resumeFromOwnedCompletion();
9755
11314
  await this.#syncSkillPromptActiveStateSafely(appMessage, true);
9756
11315
  try {
9757
11316
  await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), {
@@ -9759,6 +11318,13 @@ export class AgentSession {
9759
11318
  });
9760
11319
  } finally {
9761
11320
  await this.#syncSkillPromptActiveStateSafely(appMessage, false);
11321
+ // The direct idle admission bypasses onFollowUpConsumed:
11322
+ // settle any delivered owned-completion envelope so a terminal
11323
+ // registration does not occupy the registry until saturation —
11324
+ // a non-persistent monitor cancels right after its first line
11325
+ // and enqueues no delivery that would settle it otherwise
11326
+ // (review thread P2).
11327
+ this.#settleDeliveredOwnedRegistrations([appMessage]);
9762
11328
  }
9763
11329
  return;
9764
11330
  }
@@ -9786,13 +11352,13 @@ export class AgentSession {
9786
11352
  }
9787
11353
  const agentRemoved = this.agent.removeQueuedMessages(isMatch);
9788
11354
  const beforeNext = this.#pendingNextTurnMessages.length;
9789
- for (const m of this.#pendingNextTurnMessages) {
9790
- if (predicate(m)) {
9791
- const tag = readPendingDisplayTag(m.details);
11355
+ for (const entry of this.#pendingNextTurnMessages) {
11356
+ if (predicate(entry.message)) {
11357
+ const tag = readPendingDisplayTag(entry.message.details);
9792
11358
  if (tag) removedTags.add(tag);
9793
11359
  }
9794
11360
  }
9795
- this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !predicate(m));
11361
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(entry => !predicate(entry.message));
9796
11362
  const pendingNextTurn = beforeNext - this.#pendingNextTurnMessages.length;
9797
11363
  let displaySteering = 0;
9798
11364
  let displayFollowUp = 0;
@@ -9825,11 +11391,26 @@ export class AgentSession {
9825
11391
  content: string | (TextContent | ImageContent)[],
9826
11392
  options?: {
9827
11393
  deliverAs?: "steer" | "followUp";
11394
+ /** Preserve a busy SDK dispatch as queued work across an admission fence. */
11395
+ queuedAtDispatch?: boolean;
9828
11396
  onPreflightAccepted?: () => void;
9829
11397
  onPreflightAcceptCommit?: () => void | Promise<void>;
11398
+ /** Fired when a queued submission (steering or follow-up) is promoted to its own run (SDK ownership correlation). */
11399
+ onQueuedPromoted?: () => void;
11400
+ preflightSignal?: AbortSignal;
11401
+ sdkRunToken?: string;
9830
11402
  },
9831
11403
  ): Promise<void> {
9832
11404
  this.#assertRecoveryHydrationPromoted();
11405
+ const owner = this.#sessionAdmissionContext.getStore();
11406
+ if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
11407
+ this.#assertSessionAdmissionOpen();
11408
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
11409
+ if (typeof content !== "string" && !Array.isArray(content)) {
11410
+ throw Object.assign(new Error("sendUserMessage requires string or content-array content."), {
11411
+ code: "invalid_input",
11412
+ });
11413
+ }
9833
11414
  // Normalize content to text string + optional images
9834
11415
  let text: string;
9835
11416
  let images: ImageContent[] | undefined;
@@ -9850,38 +11431,145 @@ export class AgentSession {
9850
11431
  if (images.length === 0) images = undefined;
9851
11432
  }
9852
11433
 
9853
- if (options?.deliverAs === "followUp") {
9854
- if (options.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
9855
- await this.#queueFollowUp(text, images, { claimsGenuineUserIntent: true });
9856
- options.onPreflightAccepted?.();
9857
- return;
9858
- }
9859
- if (options?.deliverAs === "steer") {
9860
- if (options.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
9861
- await this.#queueSteer(text, images, { claimsGenuineUserIntent: true });
9862
- options.onPreflightAccepted?.();
9863
- return;
9864
- }
11434
+ const admissionSignal = options?.preflightSignal
11435
+ ? AbortSignal.any([this.#promptPreflightAbortController.signal, options.preflightSignal])
11436
+ : this.#promptPreflightAbortController.signal;
11437
+ const preflightCancellationGeneration = this.#promptPreflightCancellationGeneration;
11438
+ // Classify and reserve follow-up order synchronously, before any await
11439
+ // (selection fence or durable acceptance): a follow-up dispatch that is
11440
+ // not yet durably enqueued must already count as ahead, or a later plain
11441
+ // prompt admitted through the same window would classify itself as fresh
11442
+ // delivery/steering and overtake it. The reservation is released once
11443
+ // the durable enqueue settles either way, so a rejected or cancelled
11444
+ // acceptance leaves no phantom ordering behind.
11445
+ const queuedPlainPrompt = options?.queuedAtDispatch === true && options.deliverAs === undefined;
11446
+ const hasFollowUpAhead = (): boolean =>
11447
+ this.#activeFollowUpReservationEpochs.size > 0 ||
11448
+ this.agent.snapshotFollowUp().length > 0 ||
11449
+ this.#deferredSdkFollowUps.length > 0;
11450
+ const followUpAheadAtReservation = queuedPlainPrompt && hasFollowUpAhead();
11451
+ const freshAtReservation =
11452
+ queuedPlainPrompt &&
11453
+ !followUpAheadAtReservation &&
11454
+ !this.agent.state.isStreaming &&
11455
+ !this.#canAutoContinueForSteer();
11456
+ const deliverAs =
11457
+ options?.deliverAs ??
11458
+ (queuedPlainPrompt
11459
+ ? followUpAheadAtReservation
11460
+ ? "followUp"
11461
+ : freshAtReservation
11462
+ ? undefined
11463
+ : "steer"
11464
+ : undefined);
11465
+ const followUpReservationEpoch = deliverAs === "followUp" ? ++this.#followUpReservationEpoch : undefined;
11466
+ if (followUpReservationEpoch !== undefined) {
11467
+ this.#activeFollowUpReservationEpochs.add(followUpReservationEpoch);
11468
+ }
11469
+ const releaseFollowUpReservation = () => {
11470
+ if (followUpReservationEpoch === undefined) return;
11471
+ this.#activeFollowUpReservationEpochs.delete(followUpReservationEpoch);
11472
+ const waiters = [...this.#followUpReservationDrainWaiters];
11473
+ this.#followUpReservationDrainWaiters.clear();
11474
+ for (const waiter of waiters) waiter();
11475
+ };
11476
+ try {
11477
+ if (this.#pendingSelectionFences > 0) {
11478
+ await awaitPromptInvocationPreflight(this.#selectionFenceTail, admissionSignal);
11479
+ }
11480
+ const assertPreflightStillOpen = () => {
11481
+ this.#assertSessionAdmissionOpen();
11482
+ if (
11483
+ options?.preflightSignal?.aborted ||
11484
+ this.#promptPreflightCancellationGeneration !== preflightCancellationGeneration
11485
+ ) {
11486
+ throw promptPreflightCancelledError();
11487
+ }
11488
+ };
11489
+ assertPreflightStillOpen();
11490
+ if (deliverAs === "followUp") {
11491
+ // Durable enqueue preserves reservation order: while an earlier
11492
+ // follow-up dispatch is still between its reservation and its own
11493
+ // durable enqueue, wait for those earlier reservations so this
11494
+ // dispatch can never enqueue ahead of them.
11495
+ if (followUpReservationEpoch !== undefined) {
11496
+ await this.#waitForEarlierFollowUpReservations(
11497
+ followUpReservationEpoch,
11498
+ options?.preflightSignal,
11499
+ admissionSignal,
11500
+ );
11501
+ }
11502
+ if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
11503
+ assertPreflightStillOpen();
11504
+ const queuedFollowUp = await this.#queueFollowUp(text, images, {
11505
+ claimsGenuineUserIntent: true,
11506
+ forceOneAtATime: Boolean(options?.preflightSignal || options?.queuedAtDispatch),
11507
+ onPromoted: options?.onQueuedPromoted,
11508
+ sdkRunToken: options?.sdkRunToken,
11509
+ });
11510
+ const cancelQueuedFollowUp = () => queuedFollowUp.cancel();
11511
+ options?.preflightSignal?.addEventListener("abort", cancelQueuedFollowUp, { once: true });
11512
+ if (options?.preflightSignal?.aborted) cancelQueuedFollowUp();
11513
+ options?.onPreflightAccepted?.();
11514
+ return;
11515
+ }
11516
+ if (deliverAs === "steer") {
11517
+ if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
11518
+ assertPreflightStillOpen();
11519
+ await this.#queueSteer(text, images, {
11520
+ claimsGenuineUserIntent: true,
11521
+ onPromoted: options?.onQueuedPromoted,
11522
+ external: true,
11523
+ });
11524
+ options?.onPreflightAccepted?.();
11525
+ return;
11526
+ }
9865
11527
 
9866
- // No explicit delivery mode: only a live stream makes prompt() throw
9867
- // AgentBusyError, so queue the message as steering while streaming.
9868
- // Compaction is intentionally NOT diverted here: prompt() handles an
9869
- // in-flight compaction internally, and #queueSteer would otherwise park
9870
- // the message in the steering queue with no turn to consume it.
9871
- if (this.isStreaming) {
9872
- if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
9873
- await this.#queueSteer(text, images, { claimsGenuineUserIntent: true });
9874
- options?.onPreflightAccepted?.();
9875
- return;
9876
- }
11528
+ // No explicit delivery mode: only a live stream makes prompt() throw
11529
+ // AgentBusyError, so queue the message as steering while streaming.
11530
+ // Compaction is intentionally NOT diverted here: prompt() handles an
11531
+ // in-flight compaction internally, and #queueSteer would otherwise park
11532
+ // the message in the steering queue with no turn to consume it.
11533
+ if (this.isStreaming) {
11534
+ if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
11535
+ assertPreflightStillOpen();
11536
+ await this.#queueSteer(text, images, {
11537
+ claimsGenuineUserIntent: true,
11538
+ onPromoted: options?.onQueuedPromoted,
11539
+ external: true,
11540
+ });
11541
+ options?.onPreflightAccepted?.();
11542
+ return;
11543
+ }
9877
11544
 
9878
- // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
9879
- await this.prompt(text, {
9880
- expandPromptTemplates: false,
9881
- images,
9882
- onPreflightAccepted: options?.onPreflightAccepted,
9883
- onPreflightAcceptCommit: options?.onPreflightAcceptCommit,
9884
- });
11545
+ // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
11546
+ let queuedPromotionFired = false;
11547
+ const fireQueuedPromotion = () => {
11548
+ if (!freshAtReservation || queuedPromotionFired) return;
11549
+ queuedPromotionFired = true;
11550
+ options?.onQueuedPromoted?.();
11551
+ };
11552
+ await this.prompt(text, {
11553
+ expandPromptTemplates: false,
11554
+ images,
11555
+ onPreflightAccepted: () => {
11556
+ options?.onPreflightAccepted?.();
11557
+ fireQueuedPromotion();
11558
+ },
11559
+ onPreflightAcceptCommit:
11560
+ options?.onPreflightAcceptCommit || freshAtReservation
11561
+ ? async () => {
11562
+ if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
11563
+ else options?.onPreflightAccepted?.();
11564
+ assertPreflightStillOpen();
11565
+ fireQueuedPromotion();
11566
+ }
11567
+ : undefined,
11568
+ preflightSignal: options?.preflightSignal,
11569
+ });
11570
+ } finally {
11571
+ releaseFollowUpReservation();
11572
+ }
9885
11573
  }
9886
11574
 
9887
11575
  /**
@@ -9893,6 +11581,7 @@ export class AgentSession {
9893
11581
  const followUp = this.#followUpMessages.map(e => e.text);
9894
11582
  this.#steeringMessages = [];
9895
11583
  this.#followUpMessages = [];
11584
+ this.#deferredSdkFollowUps = [];
9896
11585
  this.agent.clearAllQueues();
9897
11586
  return { steering, followUp };
9898
11587
  }
@@ -10259,6 +11948,7 @@ export class AgentSession {
10259
11948
  this.#markRetryReplayUnsafe();
10260
11949
  this.abortRetry();
10261
11950
  this.#promptGeneration++;
11951
+ this.#promptPreflightCancellationGeneration++;
10262
11952
  this.#promptPreflightAbortController.abort();
10263
11953
  this.#promptPreflightAbortController = new AbortController();
10264
11954
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -10331,6 +12021,7 @@ export class AgentSession {
10331
12021
  delayMs: 1,
10332
12022
  generation: this.#promptGeneration,
10333
12023
  shouldContinue: () => this.agent.hasQueuedSteering(),
12024
+ rescheduleOnBusy: true,
10334
12025
  continueQueuedOnly: true,
10335
12026
  });
10336
12027
  }
@@ -10361,20 +12052,370 @@ export class AgentSession {
10361
12052
  if (outcome.kind === "error") throw outcome.cause;
10362
12053
  }
10363
12054
  /**
10364
- * Abort a specific active prompt and prove whether its tracked resources settled.
12055
+ * Private terminal-abort seam: read the CURRENT turn's attempt epoch WITHOUT
12056
+ * interrupting it. Used to write the durable initial terminal marker BEFORE
12057
+ * any fence/stop effect (plan ordered step 4). Only the epoch is exposed —
12058
+ * never the opaque lineage handle — so no private origin metadata leaves the
12059
+ * session. Fails closed (undefined) when no active turn lineage exists.
12060
+ */
12061
+ /**
12062
+ * Private terminal-abort seam: cancel a PENDING (not-yet-started) prompt
12063
+ * preflight. Aborting the preflight controller fires the captured admission
12064
+ * signal so #throwIfPromptPreflightCancelled throws and the pending prompt
12065
+ * never starts even if its SDK waiter was already settled; the controller is
12066
+ * reset for the next admission. No run handle exists for a preflight prompt.
12067
+ */
12068
+ cancelPendingPreflightForTerminalAbort(): void {
12069
+ this.#promptPreflightCancellationGeneration++;
12070
+ this.#promptPreflightAbortController.abort();
12071
+ this.#promptPreflightAbortController = new AbortController();
12072
+ }
12073
+ /**
12074
+ * Capture the steering admission snapshot at abort ADMISSION: the host
12075
+ * invokes this before its durable marker transaction, so client steering
12076
+ * admitted while the abort is in flight classifies as post-snapshot and is
12077
+ * preserved instead of being purged at the later abortPromptAndWait
12078
+ * (review thread P1). Snapshots are stored PER-ADMISSION in a per-turn
12079
+ * FIFO: the settlement of each admission consumes its own captured
12080
+ * sequence, so a later overlapping abort of the same turn can never
12081
+ * overwrite an earlier admission's snapshot and purge an already-accepted
12082
+ * steer (review thread P1).
12083
+ */
12084
+ captureTerminalAbortSteeringSnapshot(): number | undefined {
12085
+ if (this.#turnLineageIdHash === undefined) return undefined;
12086
+ const key = `${this.#turnLineageIdHash}:${this.#promptGeneration}`;
12087
+ const queue = this.#terminalAbortSteeringSnapshots.get(key) ?? [];
12088
+ const token = ++this.#terminalAbortAdmissionSeq;
12089
+ queue.push({ seq: this.#steeringAdmissionSeq, token });
12090
+ this.#terminalAbortSteeringSnapshots.set(key, queue);
12091
+ this.#terminalAbortSteeringSnapshotKeys.set(token, key);
12092
+ return token;
12093
+ }
12094
+ /**
12095
+ * Discard a captured snapshot whose admission never settles (a durable
12096
+ * same-key replay, no-effect, conflict, or marker failure): the entry is
12097
+ * keyed to the CURRENT turn, which is where its admission captured it.
12098
+ * Removing by token keeps overlapping admissions' entries independent.
12099
+ */
12100
+ /**
12101
+ * Rebind a captured snapshot to the CURRENT turn: the owner-mismatch
12102
+ * fall-through may terminalize the aborting requester's own turn that won
12103
+ * the race, while the token was captured under the other connection's
12104
+ * turn. The entry moves to the current turn's FIFO (retaining the original
12105
+ * admission sequence) so the settlement's purge classifies steering
12106
+ * admitted since admission as post-snapshot (review thread P1).
12107
+ */
12108
+ rebindTerminalAbortSteeringSnapshot(token: number): void {
12109
+ const capturedKey = this.#terminalAbortSteeringSnapshotKeys.get(token);
12110
+ if (capturedKey === undefined) return;
12111
+ if (this.#turnLineageIdHash === undefined) return;
12112
+ const currentKey = `${this.#turnLineageIdHash}:${this.#promptGeneration}`;
12113
+ if (capturedKey === currentKey) return;
12114
+ const capturedQueue = this.#terminalAbortSteeringSnapshots.get(capturedKey);
12115
+ const index = capturedQueue?.findIndex(entry => entry.token === token) ?? -1;
12116
+ if (index < 0) return;
12117
+ const [entry] = capturedQueue!.splice(index, 1);
12118
+ if (capturedQueue!.length === 0) this.#terminalAbortSteeringSnapshots.delete(capturedKey);
12119
+ const currentQueue = this.#terminalAbortSteeringSnapshots.get(currentKey) ?? [];
12120
+ currentQueue.push(entry);
12121
+ this.#terminalAbortSteeringSnapshots.set(currentKey, currentQueue);
12122
+ this.#terminalAbortSteeringSnapshotKeys.set(token, currentKey);
12123
+ }
12124
+ discardTerminalAbortSteeringSnapshot(token: number): void {
12125
+ const key = this.#terminalAbortSteeringSnapshotKeys.get(token);
12126
+ if (!key) return;
12127
+ this.#terminalAbortSteeringSnapshotKeys.delete(token);
12128
+ const queue = this.#terminalAbortSteeringSnapshots.get(key);
12129
+ if (!queue) return;
12130
+ const index = queue.findIndex(entry => entry.token === token);
12131
+ if (index >= 0) {
12132
+ queue.splice(index, 1);
12133
+ if (queue.length === 0) this.#terminalAbortSteeringSnapshots.delete(key);
12134
+ }
12135
+ }
12136
+
12137
+ getTerminalTurnEpoch(): number | undefined {
12138
+ const lineageIdHash = this.#turnLineageIdHash;
12139
+ if (!lineageIdHash) return undefined;
12140
+ return this.#promptGeneration;
12141
+ }
12142
+ /**
12143
+ * Logical endpoint used to key owned-registration lineage bindings.
12144
+ * Subagents inherit the parent's AsyncJobManager, whose completion callback
12145
+ * resolves registrations via AsyncJobManager.endpointIdOf(manager); the
12146
+ * child's own sessionManager id is never registered under the manager, so
12147
+ * binding/registering under it would make the parent's lookup miss and
12148
+ * demote the completion to an ordinary follow-up — purgeable by a parent
12149
+ * terminal turn abort and outside owned-scope delivery denial (review
12150
+ * thread P1). The manager's registered endpoint (owned for a top-level
12151
+ * session, inherited for a child) is authoritative for both binding and
12152
+ * lookup.
10365
12153
  */
10366
- async abortPromptAndWait(handle: string, options: { graceMs: number }): Promise<RunSettlementProof> {
12154
+ #ownedRegistrationEndpoint(): string {
12155
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
12156
+ return AsyncJobManager.endpointIdOf(manager) ?? this.sessionManager.getSessionId() ?? "local";
12157
+ }
12158
+ async abortPromptAndWait(
12159
+ handle: string,
12160
+ options: {
12161
+ graceMs: number;
12162
+ terminal?: { scope: "turn" | "owned"; expectedEpoch?: number; steeringSnapshotToken?: number };
12163
+ },
12164
+ ): Promise<
12165
+ RunSettlementProof & {
12166
+ terminalScope?: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string };
12167
+ }
12168
+ > {
12169
+ let registeredScope: { scopeId: string; abortedAttemptEpoch: number; lineageIdHash: string } | undefined;
12170
+ // The purge snapshot is consumed per-admission inside the terminal
12171
+ // block; the rearm below (reachable only when a scope was registered,
12172
+ // i.e. only after the purge ran) reads the same value.
12173
+ let abortSteeringSnapshot: number | undefined;
12174
+ if (options.terminal) {
12175
+ // The terminal scope must be registered for the ABORTED turn's
12176
+ // lineage, never a successor's. A new root turn advances the attempt
12177
+ // epoch at admission, so when the caller captured the epoch before a
12178
+ // durable claim and a successor won that window, the current epoch no
12179
+ // longer matches — fail closed and never fence or purge the
12180
+ // unrelated successor's queues (review thread P1). Callers that do
12181
+ // not pass an expected epoch (direct seam users) retain the prior
12182
+ // register-regardless behavior.
12183
+ if (
12184
+ options.terminal.expectedEpoch !== undefined &&
12185
+ options.terminal.expectedEpoch !== this.#promptGeneration
12186
+ ) {
12187
+ return { status: "unfenced", reason: "unknown_run", pending: [] };
12188
+ }
12189
+ const requestedSnapshotToken = options.terminal.steeringSnapshotToken;
12190
+ const currentSnapshotKey =
12191
+ this.#turnLineageIdHash === undefined ? undefined : `${this.#turnLineageIdHash}:${this.#promptGeneration}`;
12192
+ if (requestedSnapshotToken !== undefined) {
12193
+ const capturedKey = this.#terminalAbortSteeringSnapshotKeys.get(requestedSnapshotToken);
12194
+ const capturedQueue = capturedKey ? this.#terminalAbortSteeringSnapshots.get(capturedKey) : undefined;
12195
+ if (
12196
+ capturedKey !== currentSnapshotKey ||
12197
+ !capturedQueue?.some(entry => entry.token === requestedSnapshotToken)
12198
+ ) {
12199
+ this.discardTerminalAbortSteeringSnapshot(requestedSnapshotToken);
12200
+ return { status: "unfenced", reason: "unknown_run", pending: [] };
12201
+ }
12202
+ }
12203
+ // Terminal abort (C04 mode:"terminal"): register and synchronously
12204
+ // close the continuation fence for the current turn BEFORE the run is
12205
+ // interrupted, so a later left-running owned completion classifies as
12206
+ // owned-completion by exact source (lineage + attempt epoch). The
12207
+ // owned-completion policy stays enabled for scope:"turn" — delivery
12208
+ // intentionally resumes the agent — and disabled for scope:"owned".
12209
+ // A missing lineage (no active turn) fails closed: no scope is
12210
+ // registered, so nothing is attributed.
12211
+ const lineageIdHash = this.#turnLineageIdHash;
12212
+ if (lineageIdHash) {
12213
+ const scope = registerTerminalTurnScope({
12214
+ lineageIdHash,
12215
+ promptAttemptEpoch: this.#promptGeneration,
12216
+ ownedCompletionPolicy: options.terminal.scope === "owned" ? "disabled" : "enabled",
12217
+ });
12218
+ // Admission can fail closed (registry saturation): the abort
12219
+ // proceeds without a scope, so nothing is attributed.
12220
+ if (scope) {
12221
+ registeredScope = {
12222
+ scopeId: scope.scopeId,
12223
+ abortedAttemptEpoch: scope.promptAttemptEpoch,
12224
+ lineageIdHash: scope.lineageIdHash,
12225
+ };
12226
+ }
12227
+ // Terminal abort blocks STEERING continuations of the aborted turn:
12228
+ // purge steering queued just before the abort won (the loop can
12229
+ // exit on the abort signal without polling it) so it cannot alter
12230
+ // the next user turn (review thread P2). The follow-up queue is
12231
+ // preserved — owned-completion resumes must still deliver.
12232
+ const turnSnapshotKey = `${lineageIdHash}:${this.#promptGeneration}`;
12233
+ const queuedTurnSnapshots = this.#terminalAbortSteeringSnapshots.get(turnSnapshotKey);
12234
+ const snapshotIndex =
12235
+ requestedSnapshotToken === undefined
12236
+ ? 0
12237
+ : (queuedTurnSnapshots?.findIndex(entry => entry.token === requestedSnapshotToken) ?? -1);
12238
+ const consumedSnapshot =
12239
+ queuedTurnSnapshots && snapshotIndex >= 0 ? queuedTurnSnapshots.splice(snapshotIndex, 1)[0] : undefined;
12240
+ if (consumedSnapshot) this.#terminalAbortSteeringSnapshotKeys.delete(consumedSnapshot.token);
12241
+ abortSteeringSnapshot = consumedSnapshot?.seq ?? this.#steeringAdmissionSeq;
12242
+ if (queuedTurnSnapshots?.length === 0) {
12243
+ this.#terminalAbortSteeringSnapshots.delete(turnSnapshotKey);
12244
+ }
12245
+ const admittedAfterSnapshot = (message: AgentMessage): boolean =>
12246
+ (this.#externalSteerAdmissionSeq.get(message) ?? 0) > (abortSteeringSnapshot ?? -1);
12247
+ // Steering purge, STEERING queue only: the agent-wide removal
12248
+ // would also wipe the follow-up queue, which owned-completion
12249
+ // resumes must still deliver (review thread P1).
12250
+ const steeringBeforePurge = this.agent.snapshotSteering();
12251
+ this.agent.removeQueuedMessages(
12252
+ message => !(this.#externalSteerMessages.has(message) && admittedAfterSnapshot(message)),
12253
+ "steering",
12254
+ );
12255
+ // Keep the display list aligned with the ACTUAL purge decisions:
12256
+ // preserved post-snapshot external steers stay visible and
12257
+ // purged internal steers disappear — a stale entry would let the
12258
+ // positional editing APIs remove a different preserved steer
12259
+ // (review thread P2).
12260
+ const steeringAfterPurge = new Set(this.agent.snapshotSteering());
12261
+ const keptSteeringDisplays: QueuedDisplayEntry[] = [];
12262
+ for (let displayIndex = 0; displayIndex < steeringBeforePurge.length; displayIndex++) {
12263
+ if (steeringAfterPurge.has(steeringBeforePurge[displayIndex]!)) {
12264
+ keptSteeringDisplays.push(this.#steeringMessages[displayIndex]!);
12265
+ }
12266
+ }
12267
+ this.#steeringMessages = keptSteeringDisplays;
12268
+
12269
+ // Remove ORDINARY follow-ups queued for the aborted turn: the
12270
+ // aborted loop exits without polling them, so a later unrelated
12271
+ // prompt could drain and execute the supposedly-terminated turn's
12272
+ // command. Only authorized owned-completion envelopes survive
12273
+ // (review thread P1).
12274
+ const followUpBeforePurge = this.agent.snapshotFollowUp();
12275
+ this.agent.removeQueuedMessages(message => {
12276
+ // An SDK/client follow-up independently requested the next
12277
+ // root turn; it is not a continuation caused by the
12278
+ // aborted turn.
12279
+ if (this.#externalFollowUps.has(message)) return false;
12280
+ // Preserve only envelopes that are CURRENTLY AUTHORIZED as
12281
+ // owned completions (fresh or drop). The public
12282
+ // sendMessage() accepts arbitrary details, so the mere
12283
+ // presence of the ownedCompletions field is not authority —
12284
+ // an envelope with no matching terminal scope classifies
12285
+ // ordinary and must NOT survive the abort (review thread P2).
12286
+ return ownedCompletionResumeAction(message as never) === "ordinary";
12287
+ }, "followUp");
12288
+ // Mirror the follow-up purge in the display list so stale entries
12289
+ // cannot misalign the positional editing APIs (review thread P2).
12290
+ const followUpAfterPurge = new Set(this.agent.snapshotFollowUp());
12291
+ const keptFollowUpDisplays: QueuedDisplayEntry[] = [];
12292
+ for (let displayIndex = 0; displayIndex < followUpBeforePurge.length; displayIndex++) {
12293
+ if (followUpAfterPurge.has(followUpBeforePurge[displayIndex]!)) {
12294
+ keptFollowUpDisplays.push(this.#followUpMessages[displayIndex]!);
12295
+ }
12296
+ }
12297
+ this.#followUpMessages = keptFollowUpDisplays;
12298
+ }
12299
+ // Do NOT advance the attempt epoch here: the terminal scope must stay
12300
+ // keyed to the aborted epoch so #isTurnContinuationBlocked (which
12301
+ // looks up by the CURRENT #promptGeneration) still finds the closed
12302
+ // fence for hidden-next-turn and other non-generation-guarded
12303
+ // continuation paths. Fresh-turn allocation is the per-admission
12304
+ // responsibility: every NEW ROOT TURN (#promptWithMessage with
12305
+ // resetRetryReplaySafety) advances the epoch before minting, so the
12306
+ // next turn after the abort gets a distinct (lineage, epoch) and is
12307
+ // never captured by this scope (review thread P2).
12308
+ this.#promptPreflightCancellationGeneration++;
12309
+ this.#promptPreflightAbortController.abort();
12310
+ this.#promptPreflightAbortController = new AbortController();
12311
+ }
10367
12312
  const aborted = this.#runCancellationDomains.abort(handle);
10368
12313
  if (!aborted.ok) {
10369
12314
  if (aborted.reason === "quarantined") {
10370
- return await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: 0 });
12315
+ return {
12316
+ ...(await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: 0 })),
12317
+ ...(registeredScope ? { terminalScope: registeredScope } : {}),
12318
+ };
10371
12319
  }
10372
- return { status: "unfenced", reason: "unknown_run", pending: [] };
12320
+ return {
12321
+ status: "unfenced",
12322
+ reason: "unknown_run",
12323
+ pending: [],
12324
+ ...(registeredScope ? { terminalScope: registeredScope } : {}),
12325
+ };
10373
12326
  }
10374
12327
  if (handle === this.agent.activeResourceRunId) this.agent.abort();
10375
12328
  const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs });
10376
12329
  if (proof.status === "unfenced") this.agent.resourceLedger.quarantine(handle);
10377
- return proof;
12330
+ // Rearm surviving owned-completion follow-ups once the abort has
12331
+ // settled: the aborted loop exits before polling the follow-up queue,
12332
+ // so preserving the envelopes alone leaves them stranded until an
12333
+ // unrelated prompt. Schedule a continue so they are consumed as a
12334
+ // fresh turn (review thread P1).
12335
+ if (options.terminal && registeredScope) {
12336
+ // Rearm ONLY envelopes the classifier marks fresh. A drop-classified
12337
+ // envelope (a denied owned completion under scope:"owned") must never
12338
+ // reach the model: purge it from the follow-up queue BEFORE the
12339
+ // rearm's continue is scheduled, because the agent loop dequeues
12340
+ // follow-ups DIRECTLY and consumes the whole queue at once — a
12341
+ // denied envelope left queued alongside the fresh completion would
12342
+ // otherwise be passed to #runLoop with it (review thread P1). The
12343
+ // onFollowUpConsumed filter at agent.ts continue() is the second
12344
+ // line of defense for any envelope queued in the window after this
12345
+ // purge and before the dequeue.
12346
+ const followUpSnapshot = this.agent.snapshotQueues();
12347
+ const droppedFollowUps: AgentMessage[] = [];
12348
+ const purgedFollowUps = followUpSnapshot.followUp.filter(message => {
12349
+ if (ownedCompletionResumeAction(message as never) === "drop") {
12350
+ droppedFollowUps.push(message);
12351
+ return false;
12352
+ }
12353
+ return true;
12354
+ });
12355
+ if (droppedFollowUps.length > 0) {
12356
+ this.agent.restoreQueues({ ...followUpSnapshot, followUp: purgedFollowUps });
12357
+ // The denied completion is terminal; settle its registration so
12358
+ // the tuple does not occupy global capacity while the fresh
12359
+ // completion resumes (review thread P2).
12360
+ this.#settleDeliveredOwnedRegistrations(droppedFollowUps);
12361
+ }
12362
+ const hasPreservedFollowUp = () =>
12363
+ this.agent
12364
+ .snapshotQueues()
12365
+ .followUp.some(
12366
+ message =>
12367
+ ownedCompletionResumeAction(message as never) === "fresh" || this.#externalFollowUps.has(message),
12368
+ );
12369
+ // A preserved post-snapshot client steer is also an independent
12370
+ // root-turn request: the abort exits the loop and never polls the
12371
+ // steering queue, so without a rearm the accepted steer stays
12372
+ // stranded until unrelated activity (review thread P1).
12373
+ const hasPreservedSteering = (): boolean =>
12374
+ this.agent
12375
+ .snapshotQueues()
12376
+ .steering.some(
12377
+ message =>
12378
+ this.#externalSteerMessages.has(message) &&
12379
+ (this.#externalSteerAdmissionSeq.get(message) ?? 0) > (abortSteeringSnapshot ?? -1),
12380
+ );
12381
+ if (hasPreservedFollowUp() || hasPreservedSteering()) {
12382
+ // ANY preserved follow-up or steer — an owned-completion resume,
12383
+ // an independently requested external follow-up, or a
12384
+ // post-snapshot client steer — needs a fresh lineage before its
12385
+ // continue: without it the scheduled continuation runs under the
12386
+ // aborted lineage+epoch and the terminal fence skips it as
12387
+ // terminal_turn, stranding the preserved user request until
12388
+ // unrelated activity. Minting the fresh lineage does not
12389
+ // reclassify the external message as agent-initiated owned work;
12390
+ // it only unbinds the new turn from the aborted attempt (review
12391
+ // thread P1).
12392
+ this.#resumeFromOwnedCompletion();
12393
+ this.#scheduleAgentContinue({
12394
+ delayMs: 1,
12395
+ generation: this.#promptGeneration,
12396
+ shouldContinue: () => hasPreservedFollowUp() || hasPreservedSteering(),
12397
+ // The rearmed continuation must consume the queued steer inside
12398
+ // the run acceptance (continueQueuedMessages) rather than
12399
+ // dequeue it later via getSteeringMessages: a terminal abort
12400
+ // leaves a non-assistant history tail (tool result), which
12401
+ // would otherwise select Agent.continue() and accept the run
12402
+ // with an empty consumedQueuedMessages payload, so
12403
+ // #fireQueuedPromotionHooks never fires and the submitting
12404
+ // connection is not recorded as an owner — its later terminal
12405
+ // abort is rejected as an owner mismatch (review thread P1).
12406
+ // The preserved messages are still re-verified by
12407
+ // shouldContinue at continuation time, so a steer removed
12408
+ // during the delay never fires a stale hook and a post-snapshot
12409
+ // steer admitted during the delay is not missed.
12410
+ continueQueuedOnly: true,
12411
+ rescheduleOnBusy: true,
12412
+ });
12413
+ }
12414
+ }
12415
+ return {
12416
+ ...proof,
12417
+ ...(registeredScope ? { terminalScope: registeredScope } : {}),
12418
+ };
10378
12419
  }
10379
12420
 
10380
12421
  /** Atomically interrupt the active run and make text the next prompt. */
@@ -10522,6 +12563,7 @@ export class AgentSession {
10522
12563
  resetRetryReplaySafety: true,
10523
12564
  onRunAccepted: () => {
10524
12565
  runAccepted = true;
12566
+ if (selected) this.#fireQueuedPromotionHooks([message]);
10525
12567
  if (selected) {
10526
12568
  this.#steeringMessages = this.#steeringMessages.filter(entry => entry !== selected.display);
10527
12569
  this.#followUpMessages = this.#followUpMessages.filter(entry => entry !== selected.display);
@@ -10604,7 +12646,7 @@ export class AgentSession {
10604
12646
  }
10605
12647
  }
10606
12648
 
10607
- const manager = AsyncJobManager.instance();
12649
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
10608
12650
  const ownerId = this.#agentId;
10609
12651
  const lease = manager && ownerId ? manager.beginOwnerSubagentShutdown(ownerId) : undefined;
10610
12652
  if (manager && ownerId && !lease) {
@@ -10630,11 +12672,18 @@ export class AgentSession {
10630
12672
  this.#rebindProviderSessionState(new Map());
10631
12673
  this.agent.reset();
10632
12674
  if (!options?.drop) await this.sessionManager.flush();
12675
+ const noLeasePreviousSessionIdentity = this.sessionManager.getSessionId();
12676
+ const noLeasePreviousSessionFile = this.sessionManager.getSessionFile();
10633
12677
  const prepared = await this.sessionManager.prepareNewSession(options);
10634
12678
  try {
10635
12679
  // Last fallible gate while public getters still show the predecessor (#3138).
10636
12680
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
12681
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
10637
12682
  this.sessionManager.commitPreparedNewSession(prepared);
12683
+ // Endpoint identity committed to the successor: re-register the
12684
+ // manager so post-transition lineage bindings resolve and owned
12685
+ // aborts classify in the successor session (review thread P1).
12686
+ this.#rekeyJobManagerForSessionIdentity(noLeasePreviousSessionIdentity, noLeasePreviousSessionFile);
10638
12687
  await this.#runToolSessionTransitionCleanups();
10639
12688
  } catch (error) {
10640
12689
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
@@ -10662,6 +12711,7 @@ export class AgentSession {
10662
12711
  if (!manager) throw new Error("Owner subagent shutdown manager became unavailable.");
10663
12712
  if (!ownerId) throw new Error("Owner subagent shutdown owner became unavailable.");
10664
12713
  const previousSessionIdentity = this.sessionManager.getSessionId();
12714
+ const previousSessionIdentityFile = this.sessionManager.getSessionFile();
10665
12715
  try {
10666
12716
  try {
10667
12717
  manager.runOwnerProducerCleanupsStrict({ ownerId });
@@ -10704,7 +12754,12 @@ export class AgentSession {
10704
12754
  try {
10705
12755
  // Last fallible gate while public getters still show the predecessor (#3138).
10706
12756
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
12757
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
10707
12758
  this.sessionManager.commitPreparedNewSession(prepared);
12759
+ // Endpoint identity committed to the successor: re-register the
12760
+ // manager so post-transition lineage bindings resolve and owned
12761
+ // aborts classify in the successor session (review thread P1).
12762
+ this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionIdentityFile);
10708
12763
  await this.#runToolSessionTransitionCleanups();
10709
12764
  } catch (error) {
10710
12765
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
@@ -10879,6 +12934,7 @@ export class AgentSession {
10879
12934
  try {
10880
12935
  const previousSessionFile = this.sessionFile;
10881
12936
  const previousWorkflowGateSessionId = this.sessionId;
12937
+ const previousSessionIdentity = this.sessionManager.getSessionId();
10882
12938
 
10883
12939
  // Emit session_before_switch event with reason "fork" (can be cancelled)
10884
12940
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
@@ -10915,6 +12971,7 @@ export class AgentSession {
10915
12971
  getSessionId: () => forkedManager.getSessionId(),
10916
12972
  });
10917
12973
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
12974
+ this.#assertJobManagerEndpointAdmission(forkedManager.getSessionId(), forkedManager.getSessionFile());
10918
12975
  } catch (error) {
10919
12976
  const forkedFile = forkedManager.getSessionFile();
10920
12977
  const cleanupErrors: unknown[] = [];
@@ -10945,6 +13002,7 @@ export class AgentSession {
10945
13002
  throw error;
10946
13003
  }
10947
13004
  this.sessionManager = forkedManager;
13005
+ this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
10948
13006
  try {
10949
13007
  await previousManager.close();
10950
13008
  } catch (error) {
@@ -10961,7 +13019,11 @@ export class AgentSession {
10961
13019
  try {
10962
13020
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
10963
13021
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
13022
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
10964
13023
  this.sessionManager.commitPreparedNewSession(prepared);
13024
+ // Fork commits a successor endpoint identity; re-register the
13025
+ // manager under it (review thread P1).
13026
+ this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
10965
13027
  await this.#runToolSessionTransitionCleanups();
10966
13028
  } catch (error) {
10967
13029
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
@@ -11062,6 +13124,27 @@ export class AgentSession {
11062
13124
  return this.#activeModelProfile;
11063
13125
  }
11064
13126
 
13127
+ /**
13128
+ * Re-apply vendor-separated delegation after a profile activation changed the
13129
+ * role layer. `eagerTasks` is resolved at prompt build time, but under
13130
+ * `tools.discoveryMode: all` the `task` tool is hidden until something
13131
+ * activates it, and the delegation directive stays behind its
13132
+ * `has tools "task"` guard. Activating a vendor-separated profile mid-session
13133
+ * must therefore reach the live tool set, not just the next session.
13134
+ */
13135
+ async syncEagerDelegation(): Promise<void> {
13136
+ const { eagerTasks } = resolveEagerTaskDelegation({
13137
+ settings: this.settings,
13138
+ profile: this.#activeModelProfile
13139
+ ? this.#modelRegistry.getModelProfile?.(this.#activeModelProfile)
13140
+ : undefined,
13141
+ });
13142
+ if (eagerTasks && this.#toolRegistry.has("task") && !this.getActiveToolNames().includes("task")) {
13143
+ await this.activateDiscoveredTools(["task"]);
13144
+ }
13145
+ await this.refreshBaseSystemPrompt();
13146
+ }
13147
+
11065
13148
  /** Resolver intent only for assignments owned by the active profile. */
11066
13149
  #persistedModelProfileAliasIntent(role: string): { aliasIntent: "preset-equivalent" } | undefined {
11067
13150
  if (!this.#activeModelProfile) return undefined;
@@ -11209,11 +13292,10 @@ export class AgentSession {
11209
13292
  onAfterActivation?: () => void;
11210
13293
  },
11211
13294
  ): Promise<{ changed: boolean; id: string }> {
13295
+ // Do not hold selection admission while waiting for a scheduled continuation:
13296
+ // the continuation may need prompt admission to settle the current turn.
13297
+ await this.waitForIdle();
11212
13298
  const canonicalName = await this.#withSessionAdmission("selection", async () => {
11213
- // A model-picker action must not alter an in-flight generation: wait
11214
- // for the current run to settle inside the admission before applying
11215
- // the profile, matching `setDefaultModelSelection`.
11216
- await this.waitForIdle();
11217
13299
  const profiles = this.#modelRegistry.getModelProfiles();
11218
13300
  let canonical: string;
11219
13301
  try {
@@ -11300,10 +13382,10 @@ export class AgentSession {
11300
13382
  * profile activation and default-model selection.
11301
13383
  */
11302
13384
  async withSdkControlMutation<T>(body: () => Promise<T>): Promise<T> {
13385
+ // Waiting while selection owns admission deadlocks with a scheduled
13386
+ // continuation queued behind it. Wait before acquiring the mutation lease.
13387
+ await this.waitForIdle();
11303
13388
  return this.#withSessionAdmission("selection", async () => {
11304
- // A config mutation can change the active model-role assignment. Do not
11305
- // alter an in-flight turn after its prompt admission lease has released.
11306
- await this.waitForIdle();
11307
13389
  return await body();
11308
13390
  });
11309
13391
  }
@@ -11655,95 +13737,148 @@ export class AgentSession {
11655
13737
  onAfterMutation?: () => void;
11656
13738
  },
11657
13739
  ): Promise<DefaultModelSelectionResult> {
11658
- return this.#withSessionAdmission("selection", async () => {
11659
- options?.onBeforeMutation?.();
11660
- const expectedSessionId = this.sessionId;
11661
-
11662
- if (thinkingLevel === ThinkingLevel.Inherit) {
11663
- throw new Error("Default model selection cannot inherit a thinking level");
11664
- }
11665
- const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId);
11666
- if (!apiKey) {
11667
- throw new Error(`No API key for ${model.provider}/${model.id}`);
11668
- }
11669
- const resolvedLevel = resolveThinkingLevelForModel(model, thinkingLevel);
11670
- const effectiveLevel =
11671
- resolvedLevel ??
11672
- resolveThinkingLevelForModel(model, model.thinking?.defaultLevel ?? this.thinkingLevel) ??
11673
- ThinkingLevel.Off;
11674
- await this.waitForIdle();
11675
- await this.sessionManager.flush();
11676
- await this.#waitForAdmittedBaseSystemPromptRebuilds();
11677
- if (this.sessionId !== expectedSessionId) {
11678
- throw new Error("Session changed while selecting model");
11679
- }
11680
- const expectedMutationRevision = this.#defaultModelSelectionMutationRevision;
11681
- const preparedSystemPrompt = await this.#prepareDefaultModelSelectionPrompt(model);
11682
- if (this.sessionId !== expectedSessionId) {
11683
- throw new Error("Session changed while selecting model");
11684
- }
11685
- const stage = await this.sessionManager.stageDefaultModelSelection(
11686
- `${model.provider}/${model.id}`,
11687
- effectiveLevel,
11688
- { appendThinkingLevel: true },
13740
+ // Reserve the causal selection fence synchronously, before credential
13741
+ // probing can yield. Later external prompts wait behind this operation,
13742
+ // while an inherited post-prompt continuation may bypass the fence so
13743
+ // waitForIdle never deadlocks waiting for prompt admission.
13744
+ const owner = this.#sessionAdmissionContext.getStore();
13745
+ if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
13746
+ if (this.#sessionAdmissionClosing || this.#sessionAdmissionClosed || this.#isDisposed) {
13747
+ throw this.#sessionAdmissionBusyError();
13748
+ }
13749
+ if (thinkingLevel === ThinkingLevel.Inherit) {
13750
+ throw new Error("Default model selection cannot inherit a thinking level");
13751
+ }
13752
+ const expectedSessionId = this.sessionId;
13753
+ const priorSelectionFence = this.#selectionFenceTail;
13754
+ const selectionFence = Promise.withResolvers<void>();
13755
+ this.#selectionFenceGeneration += 1;
13756
+ this.#pendingSelectionFences += 1;
13757
+ const selectionFenceGeneration = this.#selectionFenceGeneration;
13758
+ if (this.#oldestPendingSelectionFenceGeneration === 0) {
13759
+ this.#oldestPendingSelectionFenceGeneration = selectionFenceGeneration;
13760
+ }
13761
+ this.#selectionFenceTail = priorSelectionFence.then(() => selectionFence.promise);
13762
+ void this.#selectionFenceTail.catch(() => {});
13763
+ try {
13764
+ await priorSelectionFence;
13765
+ const { effectiveLevel } = await this.#withSessionAdmission(
13766
+ "selection",
13767
+ async () => {
13768
+ if (this.sessionId !== expectedSessionId) {
13769
+ throw new Error("Session changed while selecting model");
13770
+ }
13771
+ const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId);
13772
+ if (this.sessionId !== expectedSessionId) {
13773
+ throw new Error("Session changed while selecting model");
13774
+ }
13775
+ if (!apiKey) {
13776
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
13777
+ }
13778
+ const resolvedLevel = resolveThinkingLevelForModel(model, thinkingLevel);
13779
+ return {
13780
+ effectiveLevel:
13781
+ resolvedLevel ??
13782
+ resolveThinkingLevelForModel(model, model.thinking?.defaultLevel ?? this.thinkingLevel) ??
13783
+ ThinkingLevel.Off,
13784
+ };
13785
+ },
13786
+ undefined,
13787
+ undefined,
13788
+ { allowDuringClosing: true },
11689
13789
  );
11690
- let durableCommit: CasReceipt;
11691
- try {
11692
- const selector = formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel);
11693
- durableCommit = await this.settings.commitAtomicBatchWithCurrent(() => [
11694
- { path: "modelRoles.default" as SettingPath, op: "set", value: selector },
11695
- ]);
11696
- } catch (error) {
11697
- try {
11698
- await this.sessionManager.discardDefaultModelSelectionStage(stage);
11699
- } catch (cleanupError) {
11700
- throw new AggregateError(
11701
- [error, cleanupError],
11702
- "Default model selection persistence and staged session cleanup both failed.",
11703
- );
11704
- }
11705
- throw error;
11706
- }
11707
- if (this.#defaultModelSelectionMutationRevision !== expectedMutationRevision) {
11708
- await this.#throwDefaultModelSelectionRecovery(
11709
- new Error("Default model selection was superseded before session promotion"),
11710
- stage,
11711
- durableCommit,
11712
- );
11713
- }
11714
- const promotion = this.sessionManager.promoteDefaultModelSelection(stage);
11715
- switch (promotion.kind) {
11716
- case "promoted": {
11717
- this.#publishDefaultModelSelection(model, effectiveLevel, preparedSystemPrompt);
11718
- break;
11719
- }
11720
- case "not_promoted": {
11721
- return this.#throwDefaultModelSelectionRecovery(
11722
- promotion.error ?? new Error("Default model selection was superseded before session promotion"),
11723
- stage,
11724
- durableCommit,
13790
+ await this.waitForIdle(selectionFenceGeneration);
13791
+ return await this.#withSessionAdmission(
13792
+ "selection",
13793
+ async () => {
13794
+ options?.onBeforeMutation?.();
13795
+ if (this.sessionId !== expectedSessionId) {
13796
+ throw new Error("Session changed while selecting model");
13797
+ }
13798
+ await this.sessionManager.flush();
13799
+ await this.#waitForAdmittedBaseSystemPromptRebuilds();
13800
+ if (this.sessionId !== expectedSessionId) {
13801
+ throw new Error("Session changed while selecting model");
13802
+ }
13803
+ const expectedMutationRevision = this.#defaultModelSelectionMutationRevision;
13804
+ const preparedSystemPrompt = await this.#prepareDefaultModelSelectionPrompt(model);
13805
+ if (this.sessionId !== expectedSessionId) {
13806
+ throw new Error("Session changed while selecting model");
13807
+ }
13808
+ const stage = await this.sessionManager.stageDefaultModelSelection(
13809
+ `${model.provider}/${model.id}`,
13810
+ effectiveLevel,
13811
+ { appendThinkingLevel: true },
11725
13812
  );
11726
- }
11727
- case "unknown": {
11728
- const message = "Session replacement outcome could not be determined.";
11729
- throw new DefaultModelSelectionRecoveryError(message, {
11730
- message,
11731
- rollback: {
11732
- disposition: "unknown",
11733
- failures: [
11734
- {
11735
- stage: "session",
11736
- message: "Session replacement outcome could not be determined.",
13813
+ let durableCommit: CasReceipt;
13814
+ try {
13815
+ const selector = formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel);
13816
+ durableCommit = await this.settings.commitAtomicBatchWithCurrent(() => [
13817
+ { path: "modelRoles.default" as SettingPath, op: "set", value: selector },
13818
+ ]);
13819
+ } catch (error) {
13820
+ try {
13821
+ await this.sessionManager.discardDefaultModelSelectionStage(stage);
13822
+ } catch (cleanupError) {
13823
+ throw new AggregateError(
13824
+ [error, cleanupError],
13825
+ "Default model selection persistence and staged session cleanup both failed.",
13826
+ );
13827
+ }
13828
+ throw error;
13829
+ }
13830
+ if (this.#defaultModelSelectionMutationRevision !== expectedMutationRevision) {
13831
+ await this.#throwDefaultModelSelectionRecovery(
13832
+ new Error("Default model selection was superseded before session promotion"),
13833
+ stage,
13834
+ durableCommit,
13835
+ );
13836
+ }
13837
+ const promotion = this.sessionManager.promoteDefaultModelSelection(stage);
13838
+ switch (promotion.kind) {
13839
+ case "promoted": {
13840
+ this.#publishDefaultModelSelection(model, effectiveLevel, preparedSystemPrompt);
13841
+ break;
13842
+ }
13843
+ case "not_promoted": {
13844
+ return this.#throwDefaultModelSelectionRecovery(
13845
+ promotion.error ?? new Error("Default model selection was superseded before session promotion"),
13846
+ stage,
13847
+ durableCommit,
13848
+ );
13849
+ }
13850
+ case "unknown": {
13851
+ const message = "Session replacement outcome could not be determined.";
13852
+ throw new DefaultModelSelectionRecoveryError(message, {
13853
+ message,
13854
+ rollback: {
13855
+ disposition: "unknown",
13856
+ failures: [
13857
+ {
13858
+ stage: "session",
13859
+ message: "Session replacement outcome could not be determined.",
13860
+ },
13861
+ ],
11737
13862
  },
11738
- ],
11739
- },
11740
- });
11741
- }
13863
+ });
13864
+ }
13865
+ }
13866
+ this.#clearActiveModelProfileForConcreteDefault("user-selection");
13867
+ options?.onAfterMutation?.();
13868
+ return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel };
13869
+ },
13870
+ undefined,
13871
+ undefined,
13872
+ { allowDuringClosing: true },
13873
+ );
13874
+ } finally {
13875
+ selectionFence.resolve();
13876
+ this.#pendingSelectionFences -= 1;
13877
+ if (this.#oldestPendingSelectionFenceGeneration === selectionFenceGeneration) {
13878
+ this.#oldestPendingSelectionFenceGeneration = 0;
11742
13879
  }
11743
- this.#clearActiveModelProfileForConcreteDefault("user-selection");
11744
- options?.onAfterMutation?.();
11745
- return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel };
11746
- });
13880
+ this.#resolveSessionSettlement();
13881
+ }
11747
13882
  }
11748
13883
  /**
11749
13884
  * Cycle to next/previous model.
@@ -13185,9 +15320,13 @@ export class AgentSession {
13185
15320
  // Last fallible action: verified local:// readiness from immutable staged options.
13186
15321
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
13187
15322
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
15323
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
13188
15324
 
13189
15325
  // --- Commit boundary: synchronous adoption is the sole identity publication.
13190
15326
  this.sessionManager.commitPreparedNewSession(prepared);
15327
+ // Handoff commits a successor endpoint identity; re-register the
15328
+ // manager under it (review thread P1).
15329
+ this.#rekeyJobManagerForSessionIdentity(rollbackSessionState.sessionId, rollbackSessionState.sessionFile);
13191
15330
  committed = true;
13192
15331
  await this.#runToolSessionTransitionCleanups();
13193
15332
  this.agent.reset();
@@ -15402,7 +17541,9 @@ export class AgentSession {
15402
17541
  classification === "transient" ||
15403
17542
  classification === "unknown" ||
15404
17543
  classification === "first_event_timeout" ||
15405
- classification === "empty_response"
17544
+ classification === "empty_response" ||
17545
+ classification === "local_snapshot" ||
17546
+ classification === "local_buffer_overflow"
15406
17547
  );
15407
17548
  }
15408
17549
  const trigger = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
@@ -15422,7 +17563,13 @@ export class AgentSession {
15422
17563
  return true;
15423
17564
  }
15424
17565
  const classification = this.#classifyErrorForRetry(message);
15425
- return classification === "transient" || classification === "unknown" || classification === "first_event_timeout";
17566
+ return (
17567
+ classification === "transient" ||
17568
+ classification === "unknown" ||
17569
+ classification === "first_event_timeout" ||
17570
+ classification === "local_snapshot" ||
17571
+ classification === "local_buffer_overflow"
17572
+ );
15426
17573
  }
15427
17574
 
15428
17575
  #isTransientErrorMessage(errorMessage: string): boolean {
@@ -15512,10 +17659,20 @@ export class AgentSession {
15512
17659
  #classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
15513
17660
  if (message.stopReason !== "error") return "none";
15514
17661
  if (message.errorKind === "provider_safety_stop") return "terminal";
17662
+ if (message.errorKind === "local_snapshot_failure") return "local_snapshot";
17663
+ if (message.errorKind === "local_buffer_overflow") return "local_buffer_overflow";
15515
17664
  if (this.#isTypedFirstEventTimeout(message)) return "first_event_timeout";
15516
17665
  if (this.#isTypedEmptyResponse(message)) return "empty_response";
15517
17666
  if (!message.errorMessage) return "none";
15518
17667
  const err = message.errorMessage;
17668
+ // Managed-attempt local failures from restored sessions may lack the
17669
+ // typed error kind; match the stable message prefixes as a fallback.
17670
+ if (err.startsWith("Managed fallback attempt could not produce a serializable event snapshot")) {
17671
+ return "local_snapshot";
17672
+ }
17673
+ if (err.startsWith("Managed fallback attempt exceeded the provisional event buffer limit")) {
17674
+ return "local_buffer_overflow";
17675
+ }
15519
17676
  // Provider safety refusals (e.g. Anthropic stop_reason "refusal" /
15520
17677
  // "sensitive") are deterministic for the submitted context: replaying
15521
17678
  // the identical conversation re-triggers the identical refusal, so an
@@ -15719,7 +17876,6 @@ export class AgentSession {
15719
17876
  }
15720
17877
 
15721
17878
  async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
15722
- if (outcome.type !== "escaped_arguments_discarded") this.#consecutiveEscapedArgumentRetries = 0;
15723
17879
  if (outcome.type === "run_terminal") {
15724
17880
  this.#defaultFallbackChain().resetAttemptBudget();
15725
17881
  return { type: "terminal", terminal: { stopReason: outcome.reason } };
@@ -15727,24 +17883,12 @@ export class AgentSession {
15727
17883
  if (outcome.type === "escaped_arguments_discarded") {
15728
17884
  // An escaped-non-ASCII wire defect is a sampling accident, not provider
15729
17885
  // evidence: never charge the attempt, advance the chain, or suppress the
15730
- // selector. The loop already removed the defective turn from history, so
15731
- // this decision just re-issues the same request on the same model.
15732
- //
15733
- // The retry bound lives HERE, not in the loop: a managed retry re-enters
15734
- // the loop as a fresh run, so no loop-local budget survives it. After
15735
- // MAX_ESCAPED_ARGUMENT_RETRIES consecutive escaped-argument discards the
15736
- // run terminates with an error instead of ping-ponging forever against a
15737
- // model that deterministically emits escaped arguments. The counter
15738
- // resets on any other outcome.
17886
+ // selector. The loop already removed the defective turn from history and
17887
+ // bounded its own resample budget, so this decision just re-issues the
17888
+ // same request on the same model. Once the loop declines (budget spent),
17889
+ // it falls through to the terminal per-call rejection, so the retry here
17890
+ // is a continuation of the same logical run rather than a new prompt.
15739
17891
  this.#defaultFallbackChain().discardStartedAttempt();
15740
- this.#consecutiveEscapedArgumentRetries++;
15741
- if (this.#consecutiveEscapedArgumentRetries > MAX_ESCAPED_ARGUMENT_RETRIES) {
15742
- this.#consecutiveEscapedArgumentRetries = 0;
15743
- return {
15744
- type: "terminal",
15745
- terminal: { stopReason: "error", messages: [outcome.message] },
15746
- };
15747
- }
15748
17892
  return {
15749
17893
  type: "retry",
15750
17894
  continuation: async ownership => {
@@ -16039,12 +18183,50 @@ export class AgentSession {
16039
18183
  this.settings.has("retry.maxRetries") ||
16040
18184
  this.settings.has("retry.baseDelayMs") ||
16041
18185
  this.settings.has("retry.maxDelayMs");
16042
- // retry.enabled=false always surfaces immediately, matching the explicit
16043
- // user opt-out.
16044
- if (!managedFallback && !retrySettings.enabled) return false;
16045
18186
  const classification = this.#classifyErrorForRetry(message);
18187
+ const localSnapshot = classification === "local_snapshot";
18188
+ const localBufferOverflow = classification === "local_buffer_overflow";
18189
+ // A local machinery failure must never stay charged against the provider
18190
+ // fallback budget, no matter which local exit follows (disabled retry,
18191
+ // visible-content surface, bounded retry, exhaustion, or the immediate
18192
+ // buffer-overflow surface). Discard the started attempt's provisional
18193
+ // charge up front; the discard is a no-op when no attempt is currently
18194
+ // charged.
18195
+ if ((localSnapshot || localBufferOverflow) && managedFallback) controller.discardStartedAttempt();
18196
+ // Local snapshot/staging failures are deterministic for the retained
18197
+ // producer shape. Re-streaming the same request only duplicates the same
18198
+ // local defect (and, before #4578, amplified it three times). Surface the
18199
+ // one producer-boundary diagnostic immediately without provider-fallback
18200
+ // attribution or credential mutation.
18201
+ if (localSnapshot || localBufferOverflow) {
18202
+ return managedOutcome
18203
+ ? {
18204
+ type: "terminal",
18205
+ terminal: { stopReason: "error", messages: [message] },
18206
+ }
18207
+ : false;
18208
+ }
18209
+ // retry.enabled=false always surfaces immediately, matching the explicit
18210
+ // user opt-out. The managed provider-fallback chain keeps its own
18211
+ // availability policy.
18212
+ if (!retrySettings.enabled && !managedFallback) {
18213
+ return managedOutcome
18214
+ ? {
18215
+ type: "terminal",
18216
+ terminal: { stopReason: "error", messages: [message] },
18217
+ }
18218
+ : false;
18219
+ }
16046
18220
  const firstEventTimeout = classification === "first_event_timeout";
16047
18221
  const emptyResponse = classification === "empty_response";
18222
+ const reportedRetryMaxAttempts = transportFailure?.retryMaxAttempts;
18223
+ if (reportedRetryMaxAttempts !== undefined) {
18224
+ this.#providerRetryMaxAttempts = Math.min(
18225
+ this.#providerRetryMaxAttempts ?? Number.POSITIVE_INFINITY,
18226
+ reportedRetryMaxAttempts,
18227
+ );
18228
+ }
18229
+ const providerRetryMaxAttempts = this.#providerRetryMaxAttempts;
16048
18230
  // Content-free message-only watchdog prose (wrapped canonical or bare
16049
18231
  // per-provider variants) is admitted like the typed path: it is
16050
18232
  // replay-safe, so a bare-default retry may re-issue the request even
@@ -16076,18 +18258,24 @@ export class AgentSession {
16076
18258
  }
16077
18259
  : false;
16078
18260
  }
16079
- const trigger = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
18261
+ const trigger:
18262
+ | { class: FallbackTriggerClass; retryAfterMs?: number; authDisposition?: AuthDisposition }
18263
+ | undefined = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
16080
18264
  if (!trigger) {
16081
18265
  return managedOutcome
16082
18266
  ? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
16083
18267
  : false;
16084
18268
  }
18269
+ const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
18270
+ const providerRetryCeilingReached =
18271
+ providerRetryMaxAttempts !== undefined && attemptsUsed >= providerRetryMaxAttempts;
16085
18272
  // Credential rotation: a content-free quota/rate-limit failure has no
16086
18273
  // observable state to corrupt, so it is replay-safe regardless of
16087
18274
  // extension lifecycle participation. Mark the failed credential and
16088
18275
  // retry with the next stored credential of the same provider.
16089
18276
  let credentialRotated =
16090
18277
  !managedFallback &&
18278
+ !providerRetryCeilingReached &&
16091
18279
  !assistantMessageHasVisibleOrToolContent(message) &&
16092
18280
  (trigger.class === "quota" || trigger.class === "rate_limit") &&
16093
18281
  (await this.#markFailedCredential(trigger));
@@ -16095,7 +18283,9 @@ export class AgentSession {
16095
18283
  // output, no tool calls, no extension-observable streaming state was produced
16096
18284
  // before the failure. This bypasses #hasCleanRetryReplaySafety because the
16097
18285
  // content-free check is the replay-safety guarantee for credential rotation.
16098
- const canReplayRotatedCredential = credentialRotated;
18286
+ // A reached provider ceiling also continues through this gate only so the
18287
+ // bounded exhaustion path can surface its exact diagnostic; it never replays.
18288
+ const canReplayRotatedCredential = credentialRotated || providerRetryCeilingReached;
16099
18289
  // Universal automatic replay-safety gate (#3791): once the failed attempt
16100
18290
  // carries observable assistant text, thinking, or tool-call content,
16101
18291
  // automatic session retry must not re-issue the request. This covers
@@ -16133,17 +18323,30 @@ export class AgentSession {
16133
18323
  !managedFallback &&
16134
18324
  classification === "transient" &&
16135
18325
  !this.#isIdleStreamStallErrorMessage(message.errorMessage ?? "");
16136
- const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
18326
+
16137
18327
  const failedSelector = managedFallback ? controller.currentSelector() : undefined;
16138
- let outcome = managedFallback
16139
- ? controller.onAttemptFailure(trigger.class, message.errorMessage || "Unknown error")
16140
- : legacyUnbounded || attemptsUsed <= retrySettings.maxRetries
16141
- ? "retry"
16142
- : "exhausted";
18328
+ let outcome: "retry" | "advance" | "exhausted";
18329
+ if (managedFallback) {
18330
+ outcome = controller.onAttemptFailure(trigger.class, message.errorMessage || "Unknown error");
18331
+ if (providerRetryCeilingReached && outcome === "retry") {
18332
+ outcome = controller.advance() ? "advance" : "exhausted";
18333
+ }
18334
+ } else {
18335
+ outcome = providerRetryCeilingReached
18336
+ ? "exhausted"
18337
+ : legacyUnbounded || attemptsUsed <= retrySettings.maxRetries
18338
+ ? "retry"
18339
+ : "exhausted";
18340
+ }
16143
18341
  // Credential rotation is unbounded: a fresh credential is a different
16144
18342
  // retry dimension from transient-error backoff, so it overrides maxRetries
16145
18343
  // exhaustion and forces an immediate same-model retry.
16146
- if (managedFallback && outcome === "advance" && (trigger.class === "quota" || trigger.class === "rate_limit")) {
18344
+ if (
18345
+ managedFallback &&
18346
+ !providerRetryCeilingReached &&
18347
+ outcome === "advance" &&
18348
+ (trigger.class === "quota" || trigger.class === "rate_limit")
18349
+ ) {
16147
18350
  credentialRotated = await this.#markFailedCredential(trigger);
16148
18351
  }
16149
18352
  if (credentialRotated) {
@@ -16157,6 +18360,9 @@ export class AgentSession {
16157
18360
  outcome = "retry";
16158
18361
  }
16159
18362
  }
18363
+ if (outcome === "advance") {
18364
+ this.#providerRetryMaxAttempts = undefined;
18365
+ }
16160
18366
  if (outcome === "exhausted") {
16161
18367
  if (managedFallback) {
16162
18368
  const errorMessage = this.#fallbackExhaustionError(controller);
@@ -16165,9 +18371,13 @@ export class AgentSession {
16165
18371
  controller.resetSticky();
16166
18372
  return managedOutcome ? this.#managedFallbackExhaustionDecision(message, errorMessage) : false;
16167
18373
  }
16168
- if (classification === "first_event_timeout") {
18374
+ if (classification === "first_event_timeout" || providerRetryMaxAttempts !== undefined) {
16169
18375
  const elapsedMs = Date.now() - (this.#firstEventTimeoutRetryStartedAt ?? Date.now());
16170
- message.errorMessage = `First-event stream timeout exhausted after ${attemptsUsed} attempts; waited ${elapsedMs}ms total: ${message.errorMessage ?? "Unknown error"}`;
18376
+ const prefix =
18377
+ classification === "first_event_timeout"
18378
+ ? "First-event stream timeout exhausted"
18379
+ : "First-event/grace retry ceiling exhausted";
18380
+ message.errorMessage = `${prefix} after ${attemptsUsed} attempts; waited ${elapsedMs}ms total: ${message.errorMessage ?? "Unknown error"}`;
16171
18381
  }
16172
18382
  return false;
16173
18383
  }
@@ -16190,7 +18400,9 @@ export class AgentSession {
16190
18400
  }
16191
18401
 
16192
18402
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
16193
- if (managedFallback && !credentialRotated) await this.#markFailedCredential(trigger);
18403
+ if (managedFallback && !credentialRotated && !providerRetryCeilingReached) {
18404
+ await this.#markFailedCredential(trigger);
18405
+ }
16194
18406
  let advanced = outcome !== "advance";
16195
18407
  let resolutionError: unknown;
16196
18408
  if (outcome === "advance") {
@@ -16233,11 +18445,17 @@ export class AgentSession {
16233
18445
  await this.#emitSessionEvent({
16234
18446
  type: "auto_retry_start",
16235
18447
  attempt: this.#retryAttempt,
16236
- maxAttempts: managedFallback
16237
- ? controller.maxAttempts
16238
- : firstEventTimeout
16239
- ? retrySettings.maxRetries + 1
16240
- : retrySettings.maxRetries,
18448
+ maxAttempts:
18449
+ managedFallback && !localSnapshot
18450
+ ? firstEventTimeout || this.#providerRetryMaxAttempts !== undefined
18451
+ ? Math.min(controller.maxAttempts, this.#providerRetryMaxAttempts ?? Number.POSITIVE_INFINITY)
18452
+ : controller.maxAttempts
18453
+ : firstEventTimeout || this.#providerRetryMaxAttempts !== undefined
18454
+ ? Math.min(
18455
+ retrySettings.maxRetries + 1,
18456
+ this.#providerRetryMaxAttempts ?? Number.POSITIVE_INFINITY,
18457
+ )
18458
+ : retrySettings.maxRetries,
16241
18459
  delayMs,
16242
18460
  errorMessage,
16243
18461
  unbounded: managedFallback ? false : legacyUnbounded,
@@ -16440,6 +18658,7 @@ export class AgentSession {
16440
18658
  }
16441
18659
  if (!preflightAccepted) {
16442
18660
  await seam?.onPreflightAccepted?.();
18661
+ if (seam?.signal?.aborted) throw promptPreflightCancelledError();
16443
18662
  preflightAccepted = true;
16444
18663
  }
16445
18664
  await this.agent.prompt(messages, options);
@@ -16545,6 +18764,7 @@ export class AgentSession {
16545
18764
  if (lastMsg.role !== "assistant") {
16546
18765
  if (!this.#isInterruptedRetryTail(lastMsg)) return false;
16547
18766
  this.#retryAttempt = 0;
18767
+ this.#providerRetryMaxAttempts = undefined;
16548
18768
  this.#scheduleAgentContinue({ delayMs: 1 });
16549
18769
  return true;
16550
18770
  }
@@ -16563,6 +18783,7 @@ export class AgentSession {
16563
18783
 
16564
18784
  // Reset retry budget for a fresh attempt
16565
18785
  this.#retryAttempt = 0;
18786
+ this.#providerRetryMaxAttempts = undefined;
16566
18787
 
16567
18788
  // Re-attempt the turn
16568
18789
  this.#scheduleAgentContinue({ delayMs: 1 });
@@ -17657,7 +19878,7 @@ export class AgentSession {
17657
19878
  }
17658
19879
  options?.onTransitionMutationStarted?.();
17659
19880
 
17660
- const asyncManager = AsyncJobManager.instance();
19881
+ const asyncManager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
17661
19882
  const ownerId = this.#agentId;
17662
19883
  const lease =
17663
19884
  switchingToDifferentSession && asyncManager && ownerId
@@ -17719,6 +19940,13 @@ export class AgentSession {
17719
19940
  await this.sessionManager.setSessionFile(sessionPath, {
17720
19941
  deferEphemeralArtifactRetirement: switchingToDifferentSession,
17721
19942
  });
19943
+ // setSessionFile rotated the endpoint identity to the successor;
19944
+ // re-register the manager under it so post-transition lineage
19945
+ // bindings resolve and owned aborts classify in the successor
19946
+ // session (review thread P1).
19947
+ this.#rekeyJobManagerForSessionIdentity(previousSessionState.sessionId, previousSessionState.sessionFile, {
19948
+ retirePredecessorRegistrations: false,
19949
+ });
17722
19950
  if (switchingToDifferentSession) this.sessionManager.stageAdoptedArtifactManagerForTransition();
17723
19951
  // The successor identity is already rotated in the manager but not yet
17724
19952
  // published; gate its local:// root before publication so the agent,
@@ -17865,6 +20093,10 @@ export class AgentSession {
17865
20093
  }
17866
20094
 
17867
20095
  if (switchingToDifferentSession) {
20096
+ const predecessorEndpointId = this.#asyncJobEndpointId(
20097
+ previousSessionState.sessionId,
20098
+ previousSessionState.sessionFile,
20099
+ );
17868
20100
  let ownerShutdownSettled = true;
17869
20101
  if (ownerShutdownManager && ownerShutdownLease && ownerId) {
17870
20102
  try {
@@ -17879,7 +20111,12 @@ export class AgentSession {
17879
20111
  } catch (error) {
17880
20112
  ownerShutdownSettled = false;
17881
20113
  ownerShutdownFinalizationDeferred = true;
17882
- this.#scheduleDeferredOwnerShutdownFinalization(ownerShutdownManager, ownerShutdownLease, ownerId);
20114
+ this.#scheduleDeferredOwnerShutdownFinalization(
20115
+ ownerShutdownManager,
20116
+ ownerShutdownLease,
20117
+ ownerId,
20118
+ predecessorEndpointId,
20119
+ );
17883
20120
  this.#suppressOwnAsyncJobDeliveries();
17884
20121
  this.emitNotice(
17885
20122
  "error",
@@ -17892,6 +20129,10 @@ export class AgentSession {
17892
20129
  // Different files may intentionally carry the same copied session id; pathname transition is the commit signal.
17893
20130
  ownerShutdownTransitionCommitted = true;
17894
20131
  if (ownerShutdownSettled) {
20132
+ // Every predecessor job/delivery has settled. Only now can its
20133
+ // tuple evidence be retired; doing this immediately after rekey
20134
+ // made rollback restore live jobs without their owned tuples.
20135
+ retireOwnedRegistrationsForEndpoint(predecessorEndpointId);
17895
20136
  this.sessionManager.retireEphemeralArtifactsAfterTransition();
17896
20137
  await this.#runToolSessionTransitionCleanups();
17897
20138
  }
@@ -17917,6 +20158,35 @@ export class AgentSession {
17917
20158
  return true;
17918
20159
  } catch (error) {
17919
20160
  if (transitionCleanupCommitted) throw error;
20161
+ // The switch never committed: rotate the manager's endpoint
20162
+ // registration back to the predecessor before restoring it
20163
+ // (review thread P1 — the map key must track the session id).
20164
+ const successorEndpointId = this.#asyncJobEndpointId(
20165
+ this.sessionManager.getSessionId(),
20166
+ this.sessionManager.getSessionFile(),
20167
+ );
20168
+ const predecessorEndpointId = this.#asyncJobEndpointId(
20169
+ previousSessionState.sessionId,
20170
+ previousSessionState.sessionFile,
20171
+ );
20172
+ const rekeyed = AsyncJobManager.rekeyForEndpoint(
20173
+ successorEndpointId,
20174
+ predecessorEndpointId,
20175
+ AsyncJobManager.forEndpoint(successorEndpointId),
20176
+ );
20177
+ if (!rekeyed) {
20178
+ // Another top-level session claimed the freed predecessor endpoint
20179
+ // while the switch's cleanup was in flight: restoring the old
20180
+ // identity would leave this manager registered under the successor
20181
+ // while tool lookups for the restored identity resolve the foreign
20182
+ // manager, so same-ID jobs could be queried or cancelled across
20183
+ // sessions and owned aborts lose their causal set (review thread
20184
+ // P1). Fail the rollback instead of restoring into misattributed
20185
+ // ownership; the caller surfaces the switch failure.
20186
+ throw new Error(
20187
+ `Session switch rollback aborted: predecessor endpoint "${previousSessionState.sessionId}" is no longer owned by this session.`,
20188
+ );
20189
+ }
17920
20190
  await this.sessionManager.restoreRollbackState(previousSessionState);
17921
20191
  this.#defaultFallbackController = undefined;
17922
20192
  this.#syncAgentSessionId(previousSessionState.sessionId);
@@ -18008,6 +20278,7 @@ export class AgentSession {
18008
20278
  try {
18009
20279
  const previousSessionFile = this.sessionFile;
18010
20280
  const previousWorkflowGateSessionId = this.sessionId;
20281
+ const previousSessionIdentity = this.sessionManager.getSessionId();
18011
20282
  const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
18012
20283
 
18013
20284
  if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
@@ -18039,7 +20310,11 @@ export class AgentSession {
18039
20310
  try {
18040
20311
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
18041
20312
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
20313
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
18042
20314
  this.sessionManager.commitPreparedNewSession(prepared);
20315
+ // Branch commits a successor endpoint identity; re-register the
20316
+ // manager under it (review thread P1).
20317
+ this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
18043
20318
  await this.#runToolSessionTransitionCleanups();
18044
20319
  } catch (error) {
18045
20320
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);