@gajae-code/coding-agent 0.13.2 → 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 (610) hide show
  1. package/CHANGELOG.md +215 -8
  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 +10 -1
  35. package/dist/types/config/models-config-schema.d.ts +2 -0
  36. package/dist/types/config/settings-schema.d.ts +73 -9
  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 +2 -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/dynamic-theme-text.d.ts +9 -0
  87. package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
  88. package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
  89. package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
  90. package/dist/types/modes/components/oauth-selector.d.ts +10 -4
  91. package/dist/types/modes/components/pet-capability.d.ts +7 -21
  92. package/dist/types/modes/components/settings-selector.d.ts +5 -0
  93. package/dist/types/modes/components/status-line/gh.d.ts +6 -2
  94. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  95. package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
  96. package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
  97. package/dist/types/modes/interactive-mode.d.ts +1 -0
  98. package/dist/types/modes/theme/defaults/index.d.ts +118 -0
  99. package/dist/types/modes/types.d.ts +1 -0
  100. package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
  101. package/dist/types/runtime-mcp/config.d.ts +9 -0
  102. package/dist/types/runtime-mcp/manager.d.ts +26 -1
  103. package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
  104. package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
  105. package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
  106. package/dist/types/runtime-mcp/protocol.d.ts +278 -0
  107. package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
  108. package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
  109. package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
  110. package/dist/types/runtime-mcp/types.d.ts +102 -10
  111. package/dist/types/sdk/acp/adapter.d.ts +26 -5
  112. package/dist/types/sdk/broker/broker.d.ts +3 -55
  113. package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
  114. package/dist/types/sdk/broker/session-index.d.ts +14 -5
  115. package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
  116. package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
  117. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
  118. package/dist/types/sdk/bus/config.d.ts +11 -0
  119. package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
  120. package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
  121. package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
  122. package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
  123. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
  124. package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
  125. package/dist/types/sdk/bus/index.d.ts +85 -99
  126. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
  127. package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
  128. package/dist/types/sdk/bus/notification-service.d.ts +52 -0
  129. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
  130. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
  131. package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
  132. package/dist/types/sdk/bus/session-control.d.ts +25 -1
  133. package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
  134. package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
  135. package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
  136. package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
  137. package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
  138. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
  139. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
  140. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
  141. package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
  142. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
  143. package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
  144. package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
  145. package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
  146. package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
  147. package/dist/types/sdk/cli/rows.d.ts +2 -11
  148. package/dist/types/sdk/cli/session-cli.d.ts +15 -25
  149. package/dist/types/sdk/client/client.d.ts +97 -1
  150. package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
  151. package/dist/types/sdk/host/control/index.d.ts +1 -1
  152. package/dist/types/sdk/host/control/operations.d.ts +18 -5
  153. package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
  154. package/dist/types/sdk/host/host.d.ts +15 -1
  155. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  156. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  157. package/dist/types/sdk/host/session-runtime.d.ts +127 -7
  158. package/dist/types/sdk/host/types.d.ts +1 -1
  159. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  160. package/dist/types/sdk/index.d.ts +2 -1
  161. package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
  162. package/dist/types/sdk/lifecycle/client.d.ts +59 -0
  163. package/dist/types/sdk/lifecycle/index.d.ts +3 -0
  164. package/dist/types/sdk/lifecycle/service.d.ts +232 -0
  165. package/dist/types/sdk/mcp/server.d.ts +9 -6
  166. package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
  167. package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
  168. package/dist/types/sdk/prompt-status.d.ts +10 -3
  169. package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
  170. package/dist/types/sdk/receipt-state.d.ts +17 -0
  171. package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
  172. package/dist/types/sdk/router/index.d.ts +1 -0
  173. package/dist/types/sdk/router/session-router.d.ts +152 -0
  174. package/dist/types/sdk/session-list.d.ts +22 -0
  175. package/dist/types/sdk/session-reconnect.d.ts +43 -1
  176. package/dist/types/sdk/session.d.ts +34 -1
  177. package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
  178. package/dist/types/session/account-inventory.d.ts +86 -0
  179. package/dist/types/session/agent-session.d.ts +145 -6
  180. package/dist/types/session/agent-storage.d.ts +10 -0
  181. package/dist/types/session/coordinator-tool-label.d.ts +28 -0
  182. package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
  183. package/dist/types/session/messages.d.ts +1 -1
  184. package/dist/types/session/secure-token-file.d.ts +3 -0
  185. package/dist/types/session/session-manager.d.ts +47 -1
  186. package/dist/types/session/startup-auth-config.d.ts +44 -0
  187. package/dist/types/session/terminal-abort.d.ts +378 -0
  188. package/dist/types/session/yield-queue.d.ts +7 -0
  189. package/dist/types/session-import/claude.d.ts +26 -0
  190. package/dist/types/session-import/codex.d.ts +88 -0
  191. package/dist/types/session-import/command.d.ts +35 -0
  192. package/dist/types/session-import/detect.d.ts +22 -0
  193. package/dist/types/session-import/index.d.ts +7 -0
  194. package/dist/types/session-import/provider-codex.d.ts +22 -0
  195. package/dist/types/session-import/provider-service.d.ts +38 -0
  196. package/dist/types/session-import/redact.d.ts +23 -0
  197. package/dist/types/session-import/service.d.ts +47 -0
  198. package/dist/types/session-import/types.d.ts +120 -0
  199. package/dist/types/setup/paseo/check.d.ts +16 -0
  200. package/dist/types/setup/paseo/install-saga.d.ts +115 -0
  201. package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
  202. package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
  203. package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
  204. package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
  205. package/dist/types/setup/paseo/provider-config.d.ts +35 -0
  206. package/dist/types/setup/paseo/remove.d.ts +14 -0
  207. package/dist/types/setup/paseo/result-types.d.ts +59 -0
  208. package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
  209. package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
  210. package/dist/types/skill-state/active-state.d.ts +5 -1
  211. package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
  212. package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
  213. package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
  214. package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
  215. package/dist/types/slash-commands/types.d.ts +7 -1
  216. package/dist/types/task/executor.d.ts +10 -0
  217. package/dist/types/task/index.d.ts +3 -1
  218. package/dist/types/tools/ask-answer-registry.d.ts +6 -3
  219. package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
  220. package/dist/types/tools/bash.d.ts +2 -6
  221. package/dist/types/tools/browser/attach.d.ts +11 -0
  222. package/dist/types/tools/browser/launch.d.ts +20 -0
  223. package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
  224. package/dist/types/tools/browser/render.d.ts +1 -0
  225. package/dist/types/tools/browser.d.ts +3 -1
  226. package/dist/types/tools/image-gen.d.ts +10 -2
  227. package/dist/types/tools/index.d.ts +8 -0
  228. package/dist/types/tools/monitor.d.ts +1 -1
  229. package/dist/types/tools/skill-discovery.d.ts +8 -1
  230. package/dist/types/tools/subagent.d.ts +2 -0
  231. package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
  232. package/dist/types/utils/herdr-pane.d.ts +76 -0
  233. package/package.json +19 -9
  234. package/scripts/build-binary.ts +2 -1
  235. package/scripts/build-sdk-package-smoke.ts +2 -13
  236. package/scripts/capture-sticky-viewport-showcase.ts +0 -1
  237. package/scripts/compile-args.ts +2 -0
  238. package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
  239. package/scripts/generate-sdk-operation-inventory.ts +28 -0
  240. package/scripts/generate-telegram-baseline-manifest.ts +1 -0
  241. package/scripts/generate-tool-catalog.ts +6 -4
  242. package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
  243. package/src/async/job-manager.ts +352 -17
  244. package/src/async/support.ts +25 -0
  245. package/src/autoresearch/dashboard.ts +28 -13
  246. package/src/capability/index.ts +3 -1
  247. package/src/capability/mcp.ts +6 -0
  248. package/src/capability/types.ts +6 -0
  249. package/src/cli/accounts-cli.ts +395 -0
  250. package/src/cli/args.ts +23 -0
  251. package/src/cli/auth-broker-cli.ts +136 -66
  252. package/src/cli/auth-gateway-cli.ts +94 -70
  253. package/src/cli/config-cli.ts +27 -2
  254. package/src/cli/crash-cli.ts +78 -0
  255. package/src/cli/customize-doctor.ts +1605 -0
  256. package/src/cli/daemon-cli.ts +12 -3
  257. package/src/cli/fast-help.ts +1 -0
  258. package/src/cli/mcp-cli.ts +128 -60
  259. package/src/cli/notify-cli.ts +8 -14
  260. package/src/cli/root-flags.ts +8 -0
  261. package/src/cli/setup-cli.ts +145 -0
  262. package/src/cli/skills-cli.ts +38 -2
  263. package/src/cli/update-cli.ts +167 -12
  264. package/src/cli.ts +4 -0
  265. package/src/commands/accounts.ts +71 -0
  266. package/src/commands/crash.ts +35 -0
  267. package/src/commands/customize.ts +84 -0
  268. package/src/commands/daemon.ts +2 -2
  269. package/src/commands/deep-interview.ts +2 -0
  270. package/src/commands/launch.ts +1 -0
  271. package/src/commands/mcp.ts +12 -7
  272. package/src/commands/ralplan.ts +2 -0
  273. package/src/commands/sdk.ts +65 -32
  274. package/src/commands/setup.ts +5 -0
  275. package/src/commands/skills.ts +14 -4
  276. package/src/commands/ultragoal.ts +10 -1
  277. package/src/commands/update.ts +26 -15
  278. package/src/config/atomic-yaml-patch-worker.ts +57 -0
  279. package/src/config/atomic-yaml-patch.ts +769 -29
  280. package/src/config/config-schema-version.ts +10 -0
  281. package/src/config/file-lock.ts +70 -1
  282. package/src/config/mcp-schema.json +52 -14
  283. package/src/config/model-profile-activation.ts +23 -2
  284. package/src/config/model-profiles.ts +88 -21
  285. package/src/config/model-registry.ts +147 -21
  286. package/src/config/models-config-schema.ts +1 -1
  287. package/src/config/settings-schema.ts +77 -7
  288. package/src/config/settings.ts +4038 -199
  289. package/src/config/skill-settings-defaults.ts +41 -3
  290. package/src/config/task-delegation.ts +87 -0
  291. package/src/config/update-channel.ts +23 -0
  292. package/src/coordinator-mcp/server.ts +377 -308
  293. package/src/crash/index-store.ts +723 -0
  294. package/src/crash/nudge.ts +89 -0
  295. package/src/crash/record-loader.ts +149 -0
  296. package/src/crash/report.ts +551 -0
  297. package/src/crash/sanitize.ts +129 -0
  298. package/src/cursor.ts +36 -10
  299. package/src/customization/import.ts +946 -0
  300. package/src/customization/index.ts +4 -0
  301. package/src/customization/inventory.ts +335 -0
  302. package/src/customization/mutations.ts +141 -0
  303. package/src/customization/types.ts +223 -0
  304. package/src/daemon/control-types.ts +8 -1
  305. package/src/daemon/operator-contract.ts +6 -6
  306. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
  307. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
  308. package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
  309. package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
  310. package/src/discovery/claude-hooks.ts +38 -0
  311. package/src/discovery/claude.ts +142 -209
  312. package/src/discovery/codex-hooks.ts +38 -0
  313. package/src/discovery/codex.ts +132 -198
  314. package/src/discovery/helpers.ts +8 -1
  315. package/src/discovery/index.ts +2 -0
  316. package/src/discovery/mcp-compat.ts +234 -0
  317. package/src/discovery/mcp-json.ts +6 -0
  318. package/src/edit/renderer.ts +40 -0
  319. package/src/edit/streaming.ts +4 -2
  320. package/src/exec/bash-executor.ts +24 -20
  321. package/src/export/html/index.ts +51 -2
  322. package/src/extensibility/extensions/loader.ts +9 -1
  323. package/src/extensibility/extensions/runner.ts +17 -4
  324. package/src/extensibility/extensions/types.ts +31 -6
  325. package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
  326. package/src/extensibility/gjc-plugins/installer.ts +9 -5
  327. package/src/extensibility/gjc-plugins/observability.ts +8 -2
  328. package/src/extensibility/gjc-plugins/registry.ts +13 -2
  329. package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
  330. package/src/extensibility/gjc-plugins/schema.ts +222 -39
  331. package/src/extensibility/hooks/loader.ts +154 -10
  332. package/src/extensibility/runtime-skill-discovery.ts +257 -60
  333. package/src/extensibility/skill-management.ts +368 -0
  334. package/src/extensibility/skills.ts +52 -16
  335. package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
  336. package/src/gjc-runtime/launch-worktree.ts +56 -2
  337. package/src/gjc-runtime/linux-proc.ts +7 -1
  338. package/src/gjc-runtime/ralplan-runtime.ts +100 -164
  339. package/src/gjc-runtime/session-state-lock.ts +992 -0
  340. package/src/gjc-runtime/session-state-sidecar.ts +644 -142
  341. package/src/gjc-runtime/state-writer.ts +39 -14
  342. package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
  343. package/src/gjc-runtime/ultragoal-guard.ts +11 -4
  344. package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
  345. package/src/gjc-runtime/workflow-settings.ts +596 -0
  346. package/src/goals/tools/goal-tool.ts +6 -1
  347. package/src/hooks/events.ts +291 -0
  348. package/src/hooks/native-skill-hook.ts +4 -0
  349. package/src/hooks/normalize.ts +369 -0
  350. package/src/hooks/skill-state.ts +165 -46
  351. package/src/internal-urls/docs-index.generated.ts +41 -32
  352. package/src/main.ts +36 -5
  353. package/src/migrate/mcp-mapper.ts +25 -2
  354. package/src/modes/acp/abort-scope.ts +29 -0
  355. package/src/modes/acp/acp-agent.ts +423 -98
  356. package/src/modes/components/assistant-message.ts +6 -1
  357. package/src/modes/components/chrome.ts +4 -3
  358. package/src/modes/components/customization/customization-dashboard.ts +443 -0
  359. package/src/modes/components/customization/import-wizard.ts +372 -0
  360. package/src/modes/components/customization/index.ts +1 -0
  361. package/src/modes/components/dynamic-theme-text.ts +22 -0
  362. package/src/modes/components/gajae-pet-widget.ts +607 -193
  363. package/src/modes/components/irc-sidebar.ts +11 -0
  364. package/src/modes/components/iterm-pet-transport.ts +585 -0
  365. package/src/modes/components/model-selector.ts +1 -6
  366. package/src/modes/components/oauth-selector.ts +277 -86
  367. package/src/modes/components/pet-capability.ts +35 -31
  368. package/src/modes/components/settings-selector.ts +29 -5
  369. package/src/modes/components/status-line/gh.ts +45 -1
  370. package/src/modes/components/status-line/segments.ts +19 -4
  371. package/src/modes/components/tool-status-header.ts +13 -3
  372. package/src/modes/controllers/command-controller.ts +20 -8
  373. package/src/modes/controllers/extension-ui-controller.ts +56 -257
  374. package/src/modes/controllers/input-controller.ts +10 -0
  375. package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
  376. package/src/modes/controllers/selector-controller.ts +238 -77
  377. package/src/modes/controllers/stt-controller.ts +3 -3
  378. package/src/modes/interactive-mode.ts +88 -16
  379. package/src/modes/print-mode.ts +35 -6
  380. package/src/modes/runtime-init.ts +21 -68
  381. package/src/modes/theme/defaults/index.ts +2 -0
  382. package/src/modes/theme/defaults/ouroboros.json +118 -0
  383. package/src/modes/types.ts +1 -0
  384. package/src/modes/utils/injected-user-submission.ts +25 -0
  385. package/src/modes/utils/terminal-bell.ts +15 -1
  386. package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
  387. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  388. package/src/prompts/agents/critic.md +1 -1
  389. package/src/prompts/system/rlm-research.md +1 -1
  390. package/src/prompts/system/system-prompt.md +0 -2
  391. package/src/prompts/tools/bash.md +7 -1
  392. package/src/prompts/tools/browser.md +1 -1
  393. package/src/prompts/tools/cron.md +6 -6
  394. package/src/prompts/tools/read.md +0 -2
  395. package/src/prompts/tools/skill-discovery.md +4 -2
  396. package/src/runtime-mcp/client.ts +427 -23
  397. package/src/runtime-mcp/config.ts +37 -5
  398. package/src/runtime-mcp/manager-pool.test.ts +56 -0
  399. package/src/runtime-mcp/manager.ts +52 -2
  400. package/src/runtime-mcp/oauth-discovery.ts +13 -0
  401. package/src/runtime-mcp/oauth-flow.ts +46 -1
  402. package/src/runtime-mcp/pool-key.ts +5 -1
  403. package/src/runtime-mcp/pool.test.ts +9 -0
  404. package/src/runtime-mcp/protocol.test.ts +353 -0
  405. package/src/runtime-mcp/protocol.ts +624 -0
  406. package/src/runtime-mcp/scope-config.ts +78 -0
  407. package/src/runtime-mcp/tool-bridge.ts +44 -10
  408. package/src/runtime-mcp/transports/http.ts +84 -13
  409. package/src/runtime-mcp/types.ts +123 -12
  410. package/src/sdk/acp/adapter.ts +328 -97
  411. package/src/sdk/broker/broker.ts +160 -741
  412. package/src/sdk/broker/ensure.ts +9 -1
  413. package/src/sdk/broker/lifecycle.ts +510 -116
  414. package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
  415. package/src/sdk/broker/process-incarnation.ts +19 -0
  416. package/src/sdk/broker/session-index.ts +516 -117
  417. package/src/sdk/broker/startup-failure.ts +28 -5
  418. package/src/sdk/broker/transport.ts +1 -13
  419. package/src/sdk/bus/chat-daemon-cli.ts +4 -2
  420. package/src/sdk/bus/chat-daemon-control.ts +67 -6
  421. package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
  422. package/src/sdk/bus/config.ts +13 -0
  423. package/src/sdk/bus/conversation-store.ts +59 -171
  424. package/src/sdk/bus/daemon-paths.ts +20 -0
  425. package/src/sdk/bus/discord-conversation.ts +2 -0
  426. package/src/sdk/bus/discord-daemon.ts +524 -126
  427. package/src/sdk/bus/discord-live-provider.ts +206 -79
  428. package/src/sdk/bus/existing-thread-readiness.ts +4 -97
  429. package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
  430. package/src/sdk/bus/index.ts +2480 -456
  431. package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
  432. package/src/sdk/bus/lifecycle-commands.ts +2 -2
  433. package/src/sdk/bus/managed-daemon.ts +2 -145
  434. package/src/sdk/bus/notification-service.ts +196 -0
  435. package/src/sdk/bus/operator-runtime.ts +1 -1
  436. package/src/sdk/bus/prompt-reconciliation.ts +24 -15
  437. package/src/sdk/bus/reconciliation-store.ts +553 -24
  438. package/src/sdk/bus/session-control.ts +49 -3
  439. package/src/sdk/bus/slack-conversation.ts +4 -0
  440. package/src/sdk/bus/slack-daemon.ts +733 -134
  441. package/src/sdk/bus/slack-live-provider.ts +65 -15
  442. package/src/sdk/bus/slack-provider.ts +4 -0
  443. package/src/sdk/bus/slack-thread-binding.ts +9 -104
  444. package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
  445. package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
  446. package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
  447. package/src/sdk/bus/telegram-daemon-control.ts +3 -13
  448. package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
  449. package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
  450. package/src/sdk/bus/telegram-daemon.ts +2552 -2280
  451. package/src/sdk/bus/telegram-reference.ts +1 -226
  452. package/src/sdk/bus/telegram-setup.ts +2 -2
  453. package/src/sdk/bus/threaded-render.ts +1 -0
  454. package/src/sdk/bus/topic-registry.ts +279 -146
  455. package/src/sdk/cli/rows.ts +2 -35
  456. package/src/sdk/cli/session-cli.ts +776 -700
  457. package/src/sdk/client/client.ts +892 -1
  458. package/src/sdk/host/control/dispatch.ts +93 -58
  459. package/src/sdk/host/control/index.ts +1 -1
  460. package/src/sdk/host/control/operations.ts +18 -10
  461. package/src/sdk/host/control/runtime-gate.ts +40 -0
  462. package/src/sdk/host/host.ts +63 -12
  463. package/src/sdk/host/query/handlers.ts +66 -7
  464. package/src/sdk/host/reverse-leases.ts +2 -2
  465. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  466. package/src/sdk/host/session-runtime.test.ts +2787 -34
  467. package/src/sdk/host/session-runtime.ts +1489 -101
  468. package/src/sdk/host/surface-policy.ts +12 -8
  469. package/src/sdk/host/types.ts +1 -1
  470. package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
  471. package/src/sdk/host/websocket-transport.ts +85 -50
  472. package/src/sdk/index.ts +2 -1
  473. package/src/sdk/lifecycle/broker-client.ts +53 -0
  474. package/src/sdk/lifecycle/client.ts +234 -0
  475. package/src/sdk/lifecycle/index.ts +3 -0
  476. package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
  477. package/src/sdk/lifecycle/service.ts +720 -0
  478. package/src/sdk/mcp/server.ts +226 -188
  479. package/src/sdk/prompt-deadline-lease.ts +55 -0
  480. package/src/sdk/prompt-deadline-manager.ts +148 -0
  481. package/src/sdk/prompt-status.ts +11 -3
  482. package/src/sdk/protocol/operation-inventory.generated.json +272 -129
  483. package/src/sdk/protocol/operation-registry.ts +71 -8
  484. package/src/sdk/receipt-state.ts +43 -0
  485. package/src/sdk/reconciliation-extensions.ts +14 -0
  486. package/src/sdk/router/index.ts +1 -0
  487. package/src/sdk/router/session-router.ts +1658 -0
  488. package/src/sdk/session-list.ts +93 -0
  489. package/src/sdk/session-reconnect.ts +45 -1
  490. package/src/sdk/session.ts +716 -120
  491. package/src/sdk/transport/serve-cli.ts +18 -34
  492. package/src/session/account-inventory.ts +603 -0
  493. package/src/session/agent-session.ts +2873 -559
  494. package/src/session/agent-storage.ts +35 -0
  495. package/src/session/blob-store.ts +32 -1
  496. package/src/session/coordinator-tool-label.ts +40 -0
  497. package/src/session/internal/managed-session-scope.ts +4 -0
  498. package/src/session/internal/managed-session-storage.ts +199 -31
  499. package/src/session/messages.ts +1 -1
  500. package/src/session/secure-token-file.ts +80 -0
  501. package/src/session/session-manager.ts +757 -57
  502. package/src/session/session-storage.ts +25 -16
  503. package/src/session/startup-auth-config.ts +297 -0
  504. package/src/session/terminal-abort.ts +1253 -0
  505. package/src/session/yield-queue.ts +57 -15
  506. package/src/session-import/claude.ts +334 -0
  507. package/src/session-import/codex.ts +1033 -0
  508. package/src/session-import/command.ts +77 -0
  509. package/src/session-import/detect.ts +133 -0
  510. package/src/session-import/index.ts +7 -0
  511. package/src/session-import/provider-codex.ts +327 -0
  512. package/src/session-import/provider-service.ts +457 -0
  513. package/src/session-import/redact.ts +128 -0
  514. package/src/session-import/service.ts +1264 -0
  515. package/src/session-import/types.ts +148 -0
  516. package/src/setup/paseo/check.ts +213 -0
  517. package/src/setup/paseo/install-saga.ts +293 -0
  518. package/src/setup/paseo/json-publisher.ts +263 -0
  519. package/src/setup/paseo/orchestration-preferences.ts +86 -0
  520. package/src/setup/paseo/paseo-ownership.ts +224 -0
  521. package/src/setup/paseo/paseo-setup.ts +253 -0
  522. package/src/setup/paseo/provider-config.ts +139 -0
  523. package/src/setup/paseo/remove.ts +183 -0
  524. package/src/setup/paseo/result-types.ts +79 -0
  525. package/src/setup/paseo/setup-deps.ts +175 -0
  526. package/src/setup/paseo/skills-bridge.ts +260 -0
  527. package/src/skill-state/active-state.ts +21 -11
  528. package/src/slash-commands/acp-builtins.ts +9 -1
  529. package/src/slash-commands/builtin-registry.ts +117 -19
  530. package/src/slash-commands/helpers/credential-switch.ts +143 -0
  531. package/src/slash-commands/helpers/mcp.ts +9 -3
  532. package/src/slash-commands/helpers/usage-report.ts +61 -56
  533. package/src/slash-commands/types.ts +7 -2
  534. package/src/task/executor.ts +30 -11
  535. package/src/task/index.ts +66 -7
  536. package/src/task/render.ts +23 -9
  537. package/src/tools/ask-answer-registry.ts +23 -7
  538. package/src/tools/ask.ts +8 -4
  539. package/src/tools/bash-sleep-advisory.ts +115 -0
  540. package/src/tools/bash.ts +98 -18
  541. package/src/tools/browser/attach.ts +71 -3
  542. package/src/tools/browser/launch.ts +86 -13
  543. package/src/tools/browser/profile-discovery.ts +57 -7
  544. package/src/tools/browser/render.ts +8 -1
  545. package/src/tools/browser.ts +130 -21
  546. package/src/tools/computer.enforcement.test.ts +1 -1
  547. package/src/tools/computer.ts +3 -2
  548. package/src/tools/cron.ts +12 -1
  549. package/src/tools/image-gen.ts +349 -142
  550. package/src/tools/index.ts +8 -0
  551. package/src/tools/job.ts +33 -6
  552. package/src/tools/monitor.ts +41 -2
  553. package/src/tools/skill-discovery.ts +21 -29
  554. package/src/tools/subagent.ts +142 -24
  555. package/src/tools/todo-contract.ts +55 -3
  556. package/src/tools/todo-write.ts +4 -3
  557. package/src/tools/tool-catalog.generated.ts +6 -6
  558. package/src/tools/ultragoal-ask-guard.ts +10 -3
  559. package/src/utils/herdr-pane.ts +345 -0
  560. package/src/utils/title-generator.ts +2 -0
  561. package/src/web/scrapers/index.ts +6 -4
  562. package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
  563. package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
  564. package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
  565. package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
  566. package/dist/types/runtime/lazy-service.test.d.ts +0 -1
  567. package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
  568. package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
  569. package/dist/types/runtime-mcp/config.test.d.ts +0 -1
  570. package/dist/types/runtime-mcp/http.test.d.ts +0 -1
  571. package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
  572. package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
  573. package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
  574. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
  575. package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
  576. package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
  577. package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
  578. package/dist/types/sdk/elevation/audit.d.ts +0 -93
  579. package/dist/types/sdk/elevation/capability.d.ts +0 -3
  580. package/dist/types/sdk/elevation/digest.d.ts +0 -72
  581. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
  582. package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
  583. package/dist/types/sdk/elevation/owner.d.ts +0 -48
  584. package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
  585. package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
  586. package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
  587. package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
  588. package/dist/types/session/auth-broker-config.d.ts +0 -13
  589. package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
  590. package/dist/types/skills/skills.test.d.ts +0 -1
  591. package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
  592. package/dist/types/task/token-log.test.d.ts +0 -1
  593. package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
  594. package/dist/types/tools/descriptors.test.d.ts +0 -1
  595. package/scripts/g005-daemon-smoke.ts +0 -180
  596. package/scripts/g011-daemon-path-smoke.ts +0 -153
  597. package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
  598. package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
  599. package/src/sdk/bus/telegram-cli.ts +0 -212
  600. package/src/sdk/elevation/allowlist.ts +0 -54
  601. package/src/sdk/elevation/audit.ts +0 -208
  602. package/src/sdk/elevation/capability.ts +0 -41
  603. package/src/sdk/elevation/digest.ts +0 -129
  604. package/src/sdk/elevation/dispatch-receipt.ts +0 -184
  605. package/src/sdk/elevation/grant-ledger.ts +0 -1142
  606. package/src/sdk/elevation/owner.ts +0 -109
  607. package/src/session/auth-broker-config.ts +0 -109
  608. /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
  609. /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
  610. /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,6 +1040,8 @@ 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 =
@@ -1040,7 +1132,12 @@ function assistantMessageHasVisibleOrToolContent(message: AssistantMessage): boo
1040
1132
 
1041
1133
  function isLocalModelEndpoint(model: Model | undefined): boolean {
1042
1134
  if (!model) return false;
1043
- 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
+ ) {
1044
1141
  return true;
1045
1142
  }
1046
1143
  try {
@@ -1504,6 +1601,7 @@ function extractPermissionLocations(
1504
1601
  * stable edit id while the display arrays preserve delivery order. */
1505
1602
  type QueuedDisplayEntry = { text: string; tag?: string; sequence: number };
1506
1603
  type IrcRosterClaim = { token: symbol; signature: string; epoch: number; message: CustomMessage };
1604
+ type QueuedFollowUpOwner = { cancel(): boolean };
1507
1605
  export type QueuedMessageEditMode = "steer" | "followUp";
1508
1606
 
1509
1607
  export interface QueuedMessageEditEntry {
@@ -1794,12 +1892,19 @@ type SessionAdmissionEntry = {
1794
1892
  ready: PromiseWithResolvers<void>;
1795
1893
  settled: PromiseWithResolvers<void>;
1796
1894
  released: boolean;
1895
+ selectionFenceGeneration: number;
1896
+ continuationCapability?: symbol;
1797
1897
  };
1798
1898
 
1799
1899
  type SessionAdmissionLease = {
1800
1900
  release(): void;
1801
1901
  };
1802
1902
 
1903
+ type ScheduledContinuationAdmission = {
1904
+ entry: SessionAdmissionEntry;
1905
+ capability: symbol;
1906
+ };
1907
+
1803
1908
  export interface DefaultFallbackRuntimeState {
1804
1909
  chain: ConfiguredFallbackChain;
1805
1910
  controller: FallbackChainRuntimeState;
@@ -1831,10 +1936,37 @@ function deobfuscateSessionContext(context: SessionContext, obfuscator: SecretOb
1831
1936
  return { ...context, messages };
1832
1937
  }
1833
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
+
1834
1955
  export class AgentSession {
1956
+ #provisionalStreamingToolCallIds = new Set<string>();
1835
1957
  readonly agent: Agent;
1836
1958
  sessionManager: SessionManager;
1837
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
+ }
1838
1970
  readonly memoryBackend: LazyService<MemoryBackend>;
1839
1971
  readonly notificationSessionController: NotificationSessionController | undefined;
1840
1972
  readonly taskDepth: number;
@@ -1859,8 +1991,20 @@ export class AgentSession {
1859
1991
  #preProfileModel: Model | undefined;
1860
1992
  #sessionAdmissionQueue: SessionAdmissionEntry[] = [];
1861
1993
  #activeSessionAdmission: SessionAdmissionEntry | undefined;
1994
+ #sessionAdmissionClosing = false;
1862
1995
  #sessionAdmissionClosed = false;
1863
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;
1864
2008
  #defaultModelSelectionMutationRevision = 0;
1865
2009
  #thinkingLevelMutationRevision = 0;
1866
2010
  #thinkingVisibilityMutationRevision = 0;
@@ -1930,9 +2074,62 @@ export class AgentSession {
1930
2074
  /** Tracks pending follow-up messages for UI display. Removed when delivered.
1931
2075
  * See `#steeringMessages` for entry shape. */
1932
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;
1933
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
+ }
1934
2131
  /** Messages queued to be included with the next user prompt as context ("asides"). */
1935
- #pendingNextTurnMessages: CustomMessage[] = [];
2132
+ #pendingNextTurnMessages: Array<{ message: CustomMessage; origin: "turn" | "external" }> = [];
1936
2133
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
1937
2134
  #planModeState: PlanModeState | undefined;
1938
2135
  #goalModeState: GoalModeState | undefined;
@@ -1989,6 +2186,7 @@ export class AgentSession {
1989
2186
  this.#retryReplayEpoch++;
1990
2187
  this.#retryReplayUnsafeEpoch = undefined;
1991
2188
  this.#firstEventTimeoutRetryStartedAt = Date.now();
2189
+ this.#providerRetryMaxAttempts = undefined;
1992
2190
  }
1993
2191
 
1994
2192
  #markRetryReplayUnsafe(): void {
@@ -2026,6 +2224,7 @@ export class AgentSession {
2026
2224
  #retryAbortController: AbortController | undefined = undefined;
2027
2225
  #retryNowRequested = false;
2028
2226
  #firstEventTimeoutRetryStartedAt: number | undefined;
2227
+ #providerRetryMaxAttempts: number | undefined;
2029
2228
  #retryAttempt = 0;
2030
2229
  #retryPromise: Promise<void> | undefined = undefined;
2031
2230
  #retryResolve: (() => void) | undefined = undefined;
@@ -2069,6 +2268,7 @@ export class AgentSession {
2069
2268
  * this undefined and **MUST NOT** dispose the global instance on teardown.
2070
2269
  */
2071
2270
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
2271
+ readonly #disposeAsyncJobManager: boolean;
2072
2272
  readonly #ownedMcpManager: MCPManager | undefined;
2073
2273
  #startupTurnBarrier: Promise<void> | undefined;
2074
2274
  #pendingPythonMessages: Array<{
@@ -2091,7 +2291,9 @@ export class AgentSession {
2091
2291
  #ircRosterClaim: IrcRosterClaim | null = null;
2092
2292
  #providerSessionId: string | undefined;
2093
2293
  #credentialSessionId: string | undefined;
2294
+ #credentialStoreIdentity: string | undefined;
2094
2295
  #providerCacheSessionId: string | undefined;
2296
+ readonly #asyncJobProviderSessionId: string | undefined;
2095
2297
  #isDisposed = false;
2096
2298
  #disposePromise: Promise<void> | undefined;
2097
2299
  readonly #toolSessionCleanups = new Set<() => Promise<void> | void>();
@@ -2100,6 +2302,9 @@ export class AgentSession {
2100
2302
  #newSessionTransition: Promise<boolean> | undefined;
2101
2303
  // Extension system
2102
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>();
2103
2308
  #attemptAuthority!: AttemptScopeAuthority;
2104
2309
  #attemptRecordStore!: AttemptRecordStore;
2105
2310
  #activeLogicalRunId: AttemptRunHandle["logicalRunId"] | undefined;
@@ -2137,6 +2342,17 @@ export class AgentSession {
2137
2342
 
2138
2343
  // Tool registry and prompt builder for extensions
2139
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>;
2140
2356
  #workflowGateToolSession: ToolSession | undefined;
2141
2357
  #transformContext: (
2142
2358
  messages: AgentMessage[],
@@ -2234,6 +2450,7 @@ export class AgentSession {
2234
2450
  /** Test-only abort outcome override for cancel-and-submit rollback coverage. */
2235
2451
  #cancelAndSubmitAbortOutcomeProviderForTests: (() => Promise<AbortOutcome>) | undefined = undefined;
2236
2452
  #postPromptTasks = new Set<Promise<void>>();
2453
+ #postPromptTaskSelectionFenceGenerations = new Map<Promise<void>, number>();
2237
2454
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
2238
2455
  #postPromptTasksResolve: (() => void) | undefined = undefined;
2239
2456
  #postPromptTasksAbortController = new AbortController();
@@ -2297,8 +2514,18 @@ export class AgentSession {
2297
2514
 
2298
2515
  #pendingRewindReport: string | undefined = undefined;
2299
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();
2300
2526
  #promptGeneration = 0;
2301
2527
  #promptPreflightAbortController = new AbortController();
2528
+ #promptPreflightCancellationGeneration = 0;
2302
2529
 
2303
2530
  #providerSessionState = new Map<string, ProviderSessionState>();
2304
2531
  #temporaryProviderSessionScopes: TemporaryProviderSessionScopeRecord[] = [];
@@ -2364,6 +2591,12 @@ export class AgentSession {
2364
2591
  });
2365
2592
  }
2366
2593
 
2594
+ #assertSessionAdmissionOpen(): void {
2595
+ if (this.#sessionAdmissionClosing || this.#sessionAdmissionClosed || this.#isDisposed) {
2596
+ throw this.#sessionAdmissionBusyError();
2597
+ }
2598
+ }
2599
+
2367
2600
  /**
2368
2601
  * Reject a turn start while a handoff transition owns the session. Handoff never
2369
2602
  * routes its own generation/injection through these turn-start chokepoints, and
@@ -2412,6 +2645,18 @@ export class AgentSession {
2412
2645
  next.ready.resolve();
2413
2646
  }
2414
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
+
2415
2660
  async #awaitStartupTurnBarrier(): Promise<void> {
2416
2661
  const barrier = this.#startupTurnBarrier;
2417
2662
  if (!barrier) return;
@@ -2427,11 +2672,43 @@ export class AgentSession {
2427
2672
  async #withSessionAdmission<T>(
2428
2673
  kind: SessionAdmissionKind,
2429
2674
  body: (lease: SessionAdmissionLease) => Promise<T>,
2675
+ signal?: AbortSignal,
2676
+ continuationAdmission?: ScheduledContinuationAdmission,
2677
+ options?: {
2678
+ allowDuringClosing?: boolean;
2679
+ bypassSelectionFenceGeneration?: number;
2680
+ allowPromptContinuationReentry?: boolean;
2681
+ },
2430
2682
  ): Promise<T> {
2431
- if (kind === "prompt") await this.#awaitStartupTurnBarrier();
2432
2683
  const owner = this.#sessionAdmissionContext.getStore();
2433
- if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
2434
- 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();
2435
2712
  // Reject new external turns for the whole handoff transition. The handoff
2436
2713
  // itself never acquires prompt admission (it generates via generateHandoff and
2437
2714
  // injects via appendCustomMessageEntry), so this fences external entrants —
@@ -2448,11 +2725,30 @@ export class AgentSession {
2448
2725
  ready: Promise.withResolvers<void>(),
2449
2726
  settled: Promise.withResolvers<void>(),
2450
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();
2451
2739
  };
2452
2740
  this.#sessionAdmissionQueue.push(entry);
2453
2741
  this.#activateNextSessionAdmission();
2454
- await entry.ready.promise;
2455
- 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
+ ) {
2456
2752
  entry.released = true;
2457
2753
  entry.settled.resolve();
2458
2754
  if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
@@ -2472,11 +2768,7 @@ export class AgentSession {
2472
2768
  }
2473
2769
 
2474
2770
  const release = () => {
2475
- if (entry.released) return;
2476
- entry.released = true;
2477
- entry.settled.resolve();
2478
- if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
2479
- this.#activateNextSessionAdmission();
2771
+ releaseEntry();
2480
2772
  };
2481
2773
  try {
2482
2774
  return await this.#sessionAdmissionContext.run(entry, () => body({ release }));
@@ -2486,19 +2778,22 @@ export class AgentSession {
2486
2778
  }
2487
2779
 
2488
2780
  async #closeSessionAdmission(): Promise<void> {
2489
- this.#sessionAdmissionClosed = true;
2490
- const queued = this.#sessionAdmissionQueue.splice(0);
2491
- for (const entry of queued) {
2492
- entry.released = true;
2493
- entry.ready.reject(this.#sessionAdmissionBusyError());
2494
- entry.settled.resolve();
2495
- }
2781
+ this.#sessionAdmissionClosing = true;
2496
2782
  const active = this.#activeSessionAdmission;
2497
2783
  if (active?.kind === "prompt") {
2498
2784
  this.#promptGeneration++;
2785
+ this.#promptPreflightCancellationGeneration++;
2499
2786
  this.#promptPreflightAbortController.abort();
2500
2787
  }
2501
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
+ }
2502
2797
  }
2503
2798
 
2504
2799
  #beginInFlight(): void {
@@ -2509,6 +2804,50 @@ export class AgentSession {
2509
2804
  // in #refreshTeamWorkerHeartbeat(), including internally dispatched turns.
2510
2805
  }
2511
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
+ }
2512
2851
 
2513
2852
  #isPromptPreflightCancelled(generation: number, signal: AbortSignal): boolean {
2514
2853
  return signal.aborted || this.#promptGeneration !== generation;
@@ -2542,6 +2881,17 @@ export class AgentSession {
2542
2881
  return hold;
2543
2882
  }
2544
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
+
2545
2895
  #claimDeferredAgentEndForContinuation(hold: symbol | undefined): AgentSessionEvent | undefined {
2546
2896
  if (!hold) return undefined;
2547
2897
  const pending = this.#pendingAgentEndContinuationHolds.get(hold);
@@ -2599,33 +2949,159 @@ export class AgentSession {
2599
2949
  }
2600
2950
  lease.closeDiscovery();
2601
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
+ }
3025
+
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
+ }
2602
3044
 
2603
- #isSessionSettlementPending(): boolean {
3045
+ #isSessionSettlementPending(ignoreSelectionFenceGeneration?: number): boolean {
2604
3046
  return (
2605
3047
  this.#promptInFlightCount > 0 ||
2606
3048
  this.#agentEventHandlersInFlight > 0 ||
2607
3049
  this.#agentEndPublicationInFlight > 0 ||
2608
3050
  this.#pendingAgentEndContinuationHolds.size > 0 ||
3051
+ this.#pendingSelectionFenceDeferredContinuations(ignoreSelectionFenceGeneration) > 0 ||
2609
3052
  this.#pendingAgentEndEmit !== undefined
2610
3053
  );
2611
3054
  }
2612
3055
 
2613
3056
  #resolveSessionSettlement(): void {
2614
- if (this.#isSessionSettlementPending() || !this.#sessionSettlementResolve) return;
3057
+ if (this.#isSessionSettlementPending() || !this.#sessionSettlementResolve) {
3058
+ this.#wakeScopedSettlementWaiters();
3059
+ return;
3060
+ }
2615
3061
  const resolve = this.#sessionSettlementResolve;
2616
3062
  this.#sessionSettlementResolve = undefined;
2617
3063
  this.#sessionSettlementPromise = undefined;
2618
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();
2619
3070
  }
2620
3071
 
2621
- async #waitForSessionSettlement(): Promise<void> {
2622
- while (this.#isSessionSettlementPending()) {
2623
- if (!this.#sessionSettlementPromise) {
2624
- const { promise, resolve } = Promise.withResolvers<void>();
2625
- this.#sessionSettlementPromise = promise;
2626
- 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);
2627
3104
  }
2628
- await this.#sessionSettlementPromise;
2629
3105
  }
2630
3106
  }
2631
3107
 
@@ -2662,7 +3138,12 @@ export class AgentSession {
2662
3138
  // A post-prompt continuation runs before its predecessor prompt returns. Its
2663
3139
  // publication must settle, but session-wide settlement can still be owned by
2664
3140
  // that predecessor; waiting here would make each prompt wait on the other.
2665
- 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
+ }
2666
3147
  if (flushError) throw flushError;
2667
3148
  }
2668
3149
 
@@ -2738,7 +3219,14 @@ export class AgentSession {
2738
3219
  // Persist before notifying synchronous subscribers: a subscriber may start a
2739
3220
  // successor prompt from agent_end, whose running state must serialize after
2740
3221
  // this terminal boundary rather than be overwritten by it.
2741
- 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);
2742
3230
  this.#emit(pending);
2743
3231
  extensionDelivery = this.#queueExtensionEvent(
2744
3232
  pending,
@@ -2760,9 +3248,31 @@ export class AgentSession {
2760
3248
 
2761
3249
  constructor(config: AgentSessionConfig) {
2762
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
+ });
2763
3272
  this.agent.bindRunCancellationDomainBridge(this.#runCancellationDomains, this.#agentSessionClaimKey);
2764
3273
  this.sessionManager = config.sessionManager;
2765
3274
  this.settings = config.settings;
3275
+ this.#requestedAgentDir = config.agentDir ? path.resolve(config.agentDir) : undefined;
2766
3276
  this.sessionManager.setSessionMemoryMode(this.settings.get("sessionMemory.mode"));
2767
3277
  this.#unregisterSessionMemorySettings = this.settings.onChanged(settingPath => {
2768
3278
  if (settingPath === "sessionMemory.mode") {
@@ -2811,6 +3321,7 @@ export class AgentSession {
2811
3321
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
2812
3322
  this.#recoveryHydrationContext = config.recoveryHydrationContext;
2813
3323
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
3324
+ this.#disposeAsyncJobManager = config.disposeAsyncJobManager ?? true;
2814
3325
  this.#retainedMemorySampler = config.retainedMemorySampler;
2815
3326
  this.#ownedMcpManager = config.ownedMcpManager;
2816
3327
  this.#startupTurnBarrier = config.startupTurnBarrier;
@@ -2834,6 +3345,7 @@ export class AgentSession {
2834
3345
  this.#providerSessionState = config.providerSessionState;
2835
3346
  }
2836
3347
  this.#toolRegistry = config.toolRegistry ?? new Map();
3348
+ this.#builtinToolIdentities = new WeakSet(config.builtinToolIdentities ?? []);
2837
3349
  this.#workflowGateToolSession = config.workflowGateToolSession;
2838
3350
  this.#requestedToolNames = config.requestedToolNames;
2839
3351
  this.#transformContext = config.transformContext ?? (messages => messages);
@@ -2870,16 +3382,80 @@ export class AgentSession {
2870
3382
  this.#bindWorkflowGateEmitter();
2871
3383
  this.yieldQueue = new YieldQueue({
2872
3384
  isStreaming: () => this.isStreaming || this.#handoffTransitionActive,
2873
- 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
+ },
2874
3415
  injectIdle: async messages => {
2875
- 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];
2876
3428
  if (!first) return;
2877
3429
  await this.#awaitStartupTurnBarrier();
2878
3430
  if (this.#isDisposed) return;
2879
- if (messages.length === 1) {
2880
- await this.agent.prompt(first, this.#managedFallbackPromptOptions());
2881
- } else {
2882
- 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);
2883
3459
  }
2884
3460
  },
2885
3461
  scheduleIdleFlush: run => {
@@ -2972,9 +3548,83 @@ export class AgentSession {
2972
3548
  this.#agentRegistry = config.agentRegistry;
2973
3549
  this.#providerSessionId = config.providerSessionId;
2974
3550
  this.#credentialSessionId = config.credentialSessionId;
3551
+ this.#credentialStoreIdentity = config.credentialStoreIdentity;
2975
3552
  this.#providerCacheSessionId = config.providerCacheSessionId;
3553
+ this.#asyncJobProviderSessionId = config.asyncJobProviderSessionId;
2976
3554
  // Per-tool TTSR reminders are folded into the matched tool's result via this hook.
2977
- 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
+ };
2978
3628
  this.agent.providerSessionState = this.#providerSessionState;
2979
3629
  this.#syncAgentSessionId();
2980
3630
  this.#removeEphemeralCustomMessages();
@@ -3631,7 +4281,12 @@ export class AgentSession {
3631
4281
  }
3632
4282
 
3633
4283
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
3634
- 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();
3635
4290
  if (!manager) return null;
3636
4291
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
3637
4292
  const running = manager.getRunningJobs(ownerFilter).map(job => ({
@@ -3664,7 +4319,12 @@ export class AgentSession {
3664
4319
  */
3665
4320
  #cancelOwnAsyncJobs(): void {
3666
4321
  if (!this.#agentId) return;
3667
- 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();
3668
4328
  if (!manager) return;
3669
4329
  // Run owner cleanups first so cron timers (and any other owner-scoped
3670
4330
  // resource cleanup) cannot register fresh jobs while we tear down the
@@ -3675,7 +4335,7 @@ export class AgentSession {
3675
4335
 
3676
4336
  async #settleOwnAsyncJobsBeforeArtifactRetirement(): Promise<void> {
3677
4337
  const ownerId = this.#agentId;
3678
- const manager = AsyncJobManager.instance();
4338
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
3679
4339
  if (!ownerId || !manager) return;
3680
4340
  const lease = manager.beginOwnerSubagentShutdown(ownerId);
3681
4341
  if (!lease) throw new Error("Owned async cleanup is already in progress before artifact retirement.");
@@ -3699,6 +4359,7 @@ export class AgentSession {
3699
4359
  manager: AsyncJobManager,
3700
4360
  lease: OwnerSubagentShutdownLease,
3701
4361
  ownerId: string,
4362
+ predecessorEndpointId: string,
3702
4363
  ): void {
3703
4364
  const finalization = (async () => {
3704
4365
  while (!this.#isDisposed) {
@@ -3709,7 +4370,11 @@ export class AgentSession {
3709
4370
  if (!(await manager.waitForOwnerInFlightDeliveries(ownerId)))
3710
4371
  throw new Error("owner_delivery_settlement_timeout");
3711
4372
  if (!(await manager.cancelAndSettleOwnerJobs(ownerId))) throw new Error("owner_job_settlement_timeout");
3712
- if (this.#isDisposed) break;
4373
+ if (this.#isDisposed) {
4374
+ retireOwnedRegistrationsForEndpoint(predecessorEndpointId);
4375
+ break;
4376
+ }
4377
+ retireOwnedRegistrationsForEndpoint(predecessorEndpointId);
3713
4378
  this.sessionManager.retireEphemeralArtifactsAfterTransition();
3714
4379
  await this.#runToolSessionTransitionCleanups();
3715
4380
  manager.finishOwnerSubagentShutdown(lease, "commit");
@@ -3730,7 +4395,14 @@ export class AgentSession {
3730
4395
 
3731
4396
  #suppressOwnAsyncJobDeliveries(): void {
3732
4397
  if (!this.#agentId) return;
3733
- 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();
3734
4406
  if (!manager) return;
3735
4407
  const pendingJobIds = manager.getDeliveryState({ ownerId: this.#agentId }).pendingJobIds;
3736
4408
  if (pendingJobIds.length > 0) {
@@ -3800,7 +4472,78 @@ export class AgentSession {
3800
4472
  return queued;
3801
4473
  }
3802
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
+
3803
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);
3804
4547
  const terminalOwner = event.type === "agent_end" ? getAgentTerminalOwnerContext(event) : undefined;
3805
4548
  const maintenanceCheckpoint =
3806
4549
  event.type === "agent_end" && event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted";
@@ -3854,14 +4597,15 @@ export class AgentSession {
3854
4597
  this.#postPromptLeases.set(eventLease.resourceRunId, eventLease);
3855
4598
  if (eventLease) {
3856
4599
  await this.#runResourceLeaseContext.run(eventLease, () =>
3857
- this.#handleAgentEvent(event, activePromptHandle),
4600
+ this.#handleAgentEvent(event, activePromptHandle, canonicalAdmission),
3858
4601
  );
3859
4602
  } else {
3860
- await this.#handleAgentEvent(event, activePromptHandle);
4603
+ await this.#handleAgentEvent(event, activePromptHandle, canonicalAdmission);
3861
4604
  }
3862
4605
  } catch (error) {
3863
4606
  logger.warn("Agent event handler failed", { event: event.type, error: String(error) });
3864
4607
  } finally {
4608
+ canonicalAdmission?.release();
3865
4609
  if (eventLease) {
3866
4610
  const pendingAgentEnd =
3867
4611
  event.type === "agent_end" && !maintenanceCheckpoint && this.#pendingAgentEndEmit === event
@@ -3884,13 +4628,43 @@ export class AgentSession {
3884
4628
  return handler;
3885
4629
  };
3886
4630
 
3887
- 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> {
3888
4658
  try {
3889
- await persistCoordinatorRuntimeStateFromEvent(event, {
3890
- sessionId: this.sessionId,
3891
- cwd: this.sessionManager.getCwd(),
3892
- sessionFile: this.sessionManager.getSessionFile(),
3893
- });
4659
+ await persistCoordinatorRuntimeStateFromEvent(
4660
+ event,
4661
+ {
4662
+ sessionId: this.sessionId,
4663
+ cwd: this.sessionManager.getCwd(),
4664
+ sessionFile: this.sessionManager.getSessionFile(),
4665
+ },
4666
+ observation,
4667
+ );
3894
4668
  } catch {
3895
4669
  logger.warn("Failed to persist coordinator runtime state", { event: event.type });
3896
4670
  }
@@ -3938,7 +4712,6 @@ export class AgentSession {
3938
4712
  if (event.type === "agent_end" && event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted")
3939
4713
  return;
3940
4714
 
3941
- const persistRuntimeState = () => this.#persistRuntimeStateInBackground(event);
3942
4715
  // Hold agent_end until the prompt's finally and all earlier async event work
3943
4716
  // have unwound. Subscribers treat this event as the ready signal; flushing it
3944
4717
  // from abort while either barrier is active permits a successor to race the
@@ -3951,7 +4724,7 @@ export class AgentSession {
3951
4724
  if (event.type === "agent_end") {
3952
4725
  // Start the durable terminal write before synchronous subscribers can
3953
4726
  // re-enter prompt(), so a successor's running transition serializes after it.
3954
- await persistRuntimeState();
4727
+ await this.#queueCoordinatorRuntimeStatePersist(event);
3955
4728
  this.#emit(event);
3956
4729
  await this.#emitExtensionEvent(event);
3957
4730
  return;
@@ -3961,13 +4734,22 @@ export class AgentSession {
3961
4734
  // auto-continuation gates, goal reminders, and tests all observe these events
3962
4735
  // synchronously. Coordinator sidecar writes and extension hooks are secondary
3963
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);
3964
4743
  this.#emit(event);
3965
- void persistRuntimeState();
3966
4744
  await this.#emitExtensionEvent(event);
3967
4745
  }
3968
4746
 
3969
4747
  // Track last assistant message for auto-compaction check
3970
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>();
3971
4753
  // Provider context construction must wait for this chain. Agent event listeners
3972
4754
  // are synchronous dispatch only; their async work cannot otherwise gate the
3973
4755
  // next tool-result provider request.
@@ -4035,7 +4817,11 @@ export class AgentSession {
4035
4817
  }
4036
4818
 
4037
4819
  /** Internal handler for agent events - shared by subscribe and reconnect */
4038
- #handleAgentEvent = async (event: AgentEvent, activePromptHandle?: string): Promise<void> => {
4820
+ #handleAgentEvent = async (
4821
+ event: AgentEvent,
4822
+ activePromptHandle?: string,
4823
+ canonicalAdmission?: CanonicalMessageAdmission,
4824
+ ): Promise<void> => {
4039
4825
  const attemptScope = (event as AgentEvent & { scope?: AttemptScope }).scope;
4040
4826
 
4041
4827
  if (
@@ -4182,10 +4968,31 @@ export class AgentSession {
4182
4968
  this.#silentAbortPending = false;
4183
4969
  }
4184
4970
 
4185
- // Canonical persistence must happen synchronously before listener work can
4186
- // await: the EventStream FIFO drain then guarantees tool results and every
4187
- // 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
+
4188
4992
  if (event.type === "message_end") {
4993
+ if (canonicalAdmission && !canonicalAdmission.predecessor.released) {
4994
+ await canonicalAdmission.predecessor.promise;
4995
+ }
4189
4996
  if (
4190
4997
  (event.message.role === "hookMessage" || event.message.role === "custom") &&
4191
4998
  !(event.message.role === "custom" && event.message.customType === "hindsight-recall")
@@ -4216,6 +5023,47 @@ export class AgentSession {
4216
5023
  try {
4217
5024
  this.sessionManager.appendMessage(event.message);
4218
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
+ }
4219
5067
  if (
4220
5068
  event.message.role !== "toolResult" ||
4221
5069
  event.message.toolName !== "todo_write" ||
@@ -4281,6 +5129,7 @@ export class AgentSession {
4281
5129
  }
4282
5130
  }
4283
5131
  }
5132
+ canonicalAdmission?.release();
4284
5133
  }
4285
5134
 
4286
5135
  // Deobfuscate assistant message content for display emission — the LLM echoes back
@@ -4478,6 +5327,7 @@ export class AgentSession {
4478
5327
 
4479
5328
  if (
4480
5329
  event.type === "message_update" &&
5330
+ !this.#isProvisionalStreamingToolEvent(event) &&
4481
5331
  (event.assistantMessageEvent.type === "toolcall_start" ||
4482
5332
  event.assistantMessageEvent.type === "toolcall_delta" ||
4483
5333
  event.assistantMessageEvent.type === "toolcall_end")
@@ -4487,6 +5337,7 @@ export class AgentSession {
4487
5337
 
4488
5338
  if (
4489
5339
  event.type === "message_update" &&
5340
+ !this.#isProvisionalStreamingToolEvent(event) &&
4490
5341
  (event.assistantMessageEvent.type === "toolcall_end" || event.assistantMessageEvent.type === "toolcall_delta")
4491
5342
  ) {
4492
5343
  this.#maybeAbortStreamingEdit(event, this.#promptGeneration);
@@ -4498,9 +5349,9 @@ export class AgentSession {
4498
5349
  this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
4499
5350
  }
4500
5351
 
4501
- // 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.)
4502
5354
  if (event.message.role === "assistant") {
4503
- this.#lastAssistantMessage = event.message;
4504
5355
  const assistantMsg = event.message as AssistantMessage;
4505
5356
  const currentGrantsAnthropicPriority =
4506
5357
  this.serviceTier === "priority" || this.serviceTier === "claude-only";
@@ -4550,6 +5401,9 @@ export class AgentSession {
4550
5401
  // #resolveRetry() is idempotent, so the later tail call is a no-op.
4551
5402
  this.#resolveRetry();
4552
5403
  }
5404
+ if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") {
5405
+ this.#providerRetryMaxAttempts = undefined;
5406
+ }
4553
5407
  }
4554
5408
 
4555
5409
  if (event.message.role === "toolResult") {
@@ -4623,6 +5477,13 @@ export class AgentSession {
4623
5477
  }
4624
5478
  }
4625
5479
 
5480
+ if (
5481
+ event.type === "agent_end" &&
5482
+ !(event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted")
5483
+ ) {
5484
+ this.#releaseDeferredSdkFollowUps();
5485
+ }
5486
+
4626
5487
  // Check auto-retry and auto-compaction after agent completes
4627
5488
  if (event.type === "agent_end") {
4628
5489
  // Cooperative mid-run maintenance interruption (issue #2035). The loop
@@ -4677,6 +5538,16 @@ export class AgentSession {
4677
5538
  .find((message): message is AssistantMessage => message.role === "assistant");
4678
5539
  const msg = this.#lastAssistantMessage ?? fallbackAssistant;
4679
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
+ }
4680
5551
  if (!msg) {
4681
5552
  this.#lastSuccessfulYieldToolCallId = undefined;
4682
5553
  this.#resolveRetry();
@@ -4805,8 +5676,14 @@ export class AgentSession {
4805
5676
  this.#postPromptTasksPromise = undefined;
4806
5677
  }
4807
5678
 
4808
- #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 {
4809
5685
  this.#postPromptTasks.add(task);
5686
+ this.#postPromptTaskSelectionFenceGenerations.set(task, selectionFenceGeneration);
4810
5687
  this.#ensurePostPromptTasksPromise();
4811
5688
  if (lease) {
4812
5689
  lease.track("post_prompt", "agent-session", leaseTask);
@@ -4816,6 +5693,7 @@ export class AgentSession {
4816
5693
  .catch(() => {})
4817
5694
  .finally(() => {
4818
5695
  this.#postPromptTasks.delete(task);
5696
+ this.#postPromptTaskSelectionFenceGenerations.delete(task);
4819
5697
  if (this.#postPromptTasks.size === 0) this.#resolvePostPromptTasks();
4820
5698
  });
4821
5699
  }
@@ -4828,8 +5706,14 @@ export class AgentSession {
4828
5706
  onSkip?: () => void;
4829
5707
  resourceRunId?: string;
4830
5708
  leaseTask?: Promise<void>;
5709
+ selectionFenceGeneration?: number;
4831
5710
  },
4832
5711
  ): Promise<void> {
5712
+ const selectionFenceGeneration =
5713
+ options?.selectionFenceGeneration ??
5714
+ this.#selectionFenceGenerationContext.getStore() ??
5715
+ this.#sessionAdmissionContext.getStore()?.selectionFenceGeneration ??
5716
+ this.#selectionFenceGeneration;
4833
5717
  const delayMs = options?.delayMs ?? 0;
4834
5718
  const resourceRunId = options?.resourceRunId;
4835
5719
  const contextualLease = this.#runResourceLeaseContext.getStore();
@@ -4867,12 +5751,17 @@ export class AgentSession {
4867
5751
  options.onSkip?.();
4868
5752
  return;
4869
5753
  }
4870
- await task(signal);
5754
+ await this.#selectionFenceGenerationContext.run(selectionFenceGeneration, () => task(signal));
4871
5755
  };
4872
5756
  const scheduled = reservation?.ok
4873
5757
  ? this.#runResourceLeaseContext.run(reservation.lease, runScheduled)
4874
5758
  : runScheduled();
4875
- 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
+ );
4876
5765
  return scheduled;
4877
5766
  }
4878
5767
 
@@ -4886,9 +5775,10 @@ export class AgentSession {
4886
5775
  skipCompactionCheck?: boolean;
4887
5776
  suppressPredecessorAgentEnd?: boolean;
4888
5777
  shouldContinue?: () => boolean;
4889
- 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;
4890
5781
  allowDuringCancelAndSubmit?: boolean;
4891
- rescheduleOnBusy?: boolean;
4892
5782
  onError?: (error: unknown) => void;
4893
5783
  resourceRunId?: string;
4894
5784
  maintenanceContinuation?: boolean;
@@ -4897,17 +5787,66 @@ export class AgentSession {
4897
5787
  // queued turn without replaying the non-assistant tail. Every other
4898
5788
  // scheduled continuation keeps continue() semantics.
4899
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;
4900
5800
  }): Promise<void> {
4901
- const predecessorAgentEndHold = options?.suppressPredecessorAgentEnd
4902
- ? this.#reserveDeferredAgentEndForContinuation()
4903
- : undefined;
4904
- let terminalized = false;
4905
- const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained" | "handoff_in_progress") => {
4906
- if (terminalized) return;
4907
- terminalized = true;
4908
- this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
4909
- options?.onSkip?.(reason);
4910
- };
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;
5847
+ this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
5848
+ options?.onSkip?.(reason);
5849
+ };
4911
5850
  const fail = (error: unknown) => {
4912
5851
  if (terminalized) return;
4913
5852
  terminalized = true;
@@ -4924,126 +5863,161 @@ export class AgentSession {
4924
5863
  const settleLease = () => leaseSettlement.resolve();
4925
5864
  return this.#schedulePostPromptTask(
4926
5865
  async scheduledSignal => {
4927
- const canContinue = (): boolean => {
4928
- if (scheduledSignal.aborted || this.#isDisposed) {
4929
- skip("aborted_signal");
4930
- return false;
4931
- }
4932
- if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4933
- skip("generation_changed");
4934
- return false;
4935
- }
4936
- if (this.#cancelAndSubmitInProgress && !options?.allowDuringCancelAndSubmit) {
4937
- skip("queue_drained");
4938
- return false;
4939
- }
4940
- if (options?.shouldContinue && !options.shouldContinue()) {
4941
- skip("queue_drained");
4942
- return false;
4943
- }
4944
- return true;
4945
- };
4946
- if (!canContinue()) {
4947
- settleLease();
4948
- return;
4949
- }
4950
- await this.#awaitStartupTurnBarrier();
4951
- if (!canContinue()) {
4952
- settleLease();
4953
- return;
4954
- }
4955
5866
  try {
4956
- if (!options?.skipCompactionCheck) {
4957
- await this.#checkEstimatedContextBeforePrompt();
4958
- if (!canContinue()) return;
4959
- }
4960
- if (scheduledSignal.aborted) {
4961
- skip("aborted_signal");
4962
- return;
4963
- }
4964
- if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
4965
- skip("generation_changed");
4966
- return;
4967
- }
4968
- if (options?.shouldContinue && !options.shouldContinue()) {
4969
- skip("queue_drained");
4970
- return;
4971
- }
4972
- // A continuation scheduled before a handoff engaged must not start a
4973
- // turn against the session being handed off (or the restored
4974
- // predecessor). rearmIdle / normal delivery resumes after the fence.
4975
- if (this.#handoffTransitionActive) {
4976
- skip("handoff_in_progress");
4977
- return;
4978
- }
4979
- const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
4980
- let predecessorAccepted = false;
4981
- const releasePredecessor = () => {
4982
- if (predecessorAccepted) return;
4983
- predecessorAccepted = true;
4984
- this.#releaseDeferredAgentEndLease(predecessorAgentEnd);
4985
- };
4986
- const hasQueuedMessages = this.agent.hasQueuedMessages();
4987
- const startsQueuedSuccessor =
4988
- hasQueuedMessages &&
4989
- (options?.continueQueuedOnly === true || this.agent.state.messages.at(-1)?.role === "assistant");
4990
- const continueQueued = options?.continueQueuedOnly
4991
- ? this.agent.continueQueuedMessages.bind(this.agent)
4992
- : this.agent.continue.bind(this.agent);
4993
- try {
4994
- await continueQueued({
4995
- ...this.#managedFallbackPromptOptions(),
4996
- maintenanceContinuation: options?.maintenanceContinuation,
4997
- // Reset only after continue() has claimed the queued turn. Skipped or stale
4998
- // continuations retain predecessor accounting, and resetAttemptBudget keeps
4999
- // the sticky fallback cursor unchanged.
5000
- onRunAccepted: (handle: AttemptRunHandle) => {
5001
- this.#acceptRunHandle(handle);
5002
- settleLease();
5003
- releasePredecessor();
5004
- if (startsQueuedSuccessor) {
5005
- this.#defaultFallbackChain().resetAttemptBudget();
5006
- 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;
5007
5874
  }
5008
- },
5009
- });
5010
- releasePredecessor();
5011
- } catch (error) {
5012
- if (
5013
- options?.rescheduleOnBusy &&
5014
- this.#isAgentBusyError(error) &&
5015
- !predecessorAccepted &&
5016
- !terminalized &&
5017
- canContinue()
5018
- ) {
5019
- if (busyReschedules < AGENT_CONTINUE_BUSY_MAX_RESCHEDULES) {
5020
- busyReschedules += 1;
5021
- const nextDelayMs = agentContinueBusyRescheduleDelayMs(busyReschedules);
5022
- this.#retainDeferredAgentEndAfterContinuationBusy(
5023
- predecessorAgentEndHold,
5024
- predecessorAgentEnd,
5025
- );
5026
- logger.debug("agent.continue busy after scheduling; rescheduling", {
5027
- error: error.message,
5028
- attempt: busyReschedules,
5029
- delayMs: nextDelayMs,
5030
- });
5031
- 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();
5032
5891
  return;
5033
5892
  }
5034
- logger.warn("agent.continue busy reschedule budget exhausted; giving up", {
5035
- attempts: busyReschedules,
5036
- error: error.message,
5037
- });
5038
- }
5039
- if (!predecessorAccepted)
5040
- this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
5041
- throw error;
5042
- }
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
+ );
5043
6018
  } catch (error) {
5044
- fail(error);
5045
- } finally {
5046
- settleLease();
6019
+ if (scheduledSignal.aborted) skip("aborted_signal");
6020
+ else fail(error);
5047
6021
  }
5048
6022
  },
5049
6023
  {
@@ -5054,12 +6028,12 @@ export class AgentSession {
5054
6028
  },
5055
6029
  resourceRunId: options?.resourceRunId,
5056
6030
  leaseTask: leaseSettlement.promise,
6031
+ selectionFenceGeneration,
5057
6032
  },
5058
6033
  );
5059
6034
  };
5060
6035
  return scheduleAttempt();
5061
6036
  }
5062
-
5063
6037
  #logCompactionContinuationSkipped(
5064
6038
  source: "auto_continue_prompt" | "queued_continue" | "overflow_retry",
5065
6039
  reason: string,
@@ -5120,7 +6094,6 @@ export class AgentSession {
5120
6094
  generation,
5121
6095
  suppressPredecessorAgentEnd: true,
5122
6096
  resourceRunId,
5123
- rescheduleOnBusy: true,
5124
6097
  onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
5125
6098
  onError: error => this.#logCompactionContinuationError("overflow_retry", error),
5126
6099
  });
@@ -5137,8 +6110,13 @@ export class AgentSession {
5137
6110
  return false;
5138
6111
  }
5139
6112
 
5140
- #scheduleAutoContinuePrompt(generation: number, requireUnfinishedWork = true, resourceRunId?: string): void {
5141
- const predecessorAgentEndHold = this.#reserveDeferredAgentEndForContinuation();
6113
+ #scheduleAutoContinuePrompt(
6114
+ generation: number,
6115
+ requireUnfinishedWork = true,
6116
+ resourceRunId?: string,
6117
+ deferredSelectionFenceGeneration?: number,
6118
+ deferredPredecessorAgentEnd?: AgentSessionEvent,
6119
+ ): void {
5142
6120
  const scheduledGeneration = generation;
5143
6121
  const continuationAuthorized = async (
5144
6122
  signal: AbortSignal,
@@ -5156,6 +6134,13 @@ export class AgentSession {
5156
6134
  );
5157
6135
  return false;
5158
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
+ }
5159
6144
  const authorized = requireUnfinishedWork
5160
6145
  ? this.#hasUnfinishedWork(snapshot) || hasPendingNextTurnMessages
5161
6146
  : snapshot.queuedMessages ||
@@ -5165,81 +6150,95 @@ export class AgentSession {
5165
6150
  if (!authorized) this.emitNotice("info", "Auto-continue skipped: no unfinished work detected");
5166
6151
  return authorized;
5167
6152
  };
5168
- let busyReschedules = 0;
5169
- const scheduleAttempt = (delayMs = 0) => {
5170
- let rescheduled = false;
5171
- void this.#schedulePostPromptTask(
5172
- async signal => {
5173
- try {
5174
- await Promise.resolve();
5175
- if (signal.aborted) {
5176
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
5177
- return;
5178
- }
5179
- if (this.#promptGeneration !== scheduledGeneration) {
5180
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
5181
- return;
5182
- }
5183
- if (!(await continuationAuthorized(signal))) return;
5184
- await this.#promptWithMessage(
5185
- {
5186
- role: "developer",
5187
- content: [{ type: "text", text: autoContinuePrompt }],
5188
- attribution: "agent",
5189
- timestamp: Date.now(),
5190
- },
5191
- autoContinuePrompt,
5192
- {
5193
- skipPostPromptRecoveryWait: true,
5194
- skipCompactionCheck: true,
5195
- predecessorAgentEndHold,
5196
- onFinalPreflight: ({ hasPendingNextTurnMessages }) =>
5197
- continuationAuthorized(signal, hasPendingNextTurnMessages),
5198
- },
5199
- );
5200
- } catch (error) {
5201
- if (signal.aborted) {
5202
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
5203
- return;
5204
- }
5205
- if (this.#isAgentBusyError(error) && this.#promptGeneration === scheduledGeneration) {
5206
- if (busyReschedules < AGENT_CONTINUE_BUSY_MAX_RESCHEDULES) {
5207
- busyReschedules += 1;
5208
- const nextDelayMs = agentContinueBusyRescheduleDelayMs(busyReschedules);
5209
- logger.debug("Auto-compaction continuation busy; rescheduling", {
5210
- source: "auto_continue_prompt",
5211
- reason: "queue_drained",
5212
- error: error.message,
5213
- attempt: busyReschedules,
5214
- delayMs: nextDelayMs,
5215
- });
5216
- rescheduled = true;
5217
- 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");
5218
6193
  return;
5219
6194
  }
5220
- logger.warn("Auto-compaction continuation busy reschedule budget exhausted; giving up", {
5221
- source: "auto_continue_prompt",
5222
- attempts: busyReschedules,
5223
- error: error.message,
5224
- });
5225
- }
5226
- this.#logCompactionContinuationError("auto_continue_prompt", error);
5227
- } finally {
5228
- if (!rescheduled) this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
5229
- }
5230
- },
5231
- {
5232
- delayMs,
5233
- generation: scheduledGeneration,
5234
- resourceRunId,
5235
- 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) {
5236
6223
  this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
5237
- this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
5238
- },
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);
5239
6239
  },
5240
- );
5241
- };
5242
- scheduleAttempt();
6240
+ },
6241
+ );
5243
6242
  }
5244
6243
 
5245
6244
  async #cancelPostPromptTasks(): Promise<void> {
@@ -5265,6 +6264,7 @@ export class AgentSession {
5265
6264
  this.#postPromptTasksAbortController.abort();
5266
6265
  this.#postPromptTasksAbortController = new AbortController();
5267
6266
  this.#postPromptTasks.clear();
6267
+ this.#postPromptTaskSelectionFenceGenerations.clear();
5268
6268
  this.#releaseDeferredAgentEndContinuations();
5269
6269
  this.#resolveTtsrResume();
5270
6270
  this.#resolvePostPromptTasks();
@@ -5301,6 +6301,19 @@ export class AgentSession {
5301
6301
  }
5302
6302
  }
5303
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
+
5304
6317
  /** Get TTSR injection payload and clear pending injections. */
5305
6318
  #getTtsrInjectionContent(): { content: string; rules: Rule[] } | undefined {
5306
6319
  if (this.#pendingTtsrInjections.length === 0) return undefined;
@@ -5659,6 +6672,13 @@ export class AgentSession {
5659
6672
  }
5660
6673
  }
5661
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
+
5662
6682
  #streamingEditPrecachePending = new Set<string>();
5663
6683
  async #preCacheFileAsync(resolvedPath: string): Promise<void> {
5664
6684
  if (this.#streamingEditFileCache.has(resolvedPath)) return;
@@ -6005,7 +7025,16 @@ export class AgentSession {
6005
7025
  try {
6006
7026
  if (event.type === "agent_start") {
6007
7027
  this.#turnIndex = 0;
6008
- 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
+ );
6009
7038
  } else if (event.type === "agent_end") {
6010
7039
  await this.#extensionRunner.emit(
6011
7040
  {
@@ -6243,6 +7272,81 @@ export class AgentSession {
6243
7272
  );
6244
7273
  }
6245
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
+
6246
7350
  #rekeyHindsightMemoryForCurrentSessionId(): void {
6247
7351
  if (resolveMemoryBackendId(this.settings) !== "hindsight") return;
6248
7352
 
@@ -6314,7 +7418,13 @@ export class AgentSession {
6314
7418
  await admissionClosed;
6315
7419
  await this.#agentEndPublicationPromise;
6316
7420
  await this.#queuedExtensionEvents;
6317
- 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;
6318
7428
  this.#pendingBackgroundExchanges = [];
6319
7429
  this.yieldQueue.clear();
6320
7430
 
@@ -6326,6 +7436,7 @@ export class AgentSession {
6326
7436
  } catch (error) {
6327
7437
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6328
7438
  }
7439
+ this.#workflowGateEmitter?.fence?.();
6329
7440
  this.#workflowGateEmitter = undefined;
6330
7441
  this.#notifyWorkflowGateEmitterChanged(this.sessionId, undefined);
6331
7442
  await this.#flushWorkerIntegrationAttempt();
@@ -6337,7 +7448,7 @@ export class AgentSession {
6337
7448
  this.#cancelOwnAsyncJobs();
6338
7449
  await Promise.allSettled(this.#deferredOwnerShutdownFinalizations);
6339
7450
  const ownedAsyncManager = this.#ownedAsyncJobManager;
6340
- if (ownedAsyncManager) {
7451
+ if (ownedAsyncManager && this.#disposeAsyncJobManager) {
6341
7452
  const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
6342
7453
  const deliveryState = ownedAsyncManager.getDeliveryState();
6343
7454
  if (drained === false && deliveryState) {
@@ -6392,6 +7503,7 @@ export class AgentSession {
6392
7503
  await this.memoryBackend.dispose();
6393
7504
  if (this.#workspaceTreeService) await this.#workspaceTreeService.dispose();
6394
7505
  if (this.#networkPrewarmService) await this.#networkPrewarmService.dispose();
7506
+ this.#modelRegistry.authStorage?.releaseCredentialScope(this.credentialSessionId);
6395
7507
  await this.sessionManager.close();
6396
7508
  this.#closeAllProviderSessions("dispose");
6397
7509
  const hindsightState = this.getHindsightSessionState();
@@ -6506,24 +7618,58 @@ export class AgentSession {
6506
7618
  }
6507
7619
 
6508
7620
  /** Wait until streaming and session settlement work are fully settled. */
6509
- 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> {
6510
7631
  while (true) {
6511
- await this.agent.waitForIdle();
6512
- await this.#waitForPostPromptRecovery();
6513
- 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);
6514
7639
  if (
6515
7640
  !this.agent.state.isStreaming &&
6516
7641
  !this.#retryPromise &&
6517
7642
  !this.#ttsrResumePromise &&
6518
7643
  !this.#postPromptTasksPromise &&
6519
- !this.#isSessionSettlementPending()
7644
+ !this.#isSessionSettlementPending(ignoreSelectionFenceGeneration)
6520
7645
  )
6521
7646
  return;
6522
7647
  }
6523
7648
  }
6524
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
+
6525
7671
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
6526
- const manager = AsyncJobManager.instance();
7672
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
6527
7673
  if (!manager) return false;
6528
7674
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
6529
7675
  const before = manager.getDeliveryState(ownerFilter);
@@ -6547,7 +7693,7 @@ export class AgentSession {
6547
7693
  * any remaining queued/delivering work is observable and can block mutation.
6548
7694
  */
6549
7695
  getAsyncDeliveryStateForAcp(): { queued: number; delivering: boolean } {
6550
- const manager = AsyncJobManager.instance();
7696
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
6551
7697
  if (!manager) return { queued: 0, delivering: false };
6552
7698
  const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
6553
7699
  return manager.getDeliveryState(ownerFilter);
@@ -7105,9 +8251,15 @@ export class AgentSession {
7105
8251
  activeSkillState: this.getActiveSkillState(),
7106
8252
  sessionId: this.sessionManager.getSessionId(),
7107
8253
  }),
8254
+ () => this.getSessionAgentDir(),
7108
8255
  ),
7109
8256
  ),
7110
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);
7111
8263
  if (!wrappersByVersion) {
7112
8264
  wrappersByVersion = new Map();
7113
8265
  this.#guardedToolWrapperCache.set(tool, wrappersByVersion);
@@ -7233,6 +8385,9 @@ export class AgentSession {
7233
8385
  const refreshedTool = await this.#reloadSshTool();
7234
8386
  if (refreshedTool) {
7235
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);
7236
8391
  } else {
7237
8392
  this.#toolRegistry.delete("ssh");
7238
8393
  this.#selectedDiscoveredToolNames.delete("ssh");
@@ -7834,6 +8989,36 @@ export class AgentSession {
7834
8989
  return this.#credentialSessionId ?? this.sessionId;
7835
8990
  }
7836
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
+
7837
9022
  /** Current session display name, if set */
7838
9023
  get sessionName(): string | undefined {
7839
9024
  return this.sessionManager.getSessionName();
@@ -7887,8 +9072,12 @@ export class AgentSession {
7887
9072
  async invokeSkill(
7888
9073
  name: string,
7889
9074
  args = "",
7890
- options?: Pick<PromptOptions, "onPreflightAccepted" | "onPreflightAcceptCommit" | "onSkillPrepared">,
9075
+ options?: Pick<
9076
+ PromptOptions,
9077
+ "onPreflightAccepted" | "onPreflightAcceptCommit" | "onSkillPrepared" | "preflightSignal"
9078
+ >,
7891
9079
  ): Promise<{ name: string; path: string; args?: string; lineCount?: number }> {
9080
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
7892
9081
  const skillName = name.trim();
7893
9082
  if (!skillName) throw Object.assign(new Error("skill.invoke requires a skill name."), { code: "invalid_input" });
7894
9083
  if (typeof args !== "string")
@@ -7900,18 +9089,24 @@ export class AgentSession {
7900
9089
  throw Object.assign(new Error(`Skill ${skillName} was not found.${availableHint}`), { code: "invalid_input" });
7901
9090
  }
7902
9091
  const deepInterviewUserIntentEpoch = this.#claimDeepInterviewUserIntent();
7903
- const activation = await resolveSubskillActivationForSkillInvocation({
7904
- cwd: this.sessionManager.getCwd(),
7905
- sessionId: this.sessionId,
7906
- skillName: skill.name,
7907
- args,
7908
- });
7909
- const built = await buildSkillPromptMessage(skill, activation.cleanedArgs, {
7910
- subskillActivation: activation.activation,
7911
- subskillActivationSet: activation.activeSubskillsToPersist,
7912
- cwd: this.sessionManager.getCwd(),
7913
- sessionId: this.sessionId,
7914
- });
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
+ );
7915
9110
  const skillPromptMessage = {
7916
9111
  customType: SKILL_PROMPT_MESSAGE_TYPE,
7917
9112
  content: built.message,
@@ -7920,6 +9115,7 @@ export class AgentSession {
7920
9115
  attribution: "user" as const,
7921
9116
  };
7922
9117
  this.#deepInterviewPreclaimedCustomInputEpochs.set(skillPromptMessage, deepInterviewUserIntentEpoch);
9118
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
7923
9119
  options?.onSkillPrepared?.({
7924
9120
  name: skill.name,
7925
9121
  path: skill.filePath,
@@ -8153,6 +9349,11 @@ export class AgentSession {
8153
9349
  const wrappedTool = wrapToolWithMetaNotice(createdAskTool as unknown as AgentTool);
8154
9350
  askTool = this.#extensionRunner ? new ExtensionToolWrapper(wrappedTool, this.#extensionRunner) : wrappedTool;
8155
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);
8156
9357
  }
8157
9358
 
8158
9359
  try {
@@ -8601,6 +9802,8 @@ export class AgentSession {
8601
9802
  */
8602
9803
  async prompt(text: string, options?: PromptOptions): Promise<void> {
8603
9804
  this.#assertRecoveryHydrationPromoted();
9805
+ const owner = this.#sessionAdmissionContext.getStore();
9806
+ if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
8604
9807
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
8605
9808
 
8606
9809
  if (expandPromptTemplates && text.startsWith("/skill:") && !options?.images?.length) {
@@ -8612,12 +9815,13 @@ export class AgentSession {
8612
9815
  await this.invokeSkill(
8613
9816
  invocation.skill.name,
8614
9817
  invocation.args,
8615
- options?.onPreflightAccepted || options?.onPreflightAcceptCommit
9818
+ options?.onPreflightAccepted || options?.onPreflightAcceptCommit || options?.preflightSignal
8616
9819
  ? {
8617
9820
  ...(options.onPreflightAccepted ? { onPreflightAccepted: options.onPreflightAccepted } : {}),
8618
9821
  ...(options.onPreflightAcceptCommit
8619
9822
  ? { onPreflightAcceptCommit: options.onPreflightAcceptCommit }
8620
9823
  : {}),
9824
+ ...(options.preflightSignal ? { preflightSignal: options.preflightSignal } : {}),
8621
9825
  }
8622
9826
  : undefined,
8623
9827
  );
@@ -8654,6 +9858,13 @@ export class AgentSession {
8654
9858
  assertImagePlaceholdersHavePayload(expandedText, options?.images);
8655
9859
  const workflowIntentDiff = options?.synthetic ? null : buildWorkflowIntentDiff(expandedText);
8656
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
+ }
8657
9868
  const deepInterviewUserIntentEpoch =
8658
9869
  claimsGenuineUserIntent && !this.isStreaming ? this.#claimDeepInterviewUserIntent() : undefined;
8659
9870
 
@@ -8678,64 +9889,67 @@ export class AgentSession {
8678
9889
  return;
8679
9890
  }
8680
9891
 
8681
- const admissionGeneration = this.#promptGeneration;
8682
- const admissionSignal = this.#promptPreflightAbortController.signal;
8683
- await this.#withSessionAdmission("prompt", async admission => {
8684
- this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
8685
- if (workflowIntentDiff) {
8686
- this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
8687
- }
8688
-
8689
- // Skip eager todo prelude when the user has already queued a directive
8690
- const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
8691
- const eagerTodoPrelude =
8692
- !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
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
+ }
8693
9899
 
8694
- const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
8695
- if (options?.images) {
8696
- userContent.push(...options.images);
8697
- }
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;
8698
9904
 
8699
- const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
8700
- const message = options?.synthetic
8701
- ? {
8702
- role: "developer" as const,
8703
- content: userContent,
8704
- attribution: promptAttribution,
8705
- timestamp: Date.now(),
8706
- }
8707
- : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
8708
- if (deepInterviewUserIntentEpoch !== undefined)
8709
- this.#deepInterviewGenuineUserMessageEpochs.set(message, deepInterviewUserIntentEpoch);
8710
- await this.refreshGjcSubskillTools();
9905
+ const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
9906
+ if (options?.images) {
9907
+ userContent.push(...options.images);
9908
+ }
8711
9909
 
8712
- if (eagerTodoPrelude?.toolChoice) {
8713
- this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
8714
- label: "eager-todo",
8715
- });
8716
- }
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
+ }
8717
9928
 
8718
- try {
8719
- await this.#promptWithMessage(message, expandedText, {
8720
- ...options,
8721
- prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
8722
- admissionLease: admission,
8723
- resetRetryReplaySafety: true,
8724
- });
8725
- } finally {
8726
- // Clean up residual eager-todo directive if the prompt never consumed it
8727
- // (e.g., compaction aborted, validation failed).
8728
- this.#toolChoiceQueue.removeByLabel("eager-todo");
8729
- }
8730
- if (!options?.synthetic) {
8731
- await this.#enforcePlanModeToolDecision();
8732
- }
8733
- });
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
+ );
8734
9947
  }
8735
9948
 
8736
9949
  async #syncSkillPromptActiveState(
8737
9950
  message: Pick<CustomMessage<unknown>, "customType" | "details">,
8738
9951
  active: boolean,
9952
+ persistActiveState = true,
8739
9953
  ): Promise<void> {
8740
9954
  if (message.customType !== SKILL_PROMPT_MESSAGE_TYPE) return;
8741
9955
  const details = message.details;
@@ -8759,8 +9973,12 @@ export class AgentSession {
8759
9973
  // initial mode-state + active row only when the skill is not already
8760
9974
  // active, so the mutation guard and Stop hook engage immediately instead
8761
9975
  // of relying on the skill prompt to run its own state-init steps.
8762
- if (active) {
8763
- 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
+ });
8764
9982
  const subskillDetails = details as {
8765
9983
  subskillActivation?: LoadedSubskillActivation;
8766
9984
  subskillActivationSet?: LoadedSubskillActivation[];
@@ -8794,9 +10012,10 @@ export class AgentSession {
8794
10012
  async #syncSkillPromptActiveStateSafely(
8795
10013
  message: Pick<CustomMessage<unknown>, "customType" | "details">,
8796
10014
  active: boolean,
10015
+ persistActiveState = true,
8797
10016
  ): Promise<void> {
8798
10017
  try {
8799
- await this.#syncSkillPromptActiveState(message, active);
10018
+ await this.#syncSkillPromptActiveState(message, active, persistActiveState);
8800
10019
  } catch {
8801
10020
  // Skill HUD state is observational; a filesystem write failure must not
8802
10021
  // interrupt the prompt turn it is visualizing. The native Stop hook still
@@ -8804,13 +10023,48 @@ export class AgentSession {
8804
10023
  }
8805
10024
  }
8806
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
+
8807
10055
  async promptCustomMessage<T = unknown>(
8808
10056
  message: Pick<CustomMessage<T>, "customType" | "content" | "display" | "details" | "attribution">,
8809
10057
  options?: Pick<
8810
10058
  PromptOptions,
8811
- "streamingBehavior" | "toolChoice" | "followUpQueuePolicy" | "onPreflightAccepted" | "onPreflightAcceptCommit"
10059
+ | "streamingBehavior"
10060
+ | "toolChoice"
10061
+ | "followUpQueuePolicy"
10062
+ | "onPreflightAccepted"
10063
+ | "onPreflightAcceptCommit"
10064
+ | "preflightSignal"
8812
10065
  >,
8813
10066
  ): Promise<void> {
10067
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
8814
10068
  const textContent =
8815
10069
  typeof message.content === "string"
8816
10070
  ? message.content
@@ -8841,31 +10095,61 @@ export class AgentSession {
8841
10095
 
8842
10096
  const admissionGeneration = this.#promptGeneration;
8843
10097
  const admissionSignal = this.#promptPreflightAbortController.signal;
8844
- await this.#withSessionAdmission("prompt", async admission => {
8845
- this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
8846
- const customMessage: CustomMessage<T> = {
8847
- role: "custom",
8848
- customType: message.customType,
8849
- content: message.content,
8850
- display: message.display,
8851
- details: message.details,
8852
- attribution: message.attribution ?? "agent",
8853
- timestamp: Date.now(),
8854
- };
8855
- if (deepInterviewUserIntentEpoch !== undefined)
8856
- this.#deepInterviewGenuineUserMessageEpochs.set(customMessage, deepInterviewUserIntentEpoch);
8857
-
8858
- await this.#syncSkillPromptActiveStateSafely(customMessage, true);
8859
- try {
8860
- await this.#promptWithMessage(customMessage, textContent, {
8861
- ...options,
8862
- admissionLease: admission,
8863
- resetRetryReplaySafety: true,
8864
- });
8865
- } finally {
8866
- await this.#syncSkillPromptActiveStateSafely(customMessage, false);
8867
- }
8868
- });
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
+ );
8869
10153
  }
8870
10154
 
8871
10155
  async #promptWithMessage(
@@ -8873,7 +10157,12 @@ export class AgentSession {
8873
10157
  expandedText: string,
8874
10158
  options?: Pick<
8875
10159
  PromptOptions,
8876
- "toolChoice" | "images" | "skipCompactionCheck" | "onPreflightAccepted" | "onPreflightAcceptCommit"
10160
+ | "toolChoice"
10161
+ | "images"
10162
+ | "skipCompactionCheck"
10163
+ | "onPreflightAccepted"
10164
+ | "onPreflightAcceptCommit"
10165
+ | "preflightSignal"
8877
10166
  > & {
8878
10167
  prependMessages?: AgentMessage[];
8879
10168
  skipPostPromptRecoveryWait?: boolean;
@@ -8885,16 +10174,59 @@ export class AgentSession {
8885
10174
  },
8886
10175
  ): Promise<void> {
8887
10176
  this.#assertNoHandoffTransition();
8888
- await this.#agentEndPublicationPromise;
10177
+ if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
10178
+ await awaitPromptInvocationPreflight(this.#agentEndPublicationPromise, options?.preflightSignal);
8889
10179
  // Re-check after the publication await: a handoff can engage during that
8890
10180
  // window, and #beginInFlight below would otherwise start a turn against the
8891
10181
  // session being handed off.
8892
10182
  this.#assertNoHandoffTransition();
8893
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
+ }
8894
10213
  const predecessorAgentEndHold =
8895
10214
  options?.predecessorAgentEndHold ?? this.#reserveDeferredAgentEndForContinuation();
8896
10215
  const generation = this.#promptGeneration;
8897
- 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;
8898
10230
  const rosterClaim = this.#claimIrcRosterCandidate();
8899
10231
  let hasPendingNextTurnMessages = false;
8900
10232
  let pendingNextTurnMessageCount = 0;
@@ -8941,7 +10273,7 @@ export class AgentSession {
8941
10273
  await this.#checkEstimatedContextBeforePrompt([
8942
10274
  ...(options?.prependMessages ?? []),
8943
10275
  message,
8944
- ...this.#pendingNextTurnMessages,
10276
+ ...this.#pendingNextTurnMessages.map(entry => entry.message),
8945
10277
  ]);
8946
10278
  }
8947
10279
 
@@ -9100,8 +10432,14 @@ export class AgentSession {
9100
10432
  messages.push(message);
9101
10433
 
9102
10434
  // Re-present captured pending next-turn messages (never drained here).
9103
- for (const msg of this.#pendingNextTurnMessages.slice(0, pendingNextTurnMessageCount)) {
9104
- 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);
9105
10443
  }
9106
10444
  messages.push(...fileMentionMessages);
9107
10445
  if (hindsightRecall) {
@@ -9145,6 +10483,15 @@ export class AgentSession {
9145
10483
  attemptMessages = undefined;
9146
10484
  },
9147
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
+ }
9148
10495
 
9149
10496
  const agentPromptOptions = {
9150
10497
  ...(options?.toolChoice ? { toolChoice: options.toolChoice } : undefined),
@@ -9160,11 +10507,20 @@ export class AgentSession {
9160
10507
  this.getHindsightSessionState()?.markRecallSnippetInjected(hindsightRecall);
9161
10508
  }
9162
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));
9163
10518
  this.#pendingNextTurnMessages.splice(0, pendingNextTurnMessageCount);
9164
10519
  }
9165
10520
  if (this.#cancelAndSubmitInProgress) this.#cancelAndSubmitPendingNextTurnDrained = true;
9166
10521
  },
9167
10522
  };
10523
+
9168
10524
  await this.#promptAgentWithIdleRetry(preSubmit, agentPromptOptions, predecessorAgentEndHold, {
9169
10525
  signal: preflightSignal,
9170
10526
  resourceRunId: this.#runResourceLeaseContext.getStore()?.resourceRunId,
@@ -9449,15 +10805,21 @@ export class AgentSession {
9449
10805
  async #queueSteer(
9450
10806
  text: string,
9451
10807
  images?: ImageContent[],
9452
- options?: { claimsGenuineUserIntent?: boolean },
10808
+ options?: { claimsGenuineUserIntent?: boolean; onPromoted?: () => void; external?: boolean },
9453
10809
  ): Promise<void> {
9454
10810
  this.#assertNoHandoffTransition();
9455
10811
  assertImagePlaceholdersHavePayload(text, images);
9456
10812
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
9457
- this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText));
10813
+ const displayEntry = this.#createQueuedDisplayEntry(displayText);
10814
+ this.#steeringMessages.push(displayEntry);
9458
10815
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
9459
10816
  if (images && images.length > 0) content.push(...images);
9460
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
+ }
9461
10823
  if (options?.claimsGenuineUserIntent) {
9462
10824
  const epoch = this.#claimDeepInterviewUserIntent();
9463
10825
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
@@ -9474,6 +10836,7 @@ export class AgentSession {
9474
10836
  if (!this.#cancelAndSubmitInProgress && this.#canAutoContinueForSteer()) {
9475
10837
  this.#scheduleAgentContinue({
9476
10838
  shouldContinue: () => this.#canAutoContinueForSteer() && this.agent.hasQueuedSteering(),
10839
+ rescheduleOnBusy: true,
9477
10840
  continueQueuedOnly: true,
9478
10841
  });
9479
10842
  }
@@ -9485,29 +10848,95 @@ export class AgentSession {
9485
10848
  async #queueFollowUp(
9486
10849
  text: string,
9487
10850
  images?: ImageContent[],
9488
- options?: { forceOneAtATime?: boolean; claimsGenuineUserIntent?: boolean },
9489
- ): Promise<void> {
10851
+ options?: {
10852
+ forceOneAtATime?: boolean;
10853
+ claimsGenuineUserIntent?: boolean;
10854
+ onPromoted?: () => void;
10855
+ sdkRunToken?: string;
10856
+ },
10857
+ ): Promise<QueuedFollowUpOwner> {
9490
10858
  this.#assertNoHandoffTransition();
9491
10859
  assertImagePlaceholdersHavePayload(text, images);
9492
10860
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
9493
- this.#followUpMessages.push(this.#createQueuedDisplayEntry(displayText));
10861
+ const queueWasEmpty = !this.agent.hasQueuedMessages();
10862
+ const displayEntry = this.#createQueuedDisplayEntry(displayText);
10863
+ this.#followUpMessages.push(displayEntry);
9494
10864
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
9495
10865
  if (images && images.length > 0) content.push(...images);
9496
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);
9497
10869
  if (options?.claimsGenuineUserIntent) {
9498
10870
  const epoch = this.#claimDeepInterviewUserIntent();
9499
10871
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
9500
10872
  }
9501
- this.agent.followUp(message, options?.forceOneAtATime ? { forceOneAtATime: true } : undefined);
9502
- // When fully idle AND the session is in a resumable assistant-ended state,
9503
- // schedule an immediate continue so the queued follow-up is delivered
9504
- // without waiting for the next user turn. We gate on isStreaming (model
9505
- // actively producing), isRetrying (auto-retry backoff is sleeping between
9506
- // attempts, #retryPromise set), and the last message being assistant —
9507
- // agent.continue() only dequeues follow-ups from an assistant-ended state;
9508
- // resuming from user/toolResult state runs an extra model call on the
9509
- // stale prompt before draining the queue.
9510
- 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";
9511
10940
  }
9512
10941
 
9513
10942
  /**
@@ -9523,10 +10952,17 @@ export class AgentSession {
9523
10952
  const last = messages[messages.length - 1];
9524
10953
  return last?.role === "assistant" || last?.role === "bashExecution" || last?.role === "pythonExecution";
9525
10954
  }
9526
- #scheduleQueuedFollowUpContinuation(): void {
9527
- 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
+ ) {
9528
10962
  this.#scheduleAgentContinue({
9529
- shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
10963
+ shouldContinue: () =>
10964
+ this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages() && (ownsQueuedMessage?.() ?? true),
10965
+ rescheduleOnBusy: true,
9530
10966
  continueQueuedOnly: true,
9531
10967
  });
9532
10968
  }
@@ -9548,16 +10984,16 @@ export class AgentSession {
9548
10984
  }
9549
10985
 
9550
10986
  queueDeferredMessage(message: CustomMessage): void {
9551
- this.#queueHiddenNextTurnMessage(message, true);
10987
+ this.#queueHiddenNextTurnMessage(message, true, "turn");
9552
10988
  }
9553
10989
 
9554
10990
  queueDeferredMessageForTests(message: CustomMessage, triggerTurn = true): void {
9555
- this.#queueHiddenNextTurnMessage(message, triggerTurn);
10991
+ this.#queueHiddenNextTurnMessage(message, triggerTurn, "turn");
9556
10992
  }
9557
10993
 
9558
10994
  /** Read-only test seam for the hidden next-turn context queue. */
9559
10995
  getPendingNextTurnMessagesForTests(): readonly CustomMessage[] {
9560
- return this.#pendingNextTurnMessages.slice();
10996
+ return this.#pendingNextTurnMessages.map(entry => entry.message);
9561
10997
  }
9562
10998
 
9563
10999
  /** Test-only abort outcome override; undefined retains the production abort race. */
@@ -9565,12 +11001,16 @@ export class AgentSession {
9565
11001
  this.#cancelAndSubmitAbortOutcomeProviderForTests = provider;
9566
11002
  }
9567
11003
 
9568
- #queueHiddenNextTurnMessage(message: CustomMessage, triggerTurn: boolean): void {
11004
+ #queueHiddenNextTurnMessage(
11005
+ message: CustomMessage,
11006
+ triggerTurn: boolean,
11007
+ origin: "turn" | "external" = "external",
11008
+ ): void {
9569
11009
  // A hidden next-turn message queued during a handoff transition would be
9570
11010
  // dropped when the successor clears predecessor queues; reject it as busy so
9571
11011
  // the caller can retry against the settled session.
9572
11012
  this.#assertNoHandoffTransition();
9573
- this.#pendingNextTurnMessages.push(message);
11013
+ this.#pendingNextTurnMessages.push({ message, origin });
9574
11014
  if (!triggerTurn) return;
9575
11015
  const generation = this.#promptGeneration;
9576
11016
  if (this.#scheduledHiddenNextTurnGeneration === generation) {
@@ -9585,6 +11025,27 @@ export class AgentSession {
9585
11025
  if (this.#pendingNextTurnMessages.length === 0) {
9586
11026
  return;
9587
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
+ }
9588
11049
  try {
9589
11050
  await this.#promptQueuedHiddenNextTurnMessages();
9590
11051
  } catch {
@@ -9609,12 +11070,47 @@ export class AgentSession {
9609
11070
 
9610
11071
  const queuedMessages = [...this.#pendingNextTurnMessages];
9611
11072
  this.#pendingNextTurnMessages = [];
9612
- 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;
9613
11109
  if (!message) {
9614
11110
  return;
9615
11111
  }
9616
11112
 
9617
- const prependMessages = queuedMessages.slice(0, -1);
11113
+ const prependMessages = reclassified.slice(0, -1).map(entry => entry.message);
9618
11114
  const textContent = this.#getCustomMessageTextContent(message);
9619
11115
  await this.#syncSkillPromptActiveStateSafely(message, true);
9620
11116
  try {
@@ -9623,13 +11119,47 @@ export class AgentSession {
9623
11119
  skipPostPromptRecoveryWait: true,
9624
11120
  });
9625
11121
  } catch (error) {
9626
- 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];
9627
11130
  throw error;
9628
11131
  } finally {
9629
11132
  await this.#syncSkillPromptActiveStateSafely(message, false);
9630
11133
  }
9631
11134
  }
9632
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
+
9633
11163
  #getCustomMessageTextContent(message: Pick<CustomMessage, "content">): string {
9634
11164
  if (typeof message.content === "string") {
9635
11165
  return message.content;
@@ -9671,6 +11201,13 @@ export class AgentSession {
9671
11201
  triggerTurn?: boolean;
9672
11202
  deliverAs?: "steer" | "followUp" | "nextTurn";
9673
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";
9674
11211
  },
9675
11212
  ): Promise<void> {
9676
11213
  this.#assertRecoveryHydrationPromoted();
@@ -9695,7 +11232,7 @@ export class AgentSession {
9695
11232
  // while an (auto-)handoff is unwinding it.
9696
11233
  this.#assertNoHandoffTransition();
9697
11234
  if (options?.deliverAs === "nextTurn") {
9698
- this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false);
11235
+ this.#queueHiddenNextTurnMessage(appMessage, options?.triggerTurn ?? false, options?.origin ?? "turn");
9699
11236
  return;
9700
11237
  }
9701
11238
 
@@ -9713,9 +11250,13 @@ export class AgentSession {
9713
11250
  if (options?.deliverAs === "nextTurn") {
9714
11251
  if (options?.triggerTurn) {
9715
11252
  if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
9716
- this.#queueHiddenNextTurnMessage(appMessage, false);
11253
+ this.#queueHiddenNextTurnMessage(appMessage, false, options?.origin ?? "turn");
9717
11254
  return;
9718
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();
9719
11260
  await this.#syncSkillPromptActiveStateSafely(appMessage, true);
9720
11261
  try {
9721
11262
  await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), {
@@ -9723,6 +11264,11 @@ export class AgentSession {
9723
11264
  });
9724
11265
  } finally {
9725
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]);
9726
11272
  }
9727
11273
  return;
9728
11274
  }
@@ -9744,6 +11290,27 @@ export class AgentSession {
9744
11290
  this.#queueHiddenNextTurnMessage(appMessage, false);
9745
11291
  return;
9746
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();
9747
11314
  await this.#syncSkillPromptActiveStateSafely(appMessage, true);
9748
11315
  try {
9749
11316
  await this.#promptWithMessage(appMessage, this.#getCustomMessageTextContent(appMessage), {
@@ -9751,6 +11318,13 @@ export class AgentSession {
9751
11318
  });
9752
11319
  } finally {
9753
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]);
9754
11328
  }
9755
11329
  return;
9756
11330
  }
@@ -9778,13 +11352,13 @@ export class AgentSession {
9778
11352
  }
9779
11353
  const agentRemoved = this.agent.removeQueuedMessages(isMatch);
9780
11354
  const beforeNext = this.#pendingNextTurnMessages.length;
9781
- for (const m of this.#pendingNextTurnMessages) {
9782
- if (predicate(m)) {
9783
- const tag = readPendingDisplayTag(m.details);
11355
+ for (const entry of this.#pendingNextTurnMessages) {
11356
+ if (predicate(entry.message)) {
11357
+ const tag = readPendingDisplayTag(entry.message.details);
9784
11358
  if (tag) removedTags.add(tag);
9785
11359
  }
9786
11360
  }
9787
- this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(m => !predicate(m));
11361
+ this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(entry => !predicate(entry.message));
9788
11362
  const pendingNextTurn = beforeNext - this.#pendingNextTurnMessages.length;
9789
11363
  let displaySteering = 0;
9790
11364
  let displayFollowUp = 0;
@@ -9817,11 +11391,26 @@ export class AgentSession {
9817
11391
  content: string | (TextContent | ImageContent)[],
9818
11392
  options?: {
9819
11393
  deliverAs?: "steer" | "followUp";
11394
+ /** Preserve a busy SDK dispatch as queued work across an admission fence. */
11395
+ queuedAtDispatch?: boolean;
9820
11396
  onPreflightAccepted?: () => void;
9821
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;
9822
11402
  },
9823
11403
  ): Promise<void> {
9824
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
+ }
9825
11414
  // Normalize content to text string + optional images
9826
11415
  let text: string;
9827
11416
  let images: ImageContent[] | undefined;
@@ -9842,38 +11431,145 @@ export class AgentSession {
9842
11431
  if (images.length === 0) images = undefined;
9843
11432
  }
9844
11433
 
9845
- if (options?.deliverAs === "followUp") {
9846
- if (options.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
9847
- await this.#queueFollowUp(text, images, { claimsGenuineUserIntent: true });
9848
- options.onPreflightAccepted?.();
9849
- return;
9850
- }
9851
- if (options?.deliverAs === "steer") {
9852
- if (options.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
9853
- await this.#queueSteer(text, images, { claimsGenuineUserIntent: true });
9854
- options.onPreflightAccepted?.();
9855
- return;
9856
- }
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
+ }
9857
11527
 
9858
- // No explicit delivery mode: only a live stream makes prompt() throw
9859
- // AgentBusyError, so queue the message as steering while streaming.
9860
- // Compaction is intentionally NOT diverted here: prompt() handles an
9861
- // in-flight compaction internally, and #queueSteer would otherwise park
9862
- // the message in the steering queue with no turn to consume it.
9863
- if (this.isStreaming) {
9864
- if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
9865
- await this.#queueSteer(text, images, { claimsGenuineUserIntent: true });
9866
- options?.onPreflightAccepted?.();
9867
- return;
9868
- }
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
+ }
9869
11544
 
9870
- // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
9871
- await this.prompt(text, {
9872
- expandPromptTemplates: false,
9873
- images,
9874
- onPreflightAccepted: options?.onPreflightAccepted,
9875
- onPreflightAcceptCommit: options?.onPreflightAcceptCommit,
9876
- });
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
+ }
9877
11573
  }
9878
11574
 
9879
11575
  /**
@@ -9885,6 +11581,7 @@ export class AgentSession {
9885
11581
  const followUp = this.#followUpMessages.map(e => e.text);
9886
11582
  this.#steeringMessages = [];
9887
11583
  this.#followUpMessages = [];
11584
+ this.#deferredSdkFollowUps = [];
9888
11585
  this.agent.clearAllQueues();
9889
11586
  return { steering, followUp };
9890
11587
  }
@@ -10251,6 +11948,7 @@ export class AgentSession {
10251
11948
  this.#markRetryReplayUnsafe();
10252
11949
  this.abortRetry();
10253
11950
  this.#promptGeneration++;
11951
+ this.#promptPreflightCancellationGeneration++;
10254
11952
  this.#promptPreflightAbortController.abort();
10255
11953
  this.#promptPreflightAbortController = new AbortController();
10256
11954
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -10323,6 +12021,7 @@ export class AgentSession {
10323
12021
  delayMs: 1,
10324
12022
  generation: this.#promptGeneration,
10325
12023
  shouldContinue: () => this.agent.hasQueuedSteering(),
12024
+ rescheduleOnBusy: true,
10326
12025
  continueQueuedOnly: true,
10327
12026
  });
10328
12027
  }
@@ -10353,20 +12052,370 @@ export class AgentSession {
10353
12052
  if (outcome.kind === "error") throw outcome.cause;
10354
12053
  }
10355
12054
  /**
10356
- * 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.
10357
12153
  */
10358
- 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
+ }
10359
12312
  const aborted = this.#runCancellationDomains.abort(handle);
10360
12313
  if (!aborted.ok) {
10361
12314
  if (aborted.reason === "quarantined") {
10362
- 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
+ };
10363
12319
  }
10364
- return { status: "unfenced", reason: "unknown_run", pending: [] };
12320
+ return {
12321
+ status: "unfenced",
12322
+ reason: "unknown_run",
12323
+ pending: [],
12324
+ ...(registeredScope ? { terminalScope: registeredScope } : {}),
12325
+ };
10365
12326
  }
10366
12327
  if (handle === this.agent.activeResourceRunId) this.agent.abort();
10367
12328
  const proof = await this.agent.resourceLedger.waitForSettlement(handle, { graceMs: options.graceMs });
10368
12329
  if (proof.status === "unfenced") this.agent.resourceLedger.quarantine(handle);
10369
- 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
+ };
10370
12419
  }
10371
12420
 
10372
12421
  /** Atomically interrupt the active run and make text the next prompt. */
@@ -10514,6 +12563,7 @@ export class AgentSession {
10514
12563
  resetRetryReplaySafety: true,
10515
12564
  onRunAccepted: () => {
10516
12565
  runAccepted = true;
12566
+ if (selected) this.#fireQueuedPromotionHooks([message]);
10517
12567
  if (selected) {
10518
12568
  this.#steeringMessages = this.#steeringMessages.filter(entry => entry !== selected.display);
10519
12569
  this.#followUpMessages = this.#followUpMessages.filter(entry => entry !== selected.display);
@@ -10596,7 +12646,7 @@ export class AgentSession {
10596
12646
  }
10597
12647
  }
10598
12648
 
10599
- const manager = AsyncJobManager.instance();
12649
+ const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
10600
12650
  const ownerId = this.#agentId;
10601
12651
  const lease = manager && ownerId ? manager.beginOwnerSubagentShutdown(ownerId) : undefined;
10602
12652
  if (manager && ownerId && !lease) {
@@ -10622,11 +12672,18 @@ export class AgentSession {
10622
12672
  this.#rebindProviderSessionState(new Map());
10623
12673
  this.agent.reset();
10624
12674
  if (!options?.drop) await this.sessionManager.flush();
12675
+ const noLeasePreviousSessionIdentity = this.sessionManager.getSessionId();
12676
+ const noLeasePreviousSessionFile = this.sessionManager.getSessionFile();
10625
12677
  const prepared = await this.sessionManager.prepareNewSession(options);
10626
12678
  try {
10627
12679
  // Last fallible gate while public getters still show the predecessor (#3138).
10628
12680
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
12681
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
10629
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);
10630
12687
  await this.#runToolSessionTransitionCleanups();
10631
12688
  } catch (error) {
10632
12689
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
@@ -10654,6 +12711,7 @@ export class AgentSession {
10654
12711
  if (!manager) throw new Error("Owner subagent shutdown manager became unavailable.");
10655
12712
  if (!ownerId) throw new Error("Owner subagent shutdown owner became unavailable.");
10656
12713
  const previousSessionIdentity = this.sessionManager.getSessionId();
12714
+ const previousSessionIdentityFile = this.sessionManager.getSessionFile();
10657
12715
  try {
10658
12716
  try {
10659
12717
  manager.runOwnerProducerCleanupsStrict({ ownerId });
@@ -10696,7 +12754,12 @@ export class AgentSession {
10696
12754
  try {
10697
12755
  // Last fallible gate while public getters still show the predecessor (#3138).
10698
12756
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
12757
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
10699
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);
10700
12763
  await this.#runToolSessionTransitionCleanups();
10701
12764
  } catch (error) {
10702
12765
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
@@ -10871,6 +12934,7 @@ export class AgentSession {
10871
12934
  try {
10872
12935
  const previousSessionFile = this.sessionFile;
10873
12936
  const previousWorkflowGateSessionId = this.sessionId;
12937
+ const previousSessionIdentity = this.sessionManager.getSessionId();
10874
12938
 
10875
12939
  // Emit session_before_switch event with reason "fork" (can be cancelled)
10876
12940
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
@@ -10907,6 +12971,7 @@ export class AgentSession {
10907
12971
  getSessionId: () => forkedManager.getSessionId(),
10908
12972
  });
10909
12973
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
12974
+ this.#assertJobManagerEndpointAdmission(forkedManager.getSessionId(), forkedManager.getSessionFile());
10910
12975
  } catch (error) {
10911
12976
  const forkedFile = forkedManager.getSessionFile();
10912
12977
  const cleanupErrors: unknown[] = [];
@@ -10937,6 +13002,7 @@ export class AgentSession {
10937
13002
  throw error;
10938
13003
  }
10939
13004
  this.sessionManager = forkedManager;
13005
+ this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
10940
13006
  try {
10941
13007
  await previousManager.close();
10942
13008
  } catch (error) {
@@ -10953,7 +13019,11 @@ export class AgentSession {
10953
13019
  try {
10954
13020
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
10955
13021
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
13022
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
10956
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);
10957
13027
  await this.#runToolSessionTransitionCleanups();
10958
13028
  } catch (error) {
10959
13029
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
@@ -11054,6 +13124,27 @@ export class AgentSession {
11054
13124
  return this.#activeModelProfile;
11055
13125
  }
11056
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
+
11057
13148
  /** Resolver intent only for assignments owned by the active profile. */
11058
13149
  #persistedModelProfileAliasIntent(role: string): { aliasIntent: "preset-equivalent" } | undefined {
11059
13150
  if (!this.#activeModelProfile) return undefined;
@@ -11201,11 +13292,10 @@ export class AgentSession {
11201
13292
  onAfterActivation?: () => void;
11202
13293
  },
11203
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();
11204
13298
  const canonicalName = await this.#withSessionAdmission("selection", async () => {
11205
- // A model-picker action must not alter an in-flight generation: wait
11206
- // for the current run to settle inside the admission before applying
11207
- // the profile, matching `setDefaultModelSelection`.
11208
- await this.waitForIdle();
11209
13299
  const profiles = this.#modelRegistry.getModelProfiles();
11210
13300
  let canonical: string;
11211
13301
  try {
@@ -11292,10 +13382,10 @@ export class AgentSession {
11292
13382
  * profile activation and default-model selection.
11293
13383
  */
11294
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();
11295
13388
  return this.#withSessionAdmission("selection", async () => {
11296
- // A config mutation can change the active model-role assignment. Do not
11297
- // alter an in-flight turn after its prompt admission lease has released.
11298
- await this.waitForIdle();
11299
13389
  return await body();
11300
13390
  });
11301
13391
  }
@@ -11647,95 +13737,148 @@ export class AgentSession {
11647
13737
  onAfterMutation?: () => void;
11648
13738
  },
11649
13739
  ): Promise<DefaultModelSelectionResult> {
11650
- return this.#withSessionAdmission("selection", async () => {
11651
- options?.onBeforeMutation?.();
11652
- const expectedSessionId = this.sessionId;
11653
-
11654
- if (thinkingLevel === ThinkingLevel.Inherit) {
11655
- throw new Error("Default model selection cannot inherit a thinking level");
11656
- }
11657
- const apiKey = await this.#modelRegistry.getApiKey(model, this.credentialSessionId);
11658
- if (!apiKey) {
11659
- throw new Error(`No API key for ${model.provider}/${model.id}`);
11660
- }
11661
- const resolvedLevel = resolveThinkingLevelForModel(model, thinkingLevel);
11662
- const effectiveLevel =
11663
- resolvedLevel ??
11664
- resolveThinkingLevelForModel(model, model.thinking?.defaultLevel ?? this.thinkingLevel) ??
11665
- ThinkingLevel.Off;
11666
- await this.waitForIdle();
11667
- await this.sessionManager.flush();
11668
- await this.#waitForAdmittedBaseSystemPromptRebuilds();
11669
- if (this.sessionId !== expectedSessionId) {
11670
- throw new Error("Session changed while selecting model");
11671
- }
11672
- const expectedMutationRevision = this.#defaultModelSelectionMutationRevision;
11673
- const preparedSystemPrompt = await this.#prepareDefaultModelSelectionPrompt(model);
11674
- if (this.sessionId !== expectedSessionId) {
11675
- throw new Error("Session changed while selecting model");
11676
- }
11677
- const stage = await this.sessionManager.stageDefaultModelSelection(
11678
- `${model.provider}/${model.id}`,
11679
- effectiveLevel,
11680
- { 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 },
11681
13789
  );
11682
- let durableCommit: CasReceipt;
11683
- try {
11684
- const selector = formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel);
11685
- durableCommit = await this.settings.commitAtomicBatchWithCurrent(() => [
11686
- { path: "modelRoles.default" as SettingPath, op: "set", value: selector },
11687
- ]);
11688
- } catch (error) {
11689
- try {
11690
- await this.sessionManager.discardDefaultModelSelectionStage(stage);
11691
- } catch (cleanupError) {
11692
- throw new AggregateError(
11693
- [error, cleanupError],
11694
- "Default model selection persistence and staged session cleanup both failed.",
11695
- );
11696
- }
11697
- throw error;
11698
- }
11699
- if (this.#defaultModelSelectionMutationRevision !== expectedMutationRevision) {
11700
- await this.#throwDefaultModelSelectionRecovery(
11701
- new Error("Default model selection was superseded before session promotion"),
11702
- stage,
11703
- durableCommit,
11704
- );
11705
- }
11706
- const promotion = this.sessionManager.promoteDefaultModelSelection(stage);
11707
- switch (promotion.kind) {
11708
- case "promoted": {
11709
- this.#publishDefaultModelSelection(model, effectiveLevel, preparedSystemPrompt);
11710
- break;
11711
- }
11712
- case "not_promoted": {
11713
- return this.#throwDefaultModelSelectionRecovery(
11714
- promotion.error ?? new Error("Default model selection was superseded before session promotion"),
11715
- stage,
11716
- 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 },
11717
13812
  );
11718
- }
11719
- case "unknown": {
11720
- const message = "Session replacement outcome could not be determined.";
11721
- throw new DefaultModelSelectionRecoveryError(message, {
11722
- message,
11723
- rollback: {
11724
- disposition: "unknown",
11725
- failures: [
11726
- {
11727
- stage: "session",
11728
- 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
+ ],
11729
13862
  },
11730
- ],
11731
- },
11732
- });
11733
- }
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;
11734
13879
  }
11735
- this.#clearActiveModelProfileForConcreteDefault("user-selection");
11736
- options?.onAfterMutation?.();
11737
- return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel };
11738
- });
13880
+ this.#resolveSessionSettlement();
13881
+ }
11739
13882
  }
11740
13883
  /**
11741
13884
  * Cycle to next/previous model.
@@ -13177,9 +15320,13 @@ export class AgentSession {
13177
15320
  // Last fallible action: verified local:// readiness from immutable staged options.
13178
15321
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
13179
15322
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
15323
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
13180
15324
 
13181
15325
  // --- Commit boundary: synchronous adoption is the sole identity publication.
13182
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);
13183
15330
  committed = true;
13184
15331
  await this.#runToolSessionTransitionCleanups();
13185
15332
  this.agent.reset();
@@ -13690,6 +15837,7 @@ export class AgentSession {
13690
15837
  sessionMemory.metadataResidentBytes,
13691
15838
  tuiChatChildren: retainedMemory.tuiChatChildren ?? 0,
13692
15839
  tuiCachedRenderBytes: retainedMemory.tuiCachedRenderBytes ?? 0,
15840
+ transcriptFileBytes: this.sessionManager.getTranscriptFileBytes(),
13693
15841
  };
13694
15842
  }
13695
15843
 
@@ -15393,7 +17541,9 @@ export class AgentSession {
15393
17541
  classification === "transient" ||
15394
17542
  classification === "unknown" ||
15395
17543
  classification === "first_event_timeout" ||
15396
- classification === "empty_response"
17544
+ classification === "empty_response" ||
17545
+ classification === "local_snapshot" ||
17546
+ classification === "local_buffer_overflow"
15397
17547
  );
15398
17548
  }
15399
17549
  const trigger = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
@@ -15413,7 +17563,13 @@ export class AgentSession {
15413
17563
  return true;
15414
17564
  }
15415
17565
  const classification = this.#classifyErrorForRetry(message);
15416
- 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
+ );
15417
17573
  }
15418
17574
 
15419
17575
  #isTransientErrorMessage(errorMessage: string): boolean {
@@ -15503,10 +17659,20 @@ export class AgentSession {
15503
17659
  #classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
15504
17660
  if (message.stopReason !== "error") return "none";
15505
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";
15506
17664
  if (this.#isTypedFirstEventTimeout(message)) return "first_event_timeout";
15507
17665
  if (this.#isTypedEmptyResponse(message)) return "empty_response";
15508
17666
  if (!message.errorMessage) return "none";
15509
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
+ }
15510
17676
  // Provider safety refusals (e.g. Anthropic stop_reason "refusal" /
15511
17677
  // "sensitive") are deterministic for the submitted context: replaying
15512
17678
  // the identical conversation re-triggers the identical refusal, so an
@@ -15714,6 +17880,23 @@ export class AgentSession {
15714
17880
  this.#defaultFallbackChain().resetAttemptBudget();
15715
17881
  return { type: "terminal", terminal: { stopReason: outcome.reason } };
15716
17882
  }
17883
+ if (outcome.type === "escaped_arguments_discarded") {
17884
+ // An escaped-non-ASCII wire defect is a sampling accident, not provider
17885
+ // evidence: never charge the attempt, advance the chain, or suppress the
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.
17891
+ this.#defaultFallbackChain().discardStartedAttempt();
17892
+ return {
17893
+ type: "retry",
17894
+ continuation: async ownership => {
17895
+ if (!ownership.isCurrent() || ownership.lease.signal.aborted) return;
17896
+ await this.agent.continue(this.#managedFallbackPromptOptions());
17897
+ },
17898
+ };
17899
+ }
15717
17900
  if (outcome.type === "context_overflow_discarded") {
15718
17901
  // The provider invocation happened, but overflow is context maintenance rather
15719
17902
  // than a fallback-policy failure. Keep the logical run owner and do not charge,
@@ -16000,12 +18183,50 @@ export class AgentSession {
16000
18183
  this.settings.has("retry.maxRetries") ||
16001
18184
  this.settings.has("retry.baseDelayMs") ||
16002
18185
  this.settings.has("retry.maxDelayMs");
16003
- // retry.enabled=false always surfaces immediately, matching the explicit
16004
- // user opt-out.
16005
- if (!managedFallback && !retrySettings.enabled) return false;
16006
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
+ }
16007
18220
  const firstEventTimeout = classification === "first_event_timeout";
16008
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;
16009
18230
  // Content-free message-only watchdog prose (wrapped canonical or bare
16010
18231
  // per-provider variants) is admitted like the typed path: it is
16011
18232
  // replay-safe, so a bare-default retry may re-issue the request even
@@ -16037,18 +18258,24 @@ export class AgentSession {
16037
18258
  }
16038
18259
  : false;
16039
18260
  }
16040
- const trigger = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
18261
+ const trigger:
18262
+ | { class: FallbackTriggerClass; retryAfterMs?: number; authDisposition?: AuthDisposition }
18263
+ | undefined = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
16041
18264
  if (!trigger) {
16042
18265
  return managedOutcome
16043
18266
  ? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
16044
18267
  : false;
16045
18268
  }
18269
+ const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
18270
+ const providerRetryCeilingReached =
18271
+ providerRetryMaxAttempts !== undefined && attemptsUsed >= providerRetryMaxAttempts;
16046
18272
  // Credential rotation: a content-free quota/rate-limit failure has no
16047
18273
  // observable state to corrupt, so it is replay-safe regardless of
16048
18274
  // extension lifecycle participation. Mark the failed credential and
16049
18275
  // retry with the next stored credential of the same provider.
16050
18276
  let credentialRotated =
16051
18277
  !managedFallback &&
18278
+ !providerRetryCeilingReached &&
16052
18279
  !assistantMessageHasVisibleOrToolContent(message) &&
16053
18280
  (trigger.class === "quota" || trigger.class === "rate_limit") &&
16054
18281
  (await this.#markFailedCredential(trigger));
@@ -16056,7 +18283,9 @@ export class AgentSession {
16056
18283
  // output, no tool calls, no extension-observable streaming state was produced
16057
18284
  // before the failure. This bypasses #hasCleanRetryReplaySafety because the
16058
18285
  // content-free check is the replay-safety guarantee for credential rotation.
16059
- 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;
16060
18289
  // Universal automatic replay-safety gate (#3791): once the failed attempt
16061
18290
  // carries observable assistant text, thinking, or tool-call content,
16062
18291
  // automatic session retry must not re-issue the request. This covers
@@ -16094,17 +18323,30 @@ export class AgentSession {
16094
18323
  !managedFallback &&
16095
18324
  classification === "transient" &&
16096
18325
  !this.#isIdleStreamStallErrorMessage(message.errorMessage ?? "");
16097
- const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
18326
+
16098
18327
  const failedSelector = managedFallback ? controller.currentSelector() : undefined;
16099
- let outcome = managedFallback
16100
- ? controller.onAttemptFailure(trigger.class, message.errorMessage || "Unknown error")
16101
- : legacyUnbounded || attemptsUsed <= retrySettings.maxRetries
16102
- ? "retry"
16103
- : "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
+ }
16104
18341
  // Credential rotation is unbounded: a fresh credential is a different
16105
18342
  // retry dimension from transient-error backoff, so it overrides maxRetries
16106
18343
  // exhaustion and forces an immediate same-model retry.
16107
- 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
+ ) {
16108
18350
  credentialRotated = await this.#markFailedCredential(trigger);
16109
18351
  }
16110
18352
  if (credentialRotated) {
@@ -16118,6 +18360,9 @@ export class AgentSession {
16118
18360
  outcome = "retry";
16119
18361
  }
16120
18362
  }
18363
+ if (outcome === "advance") {
18364
+ this.#providerRetryMaxAttempts = undefined;
18365
+ }
16121
18366
  if (outcome === "exhausted") {
16122
18367
  if (managedFallback) {
16123
18368
  const errorMessage = this.#fallbackExhaustionError(controller);
@@ -16126,9 +18371,13 @@ export class AgentSession {
16126
18371
  controller.resetSticky();
16127
18372
  return managedOutcome ? this.#managedFallbackExhaustionDecision(message, errorMessage) : false;
16128
18373
  }
16129
- if (classification === "first_event_timeout") {
18374
+ if (classification === "first_event_timeout" || providerRetryMaxAttempts !== undefined) {
16130
18375
  const elapsedMs = Date.now() - (this.#firstEventTimeoutRetryStartedAt ?? Date.now());
16131
- 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"}`;
16132
18381
  }
16133
18382
  return false;
16134
18383
  }
@@ -16151,7 +18400,9 @@ export class AgentSession {
16151
18400
  }
16152
18401
 
16153
18402
  const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
16154
- if (managedFallback && !credentialRotated) await this.#markFailedCredential(trigger);
18403
+ if (managedFallback && !credentialRotated && !providerRetryCeilingReached) {
18404
+ await this.#markFailedCredential(trigger);
18405
+ }
16155
18406
  let advanced = outcome !== "advance";
16156
18407
  let resolutionError: unknown;
16157
18408
  if (outcome === "advance") {
@@ -16194,11 +18445,17 @@ export class AgentSession {
16194
18445
  await this.#emitSessionEvent({
16195
18446
  type: "auto_retry_start",
16196
18447
  attempt: this.#retryAttempt,
16197
- maxAttempts: managedFallback
16198
- ? controller.maxAttempts
16199
- : firstEventTimeout
16200
- ? retrySettings.maxRetries + 1
16201
- : 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,
16202
18459
  delayMs,
16203
18460
  errorMessage,
16204
18461
  unbounded: managedFallback ? false : legacyUnbounded,
@@ -16401,6 +18658,7 @@ export class AgentSession {
16401
18658
  }
16402
18659
  if (!preflightAccepted) {
16403
18660
  await seam?.onPreflightAccepted?.();
18661
+ if (seam?.signal?.aborted) throw promptPreflightCancelledError();
16404
18662
  preflightAccepted = true;
16405
18663
  }
16406
18664
  await this.agent.prompt(messages, options);
@@ -16506,6 +18764,7 @@ export class AgentSession {
16506
18764
  if (lastMsg.role !== "assistant") {
16507
18765
  if (!this.#isInterruptedRetryTail(lastMsg)) return false;
16508
18766
  this.#retryAttempt = 0;
18767
+ this.#providerRetryMaxAttempts = undefined;
16509
18768
  this.#scheduleAgentContinue({ delayMs: 1 });
16510
18769
  return true;
16511
18770
  }
@@ -16524,6 +18783,7 @@ export class AgentSession {
16524
18783
 
16525
18784
  // Reset retry budget for a fresh attempt
16526
18785
  this.#retryAttempt = 0;
18786
+ this.#providerRetryMaxAttempts = undefined;
16527
18787
 
16528
18788
  // Re-attempt the turn
16529
18789
  this.#scheduleAgentContinue({ delayMs: 1 });
@@ -17618,7 +19878,7 @@ export class AgentSession {
17618
19878
  }
17619
19879
  options?.onTransitionMutationStarted?.();
17620
19880
 
17621
- const asyncManager = AsyncJobManager.instance();
19881
+ const asyncManager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
17622
19882
  const ownerId = this.#agentId;
17623
19883
  const lease =
17624
19884
  switchingToDifferentSession && asyncManager && ownerId
@@ -17680,6 +19940,13 @@ export class AgentSession {
17680
19940
  await this.sessionManager.setSessionFile(sessionPath, {
17681
19941
  deferEphemeralArtifactRetirement: switchingToDifferentSession,
17682
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
+ });
17683
19950
  if (switchingToDifferentSession) this.sessionManager.stageAdoptedArtifactManagerForTransition();
17684
19951
  // The successor identity is already rotated in the manager but not yet
17685
19952
  // published; gate its local:// root before publication so the agent,
@@ -17826,6 +20093,10 @@ export class AgentSession {
17826
20093
  }
17827
20094
 
17828
20095
  if (switchingToDifferentSession) {
20096
+ const predecessorEndpointId = this.#asyncJobEndpointId(
20097
+ previousSessionState.sessionId,
20098
+ previousSessionState.sessionFile,
20099
+ );
17829
20100
  let ownerShutdownSettled = true;
17830
20101
  if (ownerShutdownManager && ownerShutdownLease && ownerId) {
17831
20102
  try {
@@ -17840,7 +20111,12 @@ export class AgentSession {
17840
20111
  } catch (error) {
17841
20112
  ownerShutdownSettled = false;
17842
20113
  ownerShutdownFinalizationDeferred = true;
17843
- this.#scheduleDeferredOwnerShutdownFinalization(ownerShutdownManager, ownerShutdownLease, ownerId);
20114
+ this.#scheduleDeferredOwnerShutdownFinalization(
20115
+ ownerShutdownManager,
20116
+ ownerShutdownLease,
20117
+ ownerId,
20118
+ predecessorEndpointId,
20119
+ );
17844
20120
  this.#suppressOwnAsyncJobDeliveries();
17845
20121
  this.emitNotice(
17846
20122
  "error",
@@ -17853,6 +20129,10 @@ export class AgentSession {
17853
20129
  // Different files may intentionally carry the same copied session id; pathname transition is the commit signal.
17854
20130
  ownerShutdownTransitionCommitted = true;
17855
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);
17856
20136
  this.sessionManager.retireEphemeralArtifactsAfterTransition();
17857
20137
  await this.#runToolSessionTransitionCleanups();
17858
20138
  }
@@ -17878,6 +20158,35 @@ export class AgentSession {
17878
20158
  return true;
17879
20159
  } catch (error) {
17880
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
+ }
17881
20190
  await this.sessionManager.restoreRollbackState(previousSessionState);
17882
20191
  this.#defaultFallbackController = undefined;
17883
20192
  this.#syncAgentSessionId(previousSessionState.sessionId);
@@ -17969,6 +20278,7 @@ export class AgentSession {
17969
20278
  try {
17970
20279
  const previousSessionFile = this.sessionFile;
17971
20280
  const previousWorkflowGateSessionId = this.sessionId;
20281
+ const previousSessionIdentity = this.sessionManager.getSessionId();
17972
20282
  const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
17973
20283
 
17974
20284
  if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
@@ -18000,7 +20310,11 @@ export class AgentSession {
18000
20310
  try {
18001
20311
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
18002
20312
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
20313
+ this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
18003
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);
18004
20318
  await this.#runToolSessionTransitionCleanups();
18005
20319
  } catch (error) {
18006
20320
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);