@gajae-code/coding-agent 0.17.2 → 0.17.5

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 (390) hide show
  1. package/CHANGELOG.md +267 -0
  2. package/dist/types/cli/daemon-cli.d.ts +3 -1
  3. package/dist/types/cli/github-release.d.ts +17 -0
  4. package/dist/types/cli/public-command-entry.d.ts +56 -0
  5. package/dist/types/cli/public-command-errors.d.ts +126 -0
  6. package/dist/types/cli/public-command-evidence.d.ts +99 -0
  7. package/dist/types/cli/public-command-help.d.ts +65 -0
  8. package/dist/types/cli/public-command-registry.d.ts +69 -0
  9. package/dist/types/cli/session-picker.d.ts +1 -1
  10. package/dist/types/cli/setup-cli.d.ts +1 -0
  11. package/dist/types/cli/skills-cli.d.ts +5 -0
  12. package/dist/types/cli.d.ts +3 -0
  13. package/dist/types/commands/daemon.d.ts +4 -50
  14. package/dist/types/commands/sdk.d.ts +20 -37
  15. package/dist/types/commands/setup.d.ts +3 -0
  16. package/dist/types/commands/skills.d.ts +9 -0
  17. package/dist/types/config/autorouting-tier-map.d.ts +1049 -3
  18. package/dist/types/config/file-lock.d.ts +13 -3
  19. package/dist/types/config/model-profile-activation.d.ts +45 -1
  20. package/dist/types/config/model-registry.d.ts +14 -3
  21. package/dist/types/config/model-resolver.d.ts +1 -1
  22. package/dist/types/config/settings-schema.d.ts +11 -0
  23. package/dist/types/coordinator-mcp/question-state.d.ts +6 -1
  24. package/dist/types/daemon/operator-contract.d.ts +5 -1
  25. package/dist/types/eval/js/worker-protocol.d.ts +2 -0
  26. package/dist/types/exec/bash-executor.d.ts +2 -0
  27. package/dist/types/extensibility/extensions/types.d.ts +12 -0
  28. package/dist/types/extensibility/gjc-plugins/registry.d.ts +4 -0
  29. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +11 -2
  30. package/dist/types/extensibility/runtime-skill-discovery.d.ts +18 -0
  31. package/dist/types/extensibility/shared-events.d.ts +3 -1
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +61 -0
  33. package/dist/types/gjc-runtime/state-writer.d.ts +14 -1
  34. package/dist/types/harness-control-plane/control-endpoint.d.ts +4 -1
  35. package/dist/types/harness-control-plane/finalize.d.ts +14 -3
  36. package/dist/types/internal-urls/embedded-protocol.d.ts +9 -0
  37. package/dist/types/internal-urls/index.d.ts +1 -0
  38. package/dist/types/internal-urls/parse.d.ts +9 -1
  39. package/dist/types/main.d.ts +23 -1
  40. package/dist/types/modes/acp/acp-agent.d.ts +55 -1
  41. package/dist/types/modes/acp/startup-provenance.d.ts +16 -0
  42. package/dist/types/modes/components/custom-provider-wizard.d.ts +19 -1
  43. package/dist/types/modes/components/session-selector.d.ts +5 -2
  44. package/dist/types/modes/components/user-message-selector.d.ts +5 -2
  45. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  46. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  47. package/dist/types/modes/interactive-mode.d.ts +1 -1
  48. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +11 -1
  49. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +14 -1
  50. package/dist/types/modes/types.d.ts +2 -2
  51. package/dist/types/runtime/process-lifecycle.d.ts +2 -0
  52. package/dist/types/runtime-mcp/loader.d.ts +2 -0
  53. package/dist/types/runtime-mcp/manager.d.ts +69 -6
  54. package/dist/types/runtime-mcp/pool.d.ts +17 -0
  55. package/dist/types/runtime-mcp/redaction.d.ts +20 -0
  56. package/dist/types/runtime-mcp/startup-policy.d.ts +6 -0
  57. package/dist/types/runtime-mcp/tool-bridge.d.ts +9 -0
  58. package/dist/types/runtime-mcp/transports/http.d.ts +11 -0
  59. package/dist/types/sdk/broker/broker.d.ts +17 -4
  60. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  61. package/dist/types/sdk/broker/identity.d.ts +5 -1
  62. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +23 -3
  63. package/dist/types/sdk/broker/lifecycle.d.ts +15 -8
  64. package/dist/types/sdk/broker/managed-task-dag.d.ts +1335 -0
  65. package/dist/types/sdk/broker/managed-task-verification.d.ts +37 -0
  66. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  67. package/dist/types/sdk/broker/session-index.d.ts +22 -3
  68. package/dist/types/sdk/broker/spawn-authority.d.ts +12 -0
  69. package/dist/types/sdk/broker/startup-failure.d.ts +4 -0
  70. package/dist/types/sdk/broker/stderr-drainer.d.ts +13 -0
  71. package/dist/types/sdk/bus/index.d.ts +19 -0
  72. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -1
  73. package/dist/types/sdk/bus/telegram-daemon.d.ts +5 -0
  74. package/dist/types/sdk/cli/master-cli.d.ts +6 -10
  75. package/dist/types/sdk/cli/rows.d.ts +19 -1
  76. package/dist/types/sdk/cli/session-cli.d.ts +22 -5
  77. package/dist/types/sdk/client/client.d.ts +2 -0
  78. package/dist/types/sdk/guides/cli.d.ts +3 -4
  79. package/dist/types/sdk/guides/verify.d.ts +2 -0
  80. package/dist/types/sdk/host/host.d.ts +9 -0
  81. package/dist/types/sdk/host/session-runtime.d.ts +31 -0
  82. package/dist/types/sdk/host/types.d.ts +10 -0
  83. package/dist/types/sdk/index.d.ts +1 -0
  84. package/dist/types/sdk/prompt-deadline-flush.d.ts +85 -0
  85. package/dist/types/sdk/prompt-deadline-manager.d.ts +37 -0
  86. package/dist/types/sdk/prompt-tool-boundary.d.ts +73 -0
  87. package/dist/types/sdk/router/session-router.d.ts +2 -0
  88. package/dist/types/sdk/session.d.ts +3 -3
  89. package/dist/types/sdk/startup-capability.d.ts +2 -0
  90. package/dist/types/sdk/transport/relay.d.ts +1 -0
  91. package/dist/types/sdk/transport/serve-cli.d.ts +48 -4
  92. package/dist/types/sdk/transport/stdio.d.ts +5 -1
  93. package/dist/types/session/agent-session.d.ts +175 -23
  94. package/dist/types/session/fallback-chain-controller.d.ts +6 -6
  95. package/dist/types/session/fold-coordinator.d.ts +17 -3
  96. package/dist/types/session/internal/managed-session-storage.d.ts +7 -1
  97. package/dist/types/session/session-manager.d.ts +64 -1
  98. package/dist/types/session/session-work-lease.d.ts +14 -0
  99. package/dist/types/setup/model-onboarding-guidance.d.ts +1 -1
  100. package/dist/types/setup/provider-onboarding.d.ts +81 -1
  101. package/dist/types/task/types.d.ts +2 -0
  102. package/dist/types/tools/bash-interactive.d.ts +1 -0
  103. package/dist/types/tools/bash.d.ts +37 -11
  104. package/dist/types/tools/fetch.d.ts +10 -1
  105. package/dist/types/tools/job.d.ts +1 -1
  106. package/dist/types/tools/steer-fold.d.ts +31 -0
  107. package/dist/types/tools/subagent.d.ts +8 -1
  108. package/dist/types/utils/git.d.ts +28 -2
  109. package/dist/types/utils/mupdf-embedded.d.ts +2 -0
  110. package/dist/types/utils/mupdf.d.ts +4 -0
  111. package/dist/types/utils/session-title-generation.d.ts +6 -0
  112. package/dist/types/utils/title-generator.d.ts +11 -2
  113. package/dist/types/web/scrapers/types.d.ts +3 -0
  114. package/package.json +18 -10
  115. package/scripts/build-binary.ts +9 -3
  116. package/scripts/compile-args.ts +0 -9
  117. package/scripts/embed-mupdf.ts +52 -0
  118. package/scripts/generate-sdk-operation-inventory.ts +31 -1
  119. package/scripts/vendor-markit.ts +196 -0
  120. package/src/async/job-manager.ts +22 -1
  121. package/src/cli/daemon-cli.ts +53 -18
  122. package/src/cli/github-release.ts +87 -5
  123. package/src/cli/mcp-cli.ts +38 -4
  124. package/src/cli/public-command-entry.ts +450 -0
  125. package/src/cli/public-command-errors.ts +581 -0
  126. package/src/cli/public-command-evidence.ts +717 -0
  127. package/src/cli/public-command-help.ts +360 -0
  128. package/src/cli/public-command-registry.ts +541 -0
  129. package/src/cli/session-picker.ts +2 -1
  130. package/src/cli/setup-cli.ts +10 -1
  131. package/src/cli/skills-cli.ts +46 -2
  132. package/src/cli/stats-cli.ts +11 -1
  133. package/src/cli.ts +61 -1
  134. package/src/commands/daemon.ts +65 -69
  135. package/src/commands/sdk.ts +175 -117
  136. package/src/commands/setup.ts +2 -0
  137. package/src/commands/skills.ts +11 -0
  138. package/src/config/autorouting-tier-map.ts +543 -3
  139. package/src/config/file-lock.ts +206 -65
  140. package/src/config/model-discovery-manager.ts +4 -4
  141. package/src/config/model-profile-activation.ts +417 -66
  142. package/src/config/model-registry.ts +254 -36
  143. package/src/config/model-resolver.ts +6 -1
  144. package/src/config/settings-schema.ts +26 -2
  145. package/src/coordinator-mcp/policy.ts +10 -5
  146. package/src/coordinator-mcp/question-gate-codec.ts +17 -0
  147. package/src/coordinator-mcp/question-state.ts +179 -9
  148. package/src/coordinator-mcp/server.ts +521 -87
  149. package/src/coordinator-mcp/session-reaper.ts +6 -4
  150. package/src/crash/record-loader.ts +6 -2
  151. package/src/daemon/operator-contract.ts +26 -12
  152. package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
  153. package/src/eval/js/context-manager.ts +8 -8
  154. package/src/eval/js/worker-core.ts +9 -4
  155. package/src/eval/js/worker-protocol.ts +2 -0
  156. package/src/exec/bash-executor.ts +3 -0
  157. package/src/extensibility/extensions/runner.ts +52 -4
  158. package/src/extensibility/extensions/types.ts +10 -0
  159. package/src/extensibility/gjc-plugins/registry.ts +196 -37
  160. package/src/extensibility/gjc-plugins/runtime-adapters.ts +46 -8
  161. package/src/extensibility/runtime-skill-discovery.ts +61 -3
  162. package/src/extensibility/shared-events.ts +3 -1
  163. package/src/gjc-runtime/session-state-lock.ts +165 -24
  164. package/src/gjc-runtime/state-writer.ts +317 -6
  165. package/src/gjc-runtime/ultragoal-succession.ts +31 -3
  166. package/src/harness-control-plane/control-endpoint.ts +24 -6
  167. package/src/harness-control-plane/finalize.ts +312 -25
  168. package/src/harness-control-plane/owner.ts +323 -89
  169. package/src/internal-urls/docs-index.generated.ts +23 -20
  170. package/src/internal-urls/embedded-protocol.ts +83 -0
  171. package/src/internal-urls/index.ts +1 -0
  172. package/src/internal-urls/parse.ts +21 -1
  173. package/src/internal-urls/router.ts +4 -2
  174. package/src/main.ts +192 -74
  175. package/src/modes/acp/acp-agent.ts +973 -56
  176. package/src/modes/acp/startup-provenance.ts +59 -0
  177. package/src/modes/components/custom-provider-wizard.ts +308 -7
  178. package/src/modes/components/gjc-bundle-settings.ts +21 -1
  179. package/src/modes/components/oauth-selector.ts +9 -0
  180. package/src/modes/components/session-selector.ts +47 -4
  181. package/src/modes/components/tool-status-header.ts +72 -6
  182. package/src/modes/components/user-message-selector.ts +57 -15
  183. package/src/modes/controllers/command-controller.ts +58 -32
  184. package/src/modes/controllers/extension-ui-controller.ts +33 -7
  185. package/src/modes/controllers/input-controller.ts +4 -0
  186. package/src/modes/controllers/runtime-mcp-command-controller.ts +1 -1
  187. package/src/modes/controllers/selector-controller.ts +161 -15
  188. package/src/modes/interactive-mode.ts +21 -7
  189. package/src/modes/runtime-init.ts +1 -0
  190. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +124 -7
  191. package/src/modes/shared/agent-wire/workflow-gate-types.ts +16 -2
  192. package/src/modes/types.ts +2 -2
  193. package/src/prompts/agent-fragments/browser-aside.md +1 -0
  194. package/src/prompts/system/title-system.md +1 -1
  195. package/src/prompts/tools/bash.md +1 -1
  196. package/src/prompts/tools/job.md +1 -1
  197. package/src/prompts/tools/read.md +2 -0
  198. package/src/runtime/network-prewarm-service.test.ts +36 -1
  199. package/src/runtime/network-prewarm-service.ts +28 -3
  200. package/src/runtime/process-lifecycle.ts +22 -11
  201. package/src/runtime-mcp/config.ts +16 -1
  202. package/src/runtime-mcp/loader.ts +6 -3
  203. package/src/runtime-mcp/manager.ts +845 -109
  204. package/src/runtime-mcp/pool.test.ts +31 -0
  205. package/src/runtime-mcp/pool.ts +304 -28
  206. package/src/runtime-mcp/redaction.ts +42 -0
  207. package/src/runtime-mcp/startup-policy.ts +7 -0
  208. package/src/runtime-mcp/tool-bridge.ts +71 -0
  209. package/src/runtime-mcp/transports/http.ts +108 -25
  210. package/src/sdk/acp/adapter.ts +27 -8
  211. package/src/sdk/broker/broker.ts +1431 -198
  212. package/src/sdk/broker/ensure.ts +21 -3
  213. package/src/sdk/broker/identity.ts +22 -1
  214. package/src/sdk/broker/lifecycle-ledger.ts +95 -31
  215. package/src/sdk/broker/lifecycle.ts +690 -141
  216. package/src/sdk/broker/managed-task-dag.ts +1927 -0
  217. package/src/sdk/broker/managed-task-verification.ts +642 -0
  218. package/src/sdk/broker/runtime.ts +1 -1
  219. package/src/sdk/broker/session-index.ts +97 -4
  220. package/src/sdk/broker/spawn-authority.ts +175 -14
  221. package/src/sdk/broker/startup-failure.ts +80 -1
  222. package/src/sdk/broker/stderr-drainer.ts +59 -0
  223. package/src/sdk/broker/transport.ts +1 -0
  224. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  225. package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
  226. package/src/sdk/bus/index.ts +707 -81
  227. package/src/sdk/bus/telegram-daemon-cli.ts +31 -2
  228. package/src/sdk/bus/telegram-daemon-contract.ts +7 -1
  229. package/src/sdk/bus/telegram-daemon.ts +10 -3
  230. package/src/sdk/cli/master-cli.ts +47 -36
  231. package/src/sdk/cli/rows.ts +30 -7
  232. package/src/sdk/cli/session-cli.ts +705 -184
  233. package/src/sdk/client/client.ts +18 -0
  234. package/src/sdk/guides/catalog.ts +2 -2
  235. package/src/sdk/guides/cli.ts +20 -62
  236. package/src/sdk/guides/verify.ts +14 -0
  237. package/src/sdk/host/host.ts +49 -5
  238. package/src/sdk/host/session-runtime.test.ts +184 -2
  239. package/src/sdk/host/session-runtime.ts +322 -101
  240. package/src/sdk/host/types.ts +12 -1
  241. package/src/sdk/host/websocket-transport.lifecycle.test.ts +84 -0
  242. package/src/sdk/host/websocket-transport.ts +40 -6
  243. package/src/sdk/index.ts +11 -0
  244. package/src/sdk/lifecycle/service.ts +2 -0
  245. package/src/sdk/prompt-deadline-flush.ts +356 -0
  246. package/src/sdk/prompt-deadline-manager.ts +133 -1
  247. package/src/sdk/prompt-tool-boundary.ts +121 -0
  248. package/src/sdk/protocol/operation-inventory.generated.json +106 -0
  249. package/src/sdk/router/session-router.ts +9 -1
  250. package/src/sdk/session.ts +763 -116
  251. package/src/sdk/startup-capability.ts +1 -1
  252. package/src/sdk/transport/relay.ts +71 -6
  253. package/src/sdk/transport/serve-cli.ts +397 -42
  254. package/src/sdk/transport/socket.ts +12 -11
  255. package/src/sdk/transport/stdio.ts +7 -3
  256. package/src/session/agent-session.ts +2472 -407
  257. package/src/session/fallback-chain-controller.ts +6 -7
  258. package/src/session/fold-coordinator.ts +24 -5
  259. package/src/session/internal/managed-session-scope.ts +68 -18
  260. package/src/session/internal/managed-session-storage.ts +334 -12
  261. package/src/session/session-manager.ts +382 -44
  262. package/src/session/session-work-lease.ts +34 -0
  263. package/src/setup/model-onboarding-guidance.ts +1 -1
  264. package/src/setup/provider-onboarding.ts +397 -24
  265. package/src/setup/provider-presets.json +2 -1
  266. package/src/slash-commands/builtin-registry.ts +259 -15
  267. package/src/task/executor.ts +46 -17
  268. package/src/task/index.ts +14 -2
  269. package/src/task/types.ts +2 -0
  270. package/src/tools/bash-interactive.ts +2 -0
  271. package/src/tools/bash.ts +118 -78
  272. package/src/tools/fetch.ts +140 -25
  273. package/src/tools/job.ts +151 -25
  274. package/src/tools/output-meta.ts +15 -2
  275. package/src/tools/path-utils.ts +27 -3
  276. package/src/tools/read.ts +3 -3
  277. package/src/tools/steer-fold.ts +107 -0
  278. package/src/tools/subagent-render.ts +24 -1
  279. package/src/tools/subagent.ts +27 -1
  280. package/src/tools/tool-catalog.generated.ts +29 -2
  281. package/src/tools/tool-errors.ts +4 -0
  282. package/src/utils/git.ts +53 -3
  283. package/src/utils/markit.ts +55 -41
  284. package/src/utils/mupdf-assets.d.ts +4 -0
  285. package/src/utils/mupdf-embedded.ts +4 -0
  286. package/src/utils/mupdf.ts +136 -0
  287. package/src/utils/session-title-generation.ts +29 -0
  288. package/src/utils/title-generator.ts +46 -2
  289. package/src/web/scrapers/types.ts +24 -3
  290. package/vendor/markit-ai/LICENSE +21 -0
  291. package/vendor/markit-ai/MANIFEST.json +412 -0
  292. package/vendor/markit-ai/dist/commands/config.d.ts +4 -0
  293. package/vendor/markit-ai/dist/commands/config.js +139 -0
  294. package/vendor/markit-ai/dist/commands/convert.d.ts +6 -0
  295. package/vendor/markit-ai/dist/commands/convert.js +113 -0
  296. package/vendor/markit-ai/dist/commands/formats.d.ts +2 -0
  297. package/vendor/markit-ai/dist/commands/formats.js +92 -0
  298. package/vendor/markit-ai/dist/commands/init.d.ts +2 -0
  299. package/vendor/markit-ai/dist/commands/init.js +29 -0
  300. package/vendor/markit-ai/dist/commands/onboard.d.ts +2 -0
  301. package/vendor/markit-ai/dist/commands/onboard.js +61 -0
  302. package/vendor/markit-ai/dist/commands/plugin.d.ts +4 -0
  303. package/vendor/markit-ai/dist/commands/plugin.js +58 -0
  304. package/vendor/markit-ai/dist/config.d.ts +26 -0
  305. package/vendor/markit-ai/dist/config.js +42 -0
  306. package/vendor/markit-ai/dist/converters/audio.d.ts +7 -0
  307. package/vendor/markit-ai/dist/converters/audio.js +104 -0
  308. package/vendor/markit-ai/dist/converters/csv.d.ts +7 -0
  309. package/vendor/markit-ai/dist/converters/csv.js +83 -0
  310. package/vendor/markit-ai/dist/converters/docx.d.ts +6 -0
  311. package/vendor/markit-ai/dist/converters/docx.js +57 -0
  312. package/vendor/markit-ai/dist/converters/epub.d.ts +8 -0
  313. package/vendor/markit-ai/dist/converters/epub.js +128 -0
  314. package/vendor/markit-ai/dist/converters/github.d.ts +18 -0
  315. package/vendor/markit-ai/dist/converters/github.js +148 -0
  316. package/vendor/markit-ai/dist/converters/html.d.ts +6 -0
  317. package/vendor/markit-ai/dist/converters/html.js +30 -0
  318. package/vendor/markit-ai/dist/converters/image.d.ts +6 -0
  319. package/vendor/markit-ai/dist/converters/image.js +124 -0
  320. package/vendor/markit-ai/dist/converters/ipynb.d.ts +6 -0
  321. package/vendor/markit-ai/dist/converters/ipynb.js +75 -0
  322. package/vendor/markit-ai/dist/converters/iwork.d.ts +20 -0
  323. package/vendor/markit-ai/dist/converters/iwork.js +391 -0
  324. package/vendor/markit-ai/dist/converters/json.d.ts +6 -0
  325. package/vendor/markit-ai/dist/converters/json.js +21 -0
  326. package/vendor/markit-ai/dist/converters/pdf/columns.d.ts +35 -0
  327. package/vendor/markit-ai/dist/converters/pdf/columns.js +93 -0
  328. package/vendor/markit-ai/dist/converters/pdf/extract.d.ts +19 -0
  329. package/vendor/markit-ai/dist/converters/pdf/extract.js +514 -0
  330. package/vendor/markit-ai/dist/converters/pdf/grid.d.ts +25 -0
  331. package/vendor/markit-ai/dist/converters/pdf/grid.js +795 -0
  332. package/vendor/markit-ai/dist/converters/pdf/headers.d.ts +24 -0
  333. package/vendor/markit-ai/dist/converters/pdf/headers.js +108 -0
  334. package/vendor/markit-ai/dist/converters/pdf/index.d.ts +19 -0
  335. package/vendor/markit-ai/dist/converters/pdf/index.js +136 -0
  336. package/vendor/markit-ai/dist/converters/pdf/mupdf-loader.js +9 -0
  337. package/vendor/markit-ai/dist/converters/pdf/render.d.ts +24 -0
  338. package/vendor/markit-ai/dist/converters/pdf/render.js +513 -0
  339. package/vendor/markit-ai/dist/converters/pdf/types.d.ts +75 -0
  340. package/vendor/markit-ai/dist/converters/pdf/types.js +1 -0
  341. package/vendor/markit-ai/dist/converters/plain-text.d.ts +6 -0
  342. package/vendor/markit-ai/dist/converters/plain-text.js +87 -0
  343. package/vendor/markit-ai/dist/converters/pptx.d.ts +8 -0
  344. package/vendor/markit-ai/dist/converters/pptx.js +289 -0
  345. package/vendor/markit-ai/dist/converters/rss.d.ts +11 -0
  346. package/vendor/markit-ai/dist/converters/rss.js +138 -0
  347. package/vendor/markit-ai/dist/converters/wikipedia.d.ts +6 -0
  348. package/vendor/markit-ai/dist/converters/wikipedia.js +33 -0
  349. package/vendor/markit-ai/dist/converters/xlsx.d.ts +8 -0
  350. package/vendor/markit-ai/dist/converters/xlsx.js +147 -0
  351. package/vendor/markit-ai/dist/converters/xml.d.ts +6 -0
  352. package/vendor/markit-ai/dist/converters/xml.js +18 -0
  353. package/vendor/markit-ai/dist/converters/yaml.d.ts +6 -0
  354. package/vendor/markit-ai/dist/converters/yaml.js +17 -0
  355. package/vendor/markit-ai/dist/converters/zip.d.ts +8 -0
  356. package/vendor/markit-ai/dist/converters/zip.js +57 -0
  357. package/vendor/markit-ai/dist/index.d.ts +30 -0
  358. package/vendor/markit-ai/dist/index.js +26 -0
  359. package/vendor/markit-ai/dist/main.d.ts +2 -0
  360. package/vendor/markit-ai/dist/main.js +191 -0
  361. package/vendor/markit-ai/dist/markit.d.ts +45 -0
  362. package/vendor/markit-ai/dist/markit.js +267 -0
  363. package/vendor/markit-ai/dist/plugins/api.d.ts +7 -0
  364. package/vendor/markit-ai/dist/plugins/api.js +42 -0
  365. package/vendor/markit-ai/dist/plugins/index.d.ts +4 -0
  366. package/vendor/markit-ai/dist/plugins/index.js +3 -0
  367. package/vendor/markit-ai/dist/plugins/installer.d.ts +25 -0
  368. package/vendor/markit-ai/dist/plugins/installer.js +176 -0
  369. package/vendor/markit-ai/dist/plugins/loader.d.ts +6 -0
  370. package/vendor/markit-ai/dist/plugins/loader.js +61 -0
  371. package/vendor/markit-ai/dist/plugins/types.d.ts +25 -0
  372. package/vendor/markit-ai/dist/plugins/types.js +1 -0
  373. package/vendor/markit-ai/dist/providers/anthropic.d.ts +2 -0
  374. package/vendor/markit-ai/dist/providers/anthropic.js +47 -0
  375. package/vendor/markit-ai/dist/providers/index.d.ts +21 -0
  376. package/vendor/markit-ai/dist/providers/index.js +56 -0
  377. package/vendor/markit-ai/dist/providers/openai.d.ts +2 -0
  378. package/vendor/markit-ai/dist/providers/openai.js +76 -0
  379. package/vendor/markit-ai/dist/providers/types.d.ts +26 -0
  380. package/vendor/markit-ai/dist/providers/types.js +1 -0
  381. package/vendor/markit-ai/dist/types.d.ts +36 -0
  382. package/vendor/markit-ai/dist/types.js +1 -0
  383. package/vendor/markit-ai/dist/utils/exit-codes.d.ts +4 -0
  384. package/vendor/markit-ai/dist/utils/exit-codes.js +4 -0
  385. package/vendor/markit-ai/dist/utils/output.d.ts +22 -0
  386. package/vendor/markit-ai/dist/utils/output.js +31 -0
  387. package/vendor/markit-ai/dist/utils/turndown.d.ts +8 -0
  388. package/vendor/markit-ai/dist/utils/turndown.js +70 -0
  389. package/vendor/markit-ai/package.json +74 -0
  390. package/vendor/markit-ai.patch +49 -0
@@ -131,6 +131,7 @@ import {
131
131
  SERVER_OVERLOADED_PROVIDER_CODE,
132
132
  STREAM_FIRST_EVENT_TIMEOUT_PROVIDER_CODE,
133
133
  } from "@gajae-code/ai/utils/fallback-transport";
134
+ import { REPETITION_GUARD_ERROR_CODE } from "@gajae-code/ai/utils/stream-repetition-guard";
134
135
  import { AttemptRecordStore } from "./attempt-record-store";
135
136
  import {
136
137
  BTW_MAX_ANSWER_UTF8_BYTES,
@@ -142,6 +143,7 @@ import {
142
143
  truncateUtf8,
143
144
  utf8ByteLength,
144
145
  } from "./btw-contract";
146
+ import { createSessionWorkLease, type SessionWorkLease, type SessionWorkLeaseHandle } from "./session-work-lease";
145
147
  import { DEFAULT_ARTIFACT_MAX_BYTES, truncateHeadBytes } from "./streaming-output";
146
148
 
147
149
  export interface ForkContextSeedMetadata {
@@ -197,6 +199,7 @@ import {
197
199
  logger,
198
200
  prompt,
199
201
  Snowflake,
202
+ safeErrorDescription,
200
203
  } from "@gajae-code/utils";
201
204
  import { createAppendOnlyContextManager, resolveAppendOnlyMode } from "../append-only-mode";
202
205
  import {
@@ -211,7 +214,12 @@ import {
211
214
  import { reset as resetCapabilities } from "../capability";
212
215
  import type { Rule } from "../capability/rule";
213
216
  import type { CasReceipt } from "../config/atomic-yaml-patch";
214
- import { activateModelProfile, materializeActiveModelProfileAssignment } from "../config/model-profile-activation";
217
+ import {
218
+ activateModelProfile,
219
+ applyModelProfileRuntimeBindings,
220
+ materializeActiveModelProfileAssignment,
221
+ resolveMissingSessionModelRecovery,
222
+ } from "../config/model-profile-activation";
215
223
  import {
216
224
  ModelProfileRegistryError,
217
225
  resolveModelProfileName,
@@ -236,6 +244,7 @@ import {
236
244
  resolveModelChainWithAuth,
237
245
  resolveModelRoleValue,
238
246
  type ScopedModelSelection,
247
+ splitSelectorThinkingSuffix,
239
248
  } from "../config/model-resolver";
240
249
  import { type ModelSelectorValue, normalizeModelSelectorValue } from "../config/model-selector-value";
241
250
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
@@ -385,7 +394,8 @@ import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
385
394
  import type { LazyService } from "../runtime/lazy-service";
386
395
  import type { NetworkPrewarmRuntime } from "../runtime/network-prewarm-service";
387
396
  import type { WorkspaceTreeRuntime } from "../runtime/workspace-tree-service";
388
- import { MCPManager } from "../runtime-mcp/manager";
397
+ import { type ExactMcpServerControlResult, MCPManager } from "../runtime-mcp/manager";
398
+ import { attachExactMcpControls, getExactMcpControls, revokeExactMcpControls } from "../runtime-mcp/redaction";
389
399
  import type { NotificationSessionController } from "../sdk/bus/session-control";
390
400
  import { buildSyntheticModelId, syntheticNamespaceCollision } from "../sdk/model-profile-model";
391
401
  import { sanitizePromptFailure } from "../sdk/prompt-failure";
@@ -432,6 +442,7 @@ import { parseCommandArgs } from "../utils/command-args";
432
442
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
433
443
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
434
444
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
445
+ import { invalidateSessionTitleGeneration } from "../utils/session-title-generation";
435
446
  import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-choice";
436
447
  import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../workflow/workflow-intent-diff";
437
448
  import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
@@ -449,6 +460,7 @@ import {
449
460
  effectiveFallbackDelay,
450
461
  FallbackChainController,
451
462
  type FallbackChainRuntimeState,
463
+ sanitizeCompactionCandidateFailureMessage,
452
464
  } from "./fallback-chain-controller";
453
465
 
454
466
  export { DefaultModelSelectionRecoveryError } from "./default-model-selection";
@@ -508,6 +520,7 @@ import {
508
520
  getLatestCompactionEntry,
509
521
  getSessionMessageEntryId,
510
522
  getSessionMessageObservationId,
523
+ SESSION_LIMIT_RECOVERY_ACTIONS,
511
524
  SessionAppendPersistenceError,
512
525
  SessionContextTooLargeError,
513
526
  SessionManager,
@@ -835,6 +848,56 @@ export type AgentSessionEvent =
835
848
  | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }
836
849
  | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
837
850
 
851
+ export type QueuedInputDelivery = "steer" | "followUp";
852
+ export type QueuedInputQueuePolicy = "respect-mode" | "sequential";
853
+ export type QueuedInputRemovalReason = "cancelled" | "removed";
854
+
855
+ export interface QueuedInputAdmission {
856
+ submissionId: string;
857
+ delivery: QueuedInputDelivery;
858
+ queuePolicy: QueuedInputQueuePolicy;
859
+ }
860
+
861
+ export type QueuedInputExecution =
862
+ | {
863
+ submissionId: string;
864
+ delivery: QueuedInputDelivery;
865
+ disposition: "joined-current-run" | "promoted-to-run";
866
+ attemptScope: AttemptScopeRef;
867
+ }
868
+ | {
869
+ submissionId: string;
870
+ delivery: QueuedInputDelivery;
871
+ disposition: "removed";
872
+ reason: QueuedInputRemovalReason;
873
+ };
874
+
875
+ export type QueuedInputTerminal =
876
+ | {
877
+ submissionId: string;
878
+ delivery: QueuedInputDelivery;
879
+ disposition: "completed";
880
+ attemptScope: AttemptScopeRef;
881
+ }
882
+ | {
883
+ submissionId: string;
884
+ delivery: QueuedInputDelivery;
885
+ disposition: "removed";
886
+ reason: QueuedInputRemovalReason;
887
+ };
888
+
889
+ /**
890
+ * Lifecycle receipt for one explicitly queued `submitUserMessage` submission.
891
+ */
892
+ export interface QueuedInputSubmission {
893
+ readonly submissionId: string;
894
+ readonly admitted: Promise<QueuedInputAdmission>;
895
+ readonly execution: Promise<QueuedInputExecution>;
896
+ readonly terminal: Promise<QueuedInputTerminal>;
897
+ /** Remove the submission if it is still queued. Returns false after execution or removal. */
898
+ readonly cancel: () => boolean;
899
+ }
900
+
838
901
  /** Listener function for agent session events */
839
902
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
840
903
  export type AsyncJobSnapshotItem = Pick<
@@ -1115,6 +1178,29 @@ export interface PromptOptions {
1115
1178
  sdkRunCapability?: unknown;
1116
1179
  }
1117
1180
 
1181
+ export interface SendUserMessageOptions {
1182
+ deliverAs?: QueuedInputDelivery;
1183
+ /** Preserve a busy SDK dispatch as queued work across an admission fence. */
1184
+ queuedAtDispatch?: boolean;
1185
+ /** Delivery ordering for a tracked or SDK-queued submission. */
1186
+ queuePolicy?: QueuedInputQueuePolicy;
1187
+ onPreflightAccepted?: () => void;
1188
+ onPreflightAcceptCommit?: () => void | Promise<void>;
1189
+ /** Internal SDK ownership callback. */
1190
+ onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
1191
+ /** Internal dispatch disposition used before actual queue consumption. */
1192
+ onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
1193
+ preflightSignal?: AbortSignal;
1194
+ sdkRunCapability?: unknown;
1195
+ }
1196
+
1197
+ export interface TrackedSendUserMessageOptions extends SendUserMessageOptions {
1198
+ deliverAs: QueuedInputDelivery;
1199
+ trackSubmission: true;
1200
+ }
1201
+
1202
+ type SendUserMessageDispatchOptions = SendUserMessageOptions & { trackSubmission?: boolean };
1203
+
1118
1204
  type InternalPromptOptions = PromptOptions & { sdkRunToken?: string };
1119
1205
  type InternalCustomMessageOptions = Pick<
1120
1206
  PromptOptions,
@@ -1129,6 +1215,32 @@ type InternalCustomMessageOptions = Pick<
1129
1215
  sdkRunToken?: string;
1130
1216
  };
1131
1217
 
1218
+ function assertQueuedInputQueuePolicy(queuePolicy: unknown): asserts queuePolicy is QueuedInputQueuePolicy | undefined {
1219
+ if (queuePolicy !== undefined && queuePolicy !== "respect-mode" && queuePolicy !== "sequential") {
1220
+ throw Object.assign(new Error("queuePolicy must be respect-mode or sequential."), { code: "invalid_input" });
1221
+ }
1222
+ }
1223
+
1224
+ function assertTrackedSendUserMessageOptions(options: unknown): asserts options is TrackedSendUserMessageOptions {
1225
+ const candidate = options !== null && typeof options === "object" ? (options as Record<string, unknown>) : undefined;
1226
+ if (candidate?.trackSubmission !== true || (candidate.deliverAs !== "steer" && candidate.deliverAs !== "followUp")) {
1227
+ throw Object.assign(
1228
+ new Error("submitUserMessage requires trackSubmission: true and deliverAs: steer or followUp."),
1229
+ { code: "invalid_input" },
1230
+ );
1231
+ }
1232
+ assertQueuedInputQueuePolicy(candidate?.queuePolicy);
1233
+ }
1234
+
1235
+ function assertLegacySendUserMessageOptions(options: unknown): void {
1236
+ const candidate = options !== null && typeof options === "object" ? (options as Record<string, unknown>) : undefined;
1237
+ if (candidate?.trackSubmission !== undefined) {
1238
+ throw Object.assign(new Error("sendUserMessage does not support trackSubmission; use submitUserMessage."), {
1239
+ code: "invalid_input",
1240
+ });
1241
+ }
1242
+ }
1243
+
1132
1244
  function promptPreflightCancelledError(): Error {
1133
1245
  const error = Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" });
1134
1246
  error.name = "PromptPreflightCancelledError";
@@ -2004,6 +2116,40 @@ function extractPermissionLocations(
2004
2116
  type QueuedDisplayEntry = { text: string; tag?: string; sequence: number; message?: AgentMessage };
2005
2117
  type IrcRosterClaim = { token: symbol; signature: string; epoch: number; message: CustomMessage };
2006
2118
  type QueuedFollowUpOwner = { cancel(): boolean };
2119
+ type QueueFollowUpOptions = {
2120
+ forceOneAtATime?: boolean;
2121
+ claimsGenuineUserIntent?: boolean;
2122
+ onPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
2123
+ sdkRunToken?: string;
2124
+ onQueued?: (message: AgentMessage) => void;
2125
+ scheduleNonAdmittedWake?: boolean;
2126
+ onQueuedAfterAdmission?: (message: AgentMessage, cancelQueued: () => boolean) => void;
2127
+ allowDuringSessionTransition?: boolean;
2128
+ allowCancelAndSubmit?: boolean;
2129
+ createDisplayEntry?: boolean;
2130
+ trackExternalFollowUp?: boolean;
2131
+ };
2132
+ type DeferredValue<T> = {
2133
+ promise: Promise<T>;
2134
+ resolve: (value: T | PromiseLike<T>) => void;
2135
+ reject: (reason?: unknown) => void;
2136
+ };
2137
+ type TrackedQueuedInput = {
2138
+ submission: QueuedInputSubmission;
2139
+ admitted: DeferredValue<QueuedInputAdmission>;
2140
+ execution: DeferredValue<QueuedInputExecution>;
2141
+ terminal: DeferredValue<QueuedInputTerminal>;
2142
+ delivery: QueuedInputDelivery;
2143
+ queuePolicy: QueuedInputQueuePolicy;
2144
+ message?: AgentMessage;
2145
+ cancelQueued?: () => boolean;
2146
+ removePreflightAbortListener?: () => void;
2147
+ attemptScope?: AttemptScope;
2148
+ logicalRunId?: AttemptRunHandle["logicalRunId"];
2149
+ executionSettled: boolean;
2150
+ terminalSettled: boolean;
2151
+ cancelRequested: boolean;
2152
+ };
2007
2153
  export type QueuedMessageEditMode = "steer" | "followUp";
2008
2154
 
2009
2155
  export interface QueuedMessageEditEntry {
@@ -2033,6 +2179,7 @@ export type BeforeAgentStartContributor = (event: {
2033
2179
  }) => Promise<BeforeAgentStartInternalMessage | undefined>;
2034
2180
 
2035
2181
  const AGENT_END_WORKER_INTEGRATION_TIMEOUT_MS = 5_000;
2182
+ const COORDINATOR_PERSISTENCE_TEST_IDLE_TIMEOUT_MS = 1_000;
2036
2183
  const POST_PUBLICATION_ERROR_MAX_BYTES = 512;
2037
2184
 
2038
2185
  /**
@@ -2294,7 +2441,93 @@ export class StreamingEditFileCache {
2294
2441
  return this.#totalBytes;
2295
2442
  }
2296
2443
  }
2297
- type SessionAdmissionKind = "prompt" | "selection";
2444
+ type SessionAdmissionKind = "prompt" | "selection" | "mcp-control";
2445
+
2446
+ /**
2447
+ * Startup phase of the session-owned exact-config MCP manager.
2448
+ *
2449
+ * `not-started` and `no-servers-declared` are distinguishable only because the
2450
+ * snapshot reads the declared server list from the exact config; the manager
2451
+ * alone reports an empty name list in both cases.
2452
+ */
2453
+ export type ExactMcpStartupState = "not-started" | "no-servers-declared" | "in-flight" | "settled";
2454
+
2455
+ /**
2456
+ * Per-server state.
2457
+ *
2458
+ * `unavailable` covers a server the config declares that the manager has no
2459
+ * live record of. A tools-only manager erases a server from its own bookkeeping
2460
+ * when the connection fails, so a failed server and one whose startup has not
2461
+ * reached it are indistinguishable from here; both surface as `unavailable`
2462
+ * rather than being dropped from the table.
2463
+ */
2464
+ export type ExactMcpServerState = "connecting" | "connected" | "disconnected" | "unavailable";
2465
+ export type ExactMcpServerDisplayState = ExactMcpServerState | "suspended";
2466
+
2467
+ /**
2468
+ * Transport a server is declared with. Taken from the config's own
2469
+ * discriminator, never from a live connection: `getConnection` throws on a
2470
+ * tools-only manager, so the declaration is the only source available here.
2471
+ */
2472
+ export type ExactMcpTransport = "stdio" | "http" | "sse" | "unknown";
2473
+
2474
+ export interface ExactMcpServerStatus {
2475
+ readonly name: string;
2476
+ readonly transport: ExactMcpTransport;
2477
+ readonly state: ExactMcpServerDisplayState;
2478
+ readonly toolCount: number;
2479
+ }
2480
+
2481
+ /**
2482
+ * Whitelisted status shape for the TUI `/mcp` command. Every field here is
2483
+ * either a server name the operator already supplied or a derived enum/count;
2484
+ * pool keys, endpoints, commands, env, headers, tokens, and raw exception text
2485
+ * have no field to travel in.
2486
+ */
2487
+ export interface ExactMcpStatusSnapshot {
2488
+ readonly startup: ExactMcpStartupState;
2489
+ readonly servers: readonly ExactMcpServerStatus[];
2490
+ }
2491
+
2492
+ export type ExactMcpControlAction = "suspend" | "resume" | "reconnect";
2493
+
2494
+ export interface ExactMcpControlResult extends ExactMcpServerControlResult {
2495
+ readonly action: ExactMcpControlAction;
2496
+ }
2497
+
2498
+ type ExactMcpSessionStateSnapshot = {
2499
+ toolRegistry: Map<string, AgentTool>;
2500
+ discoverableMCPTools: Map<string, DiscoverableTool>;
2501
+ selectedMCPToolNames: Set<string>;
2502
+ selectedDiscoveredToolNames: Set<string>;
2503
+ tools: AgentTool[];
2504
+ baseSystemPrompt: string[];
2505
+ systemPrompt: string[];
2506
+ lastAppliedToolSignature: string | undefined;
2507
+ pendingAppliedToolSignature: string | undefined;
2508
+ defaultModelSelectionMutationRevision: number;
2509
+ baseSystemPromptGeneration: number;
2510
+ suspendedTools: Map<string, { selected: string[]; active: string[] }>;
2511
+ };
2512
+ type PreparedExactMcpRetirement = {
2513
+ canCommit(): boolean;
2514
+ commit(): Promise<void>;
2515
+ abort(): Promise<void>;
2516
+ };
2517
+
2518
+ /**
2519
+ * An entry states its transport with `type`, or implies it: a `command` entry is
2520
+ * stdio and a `url` entry is http. Only the discriminator is read — the command
2521
+ * string and URL themselves stay inside the config.
2522
+ */
2523
+ function exactMcpTransportOf(entry: unknown): ExactMcpTransport {
2524
+ if (!entry || typeof entry !== "object") return "unknown";
2525
+ const record = entry as { type?: unknown; command?: unknown; url?: unknown };
2526
+ if (record.type === "stdio" || record.type === "http" || record.type === "sse") return record.type;
2527
+ if (typeof record.command === "string") return "stdio";
2528
+ if (typeof record.url === "string") return "http";
2529
+ return "unknown";
2530
+ }
2298
2531
  class SessionRunCancellationDomainBridge implements RunCancellationDomainBridge {
2299
2532
  #domains = new Map<string, { domain: RunCancellationDomain; controller: AbortController }>();
2300
2533
  #released = new Set<string>();
@@ -2349,6 +2582,13 @@ type SessionAdmissionEntry = {
2349
2582
  continuationCapability?: symbol;
2350
2583
  };
2351
2584
 
2585
+ type SuccessorSessionAdmission = {
2586
+ generation: number;
2587
+ sessionId: string | undefined;
2588
+ capability: symbol;
2589
+ active: boolean;
2590
+ };
2591
+
2352
2592
  type SessionAdmissionLease = {
2353
2593
  release(): void;
2354
2594
  };
@@ -2421,6 +2661,17 @@ type CoordinatorRuntimeStatePersistContext = {
2421
2661
  sessionFile: string | undefined;
2422
2662
  stateFile: string | null;
2423
2663
  };
2664
+
2665
+ /** A lost continuation may only settle the emitter/session pair that opened it. */
2666
+ export function isCurrentWorkflowGateContinuation(
2667
+ currentSessionId: string,
2668
+ expectedSessionId: string,
2669
+ currentEmitter: WorkflowGateEmitter | undefined,
2670
+ expectedEmitter: WorkflowGateEmitter,
2671
+ ): boolean {
2672
+ return currentSessionId === expectedSessionId && currentEmitter === expectedEmitter;
2673
+ }
2674
+
2424
2675
  export class AgentSession {
2425
2676
  #provisionalStreamingToolCallIds = new Set<string>();
2426
2677
  readonly agent: Agent;
@@ -2465,6 +2716,7 @@ export class AgentSession {
2465
2716
  #sessionAdmissionClosing = false;
2466
2717
  #sessionAdmissionClosed = false;
2467
2718
  #sessionAdmissionContext = new AsyncLocalStorage<SessionAdmissionEntry>();
2719
+ #successorSessionAdmissionContext = new AsyncLocalStorage<SuccessorSessionAdmission>();
2468
2720
  #selectionFenceGenerationContext = new AsyncLocalStorage<number>();
2469
2721
  #selectionFenceTail: Promise<void> = Promise.resolve();
2470
2722
  #pendingSelectionFences = 0;
@@ -2477,6 +2729,7 @@ export class AgentSession {
2477
2729
  /** Epochs of follow-up reservations still between reservation and durable enqueue. */
2478
2730
  #activeFollowUpReservationEpochs = new Set<number>();
2479
2731
  #followUpReservationDrainWaiters = new Set<() => void>();
2732
+ #followUpReservationTransitionWaiters = new Set<() => void>();
2480
2733
  #selectionFenceGeneration = 0;
2481
2734
  #defaultModelSelectionMutationRevision = 0;
2482
2735
  #thinkingLevelMutationRevision = 0;
@@ -2558,8 +2811,15 @@ export class AgentSession {
2558
2811
  AgentMessage,
2559
2812
  (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void
2560
2813
  >();
2561
- /** SDK-owned follow-ups held outside Agent's live queue until the active run ends. */
2814
+ /** Deferred follow-ups held outside Agent's live queue until the active run ends. */
2562
2815
  #deferredSdkFollowUps: AgentMessage[] = [];
2816
+ /** Per-message delivery policy for deferred follow-ups; WeakSet metadata survives array snapshots. */
2817
+ #deferredFollowUpForceOneAtATime = new WeakSet<AgentMessage>();
2818
+ /** Atomic all-mode cohorts that must be released together from the deferred queue. */
2819
+ #deferredFollowUpBatches = new WeakMap<AgentMessage, readonly AgentMessage[]>();
2820
+ /** Admission order for externally submitted queued messages across both queue stores. */
2821
+ #queuedAdmissionSequence = 0;
2822
+ readonly #queuedAdmissionSeq = new WeakMap<AgentMessage, number>();
2563
2823
  // Client/SDK steering (turn.prompt diverted to steer while streaming, or an
2564
2824
  // explicit turn.steer) is an independent root-turn request ONLY when it is
2565
2825
  // admitted AFTER the terminal abort snapshot: a terminal abort admitted
@@ -2597,26 +2857,230 @@ export class AgentSession {
2597
2857
  readonly #terminalAbortSteeringSnapshotKeys = new Map<number, string>();
2598
2858
  #terminalAbortAdmissionSeq = 0;
2599
2859
  #queuedDisplaySequence = 0;
2860
+ readonly #trackedQueuedInputs = new Map<string, TrackedQueuedInput>();
2861
+ readonly #trackedQueuedInputsAwaitingOwnRun = new Set<TrackedQueuedInput>();
2862
+ readonly #trackedQueuedInputsByLogicalRunId = new Map<AttemptRunHandle["logicalRunId"], Set<TrackedQueuedInput>>();
2863
+ readonly #trackedQueuedInputsByAttemptScope = new WeakMap<AttemptScope, Set<TrackedQueuedInput>>();
2864
+
2865
+ #createTrackedQueuedInput(delivery: QueuedInputDelivery, queuePolicy: QueuedInputQueuePolicy): TrackedQueuedInput {
2866
+ const admitted = Promise.withResolvers<QueuedInputAdmission>();
2867
+ const execution = Promise.withResolvers<QueuedInputExecution>();
2868
+ const terminal = Promise.withResolvers<QueuedInputTerminal>();
2869
+ const submissionId = `queued-${crypto.randomUUID()}`;
2870
+ const state = {} as TrackedQueuedInput;
2871
+ const submission: QueuedInputSubmission = {
2872
+ submissionId,
2873
+ admitted: admitted.promise,
2874
+ execution: execution.promise,
2875
+ terminal: terminal.promise,
2876
+ cancel: () => {
2877
+ if (state.executionSettled || state.terminalSettled || state.cancelQueued === undefined) return false;
2878
+ state.cancelRequested = true;
2879
+ const removed = state.cancelQueued();
2880
+ if (!removed) state.cancelRequested = false;
2881
+ return removed;
2882
+ },
2883
+ };
2884
+ state.submission = submission;
2885
+ state.admitted = admitted;
2886
+ state.execution = execution;
2887
+ state.terminal = terminal;
2888
+ state.delivery = delivery;
2889
+ state.queuePolicy = queuePolicy;
2890
+ state.executionSettled = false;
2891
+ state.terminalSettled = false;
2892
+ state.cancelRequested = false;
2893
+ this.#trackedQueuedInputs.set(submissionId, state);
2894
+ return state;
2895
+ }
2896
+
2897
+ #scopeRef(scope: AttemptScope): AttemptScopeRef {
2898
+ return { attemptId: scope.attemptId, generation: scope.generation, lineage: scope.lineage };
2899
+ }
2900
+
2901
+ #forgetTrackedQueuedInputOwnership(state: TrackedQueuedInput): void {
2902
+ if (state.logicalRunId !== undefined) {
2903
+ const states = this.#trackedQueuedInputsByLogicalRunId.get(state.logicalRunId);
2904
+ if (states) {
2905
+ states.delete(state);
2906
+ if (states.size === 0) this.#trackedQueuedInputsByLogicalRunId.delete(state.logicalRunId);
2907
+ }
2908
+ }
2909
+ if (state.attemptScope !== undefined) {
2910
+ const states = this.#trackedQueuedInputsByAttemptScope.get(state.attemptScope);
2911
+ if (states) {
2912
+ states.delete(state);
2913
+ if (states.size === 0) this.#trackedQueuedInputsByAttemptScope.delete(state.attemptScope);
2914
+ }
2915
+ }
2916
+ }
2917
+
2918
+ #clearTrackedQueuedInputQueueOwnership(state: TrackedQueuedInput): void {
2919
+ state.removePreflightAbortListener?.();
2920
+ state.removePreflightAbortListener = undefined;
2921
+ state.cancelQueued = undefined;
2922
+ state.message = undefined;
2923
+ }
2924
+
2925
+ #admitTrackedQueuedInput(state: TrackedQueuedInput, message: AgentMessage, cancelQueued: () => boolean): void {
2926
+ if (state.message !== undefined || state.terminalSettled) return;
2927
+ state.message = message;
2928
+ state.cancelQueued = cancelQueued;
2929
+ state.admitted.resolve({
2930
+ submissionId: state.submission.submissionId,
2931
+ delivery: state.delivery,
2932
+ queuePolicy: state.queuePolicy,
2933
+ });
2934
+ }
2935
+ #settleTrackedAdmissionCallbackFailure(state: TrackedQueuedInput, owner: QueuedFollowUpOwner): void {
2936
+ if (owner.cancel()) return;
2937
+ this.#settleTrackedQueuedInputRemoved(state, "removed");
2938
+ }
2939
+
2940
+ #settleTrackedExecution(
2941
+ state: TrackedQueuedInput,
2942
+ disposition: "joined-current-run" | "promoted-to-run",
2943
+ scope: AttemptScope,
2944
+ logicalRunId?: AttemptRunHandle["logicalRunId"],
2945
+ ): void {
2946
+ if (state.executionSettled || state.terminalSettled) return;
2947
+ state.executionSettled = true;
2948
+ this.#clearTrackedQueuedInputQueueOwnership(state);
2949
+ state.attemptScope = scope;
2950
+ const owningLogicalRunId =
2951
+ logicalRunId ?? this.#logicalRunIdByAttemptScope.get(scope) ?? this.#activeLogicalRunId;
2952
+ state.logicalRunId = owningLogicalRunId;
2953
+ if (owningLogicalRunId !== undefined) {
2954
+ let logicalStates = this.#trackedQueuedInputsByLogicalRunId.get(owningLogicalRunId);
2955
+ if (logicalStates === undefined) {
2956
+ logicalStates = new Set<TrackedQueuedInput>();
2957
+ this.#trackedQueuedInputsByLogicalRunId.set(owningLogicalRunId, logicalStates);
2958
+ }
2959
+ logicalStates.add(state);
2960
+ }
2961
+ let scopeStates = this.#trackedQueuedInputsByAttemptScope.get(scope);
2962
+ if (scopeStates === undefined) {
2963
+ scopeStates = new Set<TrackedQueuedInput>();
2964
+ this.#trackedQueuedInputsByAttemptScope.set(scope, scopeStates);
2965
+ }
2966
+ scopeStates.add(state);
2967
+ state.execution.resolve({
2968
+ submissionId: state.submission.submissionId,
2969
+ delivery: state.delivery,
2970
+ disposition,
2971
+ attemptScope: this.#scopeRef(scope),
2972
+ });
2973
+ }
2974
+
2975
+ #settleTrackedOwnRunPromotions(handle: AttemptRunHandle): void {
2976
+ if (this.#trackedQueuedInputsAwaitingOwnRun.size === 0) return;
2977
+ for (const state of [...this.#trackedQueuedInputsAwaitingOwnRun]) {
2978
+ this.#trackedQueuedInputsAwaitingOwnRun.delete(state);
2979
+ this.#settleTrackedExecution(state, "promoted-to-run", handle.scope, handle.logicalRunId);
2980
+ }
2981
+ }
2982
+
2983
+ #settleTrackedOwnRunPromotionFailures(): void {
2984
+ for (const state of [...this.#trackedQueuedInputsAwaitingOwnRun])
2985
+ this.#settleTrackedQueuedInputRemoved(state, "removed");
2986
+ }
2987
+
2988
+ #settleTrackedQueuedInputRemoved(state: TrackedQueuedInput, reason: QueuedInputRemovalReason): void {
2989
+ if (!state.executionSettled) {
2990
+ state.executionSettled = true;
2991
+ state.execution.resolve({
2992
+ submissionId: state.submission.submissionId,
2993
+ delivery: state.delivery,
2994
+ disposition: "removed",
2995
+ reason,
2996
+ });
2997
+ }
2998
+ if (state.terminalSettled) return;
2999
+ state.terminalSettled = true;
3000
+ this.#clearTrackedQueuedInputQueueOwnership(state);
3001
+ state.terminal.resolve({
3002
+ submissionId: state.submission.submissionId,
3003
+ delivery: state.delivery,
3004
+ disposition: "removed",
3005
+ reason,
3006
+ });
3007
+ this.#trackedQueuedInputsAwaitingOwnRun.delete(state);
3008
+ this.#forgetTrackedQueuedInputOwnership(state);
3009
+ this.#trackedQueuedInputs.delete(state.submission.submissionId);
3010
+ }
3011
+
3012
+ #handleTrackedQueuedInputPromotion(
3013
+ state: TrackedQueuedInput,
3014
+ promotion: { startsOwnRun?: boolean; removed?: boolean },
3015
+ ): void {
3016
+ if (promotion.removed) {
3017
+ this.#settleTrackedQueuedInputRemoved(state, state.cancelRequested ? "cancelled" : "removed");
3018
+ return;
3019
+ }
3020
+ if (promotion.startsOwnRun === true) {
3021
+ this.#trackedQueuedInputsAwaitingOwnRun.add(state);
3022
+ return;
3023
+ }
3024
+ const scope = this.#activeAttemptScope;
3025
+ if (scope === undefined) {
3026
+ this.#settleTrackedQueuedInputRemoved(state, "removed");
3027
+ return;
3028
+ }
3029
+ this.#settleTrackedExecution(state, "joined-current-run", scope);
3030
+ }
3031
+
3032
+ #settleTrackedQueuedInputTerminal(scope: AttemptScope | undefined): void {
3033
+ if (scope === undefined) return;
3034
+ const logicalRunId = this.#logicalRunIdByAttemptScope.get(scope);
3035
+ const states =
3036
+ (logicalRunId === undefined ? undefined : this.#trackedQueuedInputsByLogicalRunId.get(logicalRunId)) ??
3037
+ this.#trackedQueuedInputsByAttemptScope.get(scope);
3038
+ if (states === undefined) return;
3039
+ for (const state of [...states]) {
3040
+ if (state.terminalSettled) continue;
3041
+ state.terminalSettled = true;
3042
+ this.#clearTrackedQueuedInputQueueOwnership(state);
3043
+ state.terminal.resolve({
3044
+ submissionId: state.submission.submissionId,
3045
+ delivery: state.delivery,
3046
+ disposition: "completed",
3047
+ attemptScope: this.#scopeRef(scope),
3048
+ });
3049
+ this.#forgetTrackedQueuedInputOwnership(state);
3050
+ this.#trackedQueuedInputs.delete(state.submission.submissionId);
3051
+ }
3052
+ }
3053
+
2600
3054
  /** Fire the stored promotion hook with a REMOVAL disposition for messages
2601
3055
  * leaving their queue without consumption (queue.message.remove, positional
2602
3056
  * editing, clearQueue, or the terminal-abort purge). The SDK terminalizes
2603
3057
  * those accepted submissions boundedly instead of leaving them accepted
2604
3058
  * forever (#4668 review P1). */
2605
3059
  #fireQueuedRemovalHooks(messages: readonly AgentMessage[]): void {
3060
+ let callbackError: unknown;
2606
3061
  for (const message of messages) {
2607
3062
  const steerHook = this.#steerPromotionHooks.get(message);
2608
3063
  if (steerHook) {
2609
3064
  this.#steerPromotionHooks.delete(message);
2610
- steerHook({ startsOwnRun: false, removed: true });
3065
+ try {
3066
+ steerHook({ startsOwnRun: false, removed: true });
3067
+ } catch (error) {
3068
+ callbackError ??= error;
3069
+ }
2611
3070
  }
2612
3071
  const followUpHook = this.#followUpPromotionHooks.get(message);
2613
3072
  if (followUpHook) {
2614
3073
  this.#followUpPromotionHooks.delete(message);
2615
- followUpHook({ startsOwnRun: true, removed: true });
3074
+ try {
3075
+ followUpHook({ startsOwnRun: true, removed: true });
3076
+ } catch (error) {
3077
+ callbackError ??= error;
3078
+ }
2616
3079
  }
2617
3080
  this.#sdkRunTokensByQueuedMessage.delete(message);
2618
3081
  this.#deepInterviewGenuineUserMessageEpochs.delete(message);
2619
3082
  }
3083
+ if (callbackError !== undefined) logger.warn("Queued removal callback failed", { error: callbackError });
2620
3084
  }
2621
3085
 
2622
3086
  /**
@@ -2737,22 +3201,28 @@ export class AgentSession {
2737
3201
  // follow-up queued after them, preserving submission order. Marking
2738
3202
  // each as sequential first, then restoring ahead of the existing queue,
2739
3203
  // keeps the existing follow-ups' identity and per-message marks intact.
2740
- let batch: AgentMessage[] = [];
2741
- const flushBatch = () => {
2742
- if (batch.length > 1) this.agent.markFollowUpBatch(batch);
2743
- batch = [];
2744
- };
2745
- for (const message of rearmed) {
2746
- if (this.#sequentialSteerMessages.has(message)) {
2747
- flushBatch();
2748
- this.agent.markFollowUpSequential(message);
2749
- } else if (this.agent.getSteeringMode() === "all") {
2750
- batch.push(message);
2751
- }
3204
+ this.#markSteeringAsFollowUpPolicy(rearmed);
3205
+ const deferredAlreadyPresent = this.#deferredSdkFollowUps.length > 0;
3206
+ if (deferredAlreadyPresent) {
3207
+ // Deferred follow-ups are an older cross-store FIFO head. Keep rearmed
3208
+ // steering in that store so it cannot block the head in Agent's queue or
3209
+ // be prepended ahead of it when this terminal reclassifies the queue.
3210
+ for (const message of rearmed) {
3211
+ if (this.#sequentialSteerMessages.has(message)) this.#deferredFollowUpForceOneAtATime.add(message);
3212
+ }
3213
+ const combined = [...this.#deferredSdkFollowUps, ...rearmed];
3214
+ combined.sort((left, right) => {
3215
+ const leftSequence = this.#queuedAdmissionSeq.get(left);
3216
+ const rightSequence = this.#queuedAdmissionSeq.get(right);
3217
+ if (leftSequence === undefined || rightSequence === undefined) return 0;
3218
+ return leftSequence - rightSequence;
3219
+ });
3220
+ this.#deferredSdkFollowUps = combined;
3221
+ this.#followUpMessages = [...this.#followUpMessages, ...rearmedDisplays];
3222
+ } else {
3223
+ this.agent.restoreFollowUp(rearmed);
3224
+ this.#followUpMessages = [...rearmedDisplays, ...this.#followUpMessages];
2752
3225
  }
2753
- flushBatch();
2754
- this.agent.restoreFollowUp(rearmed);
2755
- this.#followUpMessages = [...rearmedDisplays, ...this.#followUpMessages];
2756
3226
  for (const message of rearmed) {
2757
3227
  // External SDK steers keep their promotion hook: it now fires at the
2758
3228
  // follow-up promotion boundary with startsOwnRun: true.
@@ -2769,22 +3239,61 @@ export class AgentSession {
2769
3239
  // once the queue is drained.
2770
3240
  this.#scheduleQueuedDelivery();
2771
3241
  }
3242
+ #markSteeringAsFollowUpPolicy(messages: readonly AgentMessage[]): void {
3243
+ let batch: AgentMessage[] = [];
3244
+ const flushBatch = () => {
3245
+ if (batch.length > 1) {
3246
+ this.agent.markFollowUpBatch(batch);
3247
+ for (const message of batch) this.#deferredFollowUpBatches.set(message, batch.slice());
3248
+ }
3249
+ batch = [];
3250
+ };
3251
+ for (const message of messages) {
3252
+ if (this.#sequentialSteerMessages.has(message)) {
3253
+ flushBatch();
3254
+ this.agent.markFollowUpSequential(message);
3255
+ } else if (this.agent.getSteeringMode() === "all") {
3256
+ batch.push(message);
3257
+ }
3258
+ }
3259
+ flushBatch();
3260
+ this.#markTrackedFollowUpSequential(messages);
3261
+ }
3262
+ #markTrackedFollowUpSequential(messages: readonly AgentMessage[]): void {
3263
+ if (messages.length === 0 || this.#trackedQueuedInputs.size === 0) return;
3264
+ const trackedMessages = new Set(messages);
3265
+ for (const state of this.#trackedQueuedInputs.values()) {
3266
+ if (state.queuePolicy === "sequential" && state.message !== undefined && trackedMessages.has(state.message)) {
3267
+ this.agent.markFollowUpSequential(state.message);
3268
+ }
3269
+ }
3270
+ }
2772
3271
  #fireQueuedPromotionHooks(messages: readonly AgentMessage[], promotion?: { startsOwnRun?: boolean }): void {
3272
+ let callbackError: unknown;
2773
3273
  for (const message of messages) {
2774
3274
  const steerHook = this.#steerPromotionHooks.get(message);
2775
3275
  if (steerHook) {
2776
3276
  this.#steerPromotionHooks.delete(message);
2777
3277
  // A steer is consumed INSIDE the currently running turn: no new
2778
3278
  // agent_start follows for it (#4668 review).
2779
- steerHook({ startsOwnRun: promotion?.startsOwnRun ?? false });
3279
+ try {
3280
+ steerHook({ startsOwnRun: promotion?.startsOwnRun ?? false });
3281
+ } catch (error) {
3282
+ callbackError ??= error;
3283
+ }
2780
3284
  }
2781
3285
  const followUpHook = this.#followUpPromotionHooks.get(message);
2782
3286
  if (followUpHook) {
2783
3287
  this.#followUpPromotionHooks.delete(message);
2784
3288
  // A follow-up is promoted to its own run, whose agent_start follows.
2785
- followUpHook({ startsOwnRun: promotion?.startsOwnRun ?? true });
3289
+ try {
3290
+ followUpHook({ startsOwnRun: promotion?.startsOwnRun ?? true });
3291
+ } catch (error) {
3292
+ callbackError ??= error;
3293
+ }
2786
3294
  }
2787
3295
  }
3296
+ if (callbackError !== undefined) logger.warn("Queued promotion callback failed", { error: callbackError });
2788
3297
  }
2789
3298
  #queuedMessagesForSessionTransition(): AgentMessage[] {
2790
3299
  return [
@@ -2792,17 +3301,81 @@ export class AgentSession {
2792
3301
  ...this.agent.snapshotSteering(),
2793
3302
  ...this.agent.snapshotFollowUp(),
2794
3303
  ...this.#deferredSdkFollowUps,
3304
+ ...this.#pendingNextTurnMessages.map(entry => entry.message),
2795
3305
  ]),
2796
3306
  ];
2797
3307
  }
3308
+ /** Settle consumed tracked work before a maintenance path disconnects Agent events. */
3309
+ #settleTrackedQueuedInputsBeforeAgentDisconnect(): void {
3310
+ const queued = new Set([
3311
+ ...this.agent.snapshotSteering(),
3312
+ ...this.agent.snapshotFollowUp(),
3313
+ ...this.#deferredSdkFollowUps,
3314
+ ]);
3315
+ for (const state of [...this.#trackedQueuedInputs.values()]) {
3316
+ if (
3317
+ state.executionSettled ||
3318
+ this.#trackedQueuedInputsAwaitingOwnRun.has(state) ||
3319
+ (state.message !== undefined && !queued.has(state.message))
3320
+ ) {
3321
+ this.#settleTrackedQueuedInputRemoved(state, "removed");
3322
+ }
3323
+ }
3324
+ }
3325
+ #reconcileCompactionQueueDisplay(snapshot: readonly QueuedDisplayEntry[], mode: "steering" | "followUp"): void {
3326
+ const queued = mode === "steering" ? this.agent.snapshotSteering() : this.agent.snapshotFollowUp();
3327
+ const displayEntries = mode === "steering" ? this.#steeringMessages : this.#followUpMessages;
3328
+ const currentByMessage = new Map<AgentMessage, QueuedDisplayEntry>();
3329
+ for (const entry of displayEntries) {
3330
+ if (entry.message !== undefined && !currentByMessage.has(entry.message)) {
3331
+ currentByMessage.set(entry.message, entry);
3332
+ }
3333
+ }
3334
+ const snapshotByMessage = new Map<AgentMessage, QueuedDisplayEntry>();
3335
+ for (const entry of snapshot) {
3336
+ if (entry.message !== undefined && !snapshotByMessage.has(entry.message)) {
3337
+ snapshotByMessage.set(entry.message, entry);
3338
+ }
3339
+ }
3340
+ const selected = new Set<QueuedDisplayEntry>();
3341
+ const next: QueuedDisplayEntry[] = [];
3342
+ for (const message of queued) {
3343
+ const entry = currentByMessage.get(message) ?? snapshotByMessage.get(message);
3344
+ if (!entry || selected.has(entry)) continue;
3345
+ selected.add(entry);
3346
+ next.push(entry);
3347
+ }
3348
+ const queuedTags = new Set(
3349
+ queued.flatMap(message => {
3350
+ if (message.role !== "custom") return [];
3351
+ const tag = readPendingDisplayTag(message.details);
3352
+ return tag === undefined ? [] : [tag];
3353
+ }),
3354
+ );
3355
+ for (const entry of displayEntries) {
3356
+ if (entry.message !== undefined || selected.has(entry)) continue;
3357
+ if (entry.tag !== undefined && queuedTags.has(entry.tag)) {
3358
+ selected.add(entry);
3359
+ next.push(entry);
3360
+ }
3361
+ }
3362
+ if (mode === "steering") this.#steeringMessages = next;
3363
+ else this.#followUpMessages = next;
3364
+ }
2798
3365
  /** Drop queued SDK work when the session identity is replaced. The old
2799
3366
  * promotion hooks belong to the predecessor runtime; retaining the message
2800
3367
  * would let it execute later under the successor without an owner. */
2801
- #terminalizeQueuedSdkWorkForSessionTransition(messages: readonly AgentMessage[]): void {
2802
- if (messages.length === 0) return;
2803
- this.#fireQueuedRemovalHooks(messages);
2804
- for (const message of messages) this.#sdkRunTokensByQueuedMessage.delete(message);
2805
- this.#deferredSdkFollowUps = this.#deferredSdkFollowUps.filter(message => !messages.includes(message));
3368
+ #terminalizeQueuedSdkWorkForSessionTransition(
3369
+ messages: readonly AgentMessage[],
3370
+ predecessorStates: readonly TrackedQueuedInput[] = [...this.#trackedQueuedInputs.values()],
3371
+ ): void {
3372
+ if (messages.length > 0) {
3373
+ this.#fireQueuedRemovalHooks(messages);
3374
+ this.#settleDeliveredOwnedRegistrations(messages);
3375
+ for (const message of messages) this.#sdkRunTokensByQueuedMessage.delete(message);
3376
+ this.#deferredSdkFollowUps = this.#deferredSdkFollowUps.filter(message => !messages.includes(message));
3377
+ }
3378
+ for (const state of predecessorStates) this.#settleTrackedQueuedInputRemoved(state, "removed");
2806
3379
  }
2807
3380
  #resetActiveSdkRunOwnership(): void {
2808
3381
  this.#activeSdkRunToken = undefined;
@@ -2819,6 +3392,7 @@ export class AgentSession {
2819
3392
  predecessorScope !== undefined &&
2820
3393
  this.#skipPostPromptRecoveryWaitByAttemptScope.delete(predecessorScope);
2821
3394
  this.#acceptRunHandle(handle);
3395
+ this.#settleTrackedOwnRunPromotions(handle);
2822
3396
  if (sdkRunToken !== undefined) {
2823
3397
  this.#activeSdkRunToken = sdkRunToken;
2824
3398
  this.#sdkRunTokensByAttemptScope.set(handle.scope, sdkRunToken);
@@ -2865,6 +3439,7 @@ export class AgentSession {
2865
3439
  // Compaction state
2866
3440
  #compactionAbortController: AbortController | undefined = undefined;
2867
3441
  #compactionCompletion: Promise<void> | undefined;
3442
+ #compactionQueuedDeliveryArmed = false;
2868
3443
  #autoCompactionCompletions = new Set<Promise<AutoCompactionTerminalStatus>>();
2869
3444
  #autoCompactionAbortController: AbortController | undefined = undefined;
2870
3445
 
@@ -2938,6 +3513,7 @@ export class AgentSession {
2938
3513
  #retryPromise: Promise<void> | undefined = undefined;
2939
3514
  #retryResolve: (() => void) | undefined = undefined;
2940
3515
  #defaultFallbackController: FallbackChainController | undefined;
3516
+ #startupRecoveryBindingsRequired = false;
2941
3517
  #fallbackTransitionGeneration = 0;
2942
3518
  /** Managed escaped-non-ASCII retries issued for the current logical run. Bounded so a deterministic escaper cannot loop forever through un-charged fallback retries. */
2943
3519
  #escapedNonAsciiManagedRetries = 0;
@@ -3119,6 +3695,7 @@ export class AgentSession {
3119
3695
  #disposeCallerPromise: Promise<void> | undefined;
3120
3696
  #disposeCompleted = false;
3121
3697
  #disposeTerminalError: unknown;
3698
+ #sessionShutdownReason: "detached_idle" | undefined;
3122
3699
  readonly #disposeAbortController = new AbortController();
3123
3700
  #disposeAdmissionClosed: Promise<void> | undefined;
3124
3701
  #disposePostPromptDrain: Promise<void> | undefined;
@@ -3143,12 +3720,14 @@ export class AgentSession {
3143
3720
  #attemptAuthority!: AttemptScopeAuthority;
3144
3721
  #attemptRecordStore!: AttemptRecordStore;
3145
3722
  #activeLogicalRunId: AttemptRunHandle["logicalRunId"] | undefined;
3723
+ readonly #logicalRunIdByAttemptScope = new WeakMap<AttemptScope, AttemptRunHandle["logicalRunId"]>();
3146
3724
  #acceptRunHandle(handle: AttemptRunHandle | undefined): void {
3147
3725
  // Integration doubles can accept a run without minting a handle; keep the
3148
3726
  // previously recorded run id rather than throwing inside the callback.
3149
3727
  if (!handle) return;
3150
3728
  this.#activeLogicalRunId = handle.logicalRunId;
3151
3729
  this.#activeAttemptScope = handle.scope;
3730
+ this.#logicalRunIdByAttemptScope.set(handle.scope, handle.logicalRunId);
3152
3731
  }
3153
3732
 
3154
3733
  #turnIndex = 0;
@@ -3231,6 +3810,7 @@ export class AgentSession {
3231
3810
  #discoveryMode: "off" | "mcp-only" | "all" = "off";
3232
3811
  #discoverableMCPTools = new Map<string, DiscoverableTool>();
3233
3812
  #selectedMCPToolNames = new Set<string>();
3813
+ #exactMcpSuspendedTools = new Map<string, { selected: string[]; active: string[] }>();
3234
3814
  // Generic tool discovery (covers built-in + MCP + extension when tools.discoveryMode === "all")
3235
3815
  #discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
3236
3816
  #selectedDiscoveredToolNames = new Set<string>();
@@ -3305,6 +3885,9 @@ export class AgentSession {
3305
3885
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
3306
3886
  #postPromptTasksResolve: (() => void) | undefined = undefined;
3307
3887
  #postPromptTasksAbortController = new AbortController();
3888
+ readonly #sessionWorkLease: SessionWorkLease = createSessionWorkLease();
3889
+ #postPromptWorkLeases = new Map<Promise<void>, SessionWorkLeaseHandle>();
3890
+ #deferredAgentEndWorkLeases = new Map<symbol, SessionWorkLeaseHandle>();
3308
3891
  #runCancellationDomains = new SessionRunCancellationDomainBridge();
3309
3892
  #postPromptLeases = new Map<string, RunResourceProducerLease>();
3310
3893
  #runResourceLeaseContext = new AsyncLocalStorage<RunResourceProducerLease>();
@@ -3463,6 +4046,33 @@ export class AgentSession {
3463
4046
  }
3464
4047
  }
3465
4048
 
4049
+ /**
4050
+ * Reject public work that would enter a session while its identity or
4051
+ * delivery owner is being changed. The only exception is a tracked
4052
+ * submitUserMessage issued by the committed successor hook; that capability
4053
+ * is checked by the caller and is intentionally narrower than the general
4054
+ * successor-hook context.
4055
+ */
4056
+ #assertExternalSessionIngress(options?: { allowTrackedSuccessor?: boolean; allowCancelAndSubmit?: boolean }): void {
4057
+ this.#assertSessionAdmissionOpen();
4058
+ if (this.#cancelAndSubmitInProgress && options?.allowCancelAndSubmit !== true) {
4059
+ throw Object.assign(new AgentBusyError("Cannot submit work while cancel-and-submit is in progress."), {
4060
+ code: "busy",
4061
+ });
4062
+ }
4063
+ if (
4064
+ (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) &&
4065
+ !(options?.allowTrackedSuccessor && this.#hasCommittedSuccessorAdmission())
4066
+ ) {
4067
+ throw Object.assign(
4068
+ new AgentBusyError(
4069
+ `Cannot submit work while ${this.#sessionTransitionKind ?? "a handoff"} is in progress.`,
4070
+ ),
4071
+ { code: "busy" },
4072
+ );
4073
+ }
4074
+ }
4075
+
3466
4076
  /**
3467
4077
  * Reject a turn start while a handoff transition owns the session. Handoff never
3468
4078
  * routes its own generation/injection through these turn-start chokepoints, and
@@ -3480,14 +4090,17 @@ export class AgentSession {
3480
4090
 
3481
4091
  /**
3482
4092
  * Single, synchronously-acquired mutex for session-identity transitions
3483
- * (handoff, compact, new/switch/branch/clear, fork, tree navigation). Acquired
4093
+ * (handoff, manual/automatic compact, new/switch/branch/clear, fork, tree navigation). Acquired
3484
4094
  * BEFORE any await at each transition's entry and released in its finally, so
3485
4095
  * exclusion is symmetric regardless of which transition starts first — an
3486
4096
  * operation that began earlier and yielded still owns the lease when a peer
3487
- * tries to start. Auto-handoff acquires it via handoff() (the maintenance
3488
- * orchestrator does not hold it), so there is no self-deadlock.
4097
+ * tries to start. Auto-handoff acquires it via handoff() while automatic
4098
+ * context-full maintenance acquires it in its own flow, so neither path
4099
+ * exposes an unowned history rewrite.
3489
4100
  */
3490
4101
  #sessionTransitionKind: string | undefined;
4102
+ #queuedDeliveryPendingWhileTransition = false;
4103
+ #deferredAutoContinueDuringTransition: (() => void) | undefined;
3491
4104
  #coordinatorPersistGeneration = 0;
3492
4105
  #coordinatorRescopeBarrier: Promise<void> | undefined;
3493
4106
  #releaseCoordinatorRescopeBarrier: (() => void) | undefined;
@@ -3550,18 +4163,79 @@ export class AgentSession {
3550
4163
  }
3551
4164
 
3552
4165
  #beginSessionTransition(kind: string): void {
4166
+ if (this.#cancelAndSubmitInProgress) {
4167
+ throw Object.assign(new AgentBusyError(`Cannot start ${kind} during cancel-and-submit.`), { code: "busy" });
4168
+ }
3553
4169
  if (this.#sessionTransitionKind !== undefined) {
3554
4170
  throw Object.assign(
3555
- new Error(`Cannot start ${kind} while a ${this.#sessionTransitionKind} transition is in progress.`),
4171
+ new AgentBusyError(
4172
+ `Cannot start ${kind} while a ${this.#sessionTransitionKind} transition is in progress.`,
4173
+ ),
3556
4174
  { code: "busy" },
3557
4175
  );
3558
4176
  }
3559
4177
  this.#sessionTransitionKind = kind;
3560
4178
  this.#coordinatorPersistGeneration += 1;
4179
+ this.#wakeFollowUpReservationTransitionWaiters();
4180
+ }
4181
+
4182
+ async #settleActivePromptForHistoryTransition(): Promise<void> {
4183
+ if (this.#livePromptsInFlight() > 0 || this.agent.state.isStreaming) {
4184
+ await this.abort({ cause: "session_switch" });
4185
+ }
4186
+ const activeAdmission = this.#activeSessionAdmission;
4187
+ if (activeAdmission?.kind === "prompt") {
4188
+ if (this.#livePromptsInFlight() === 0 && !this.agent.state.isStreaming)
4189
+ this.cancelPendingPreflightForTerminalAbort();
4190
+ await activeAdmission.settled.promise;
4191
+ }
4192
+ }
4193
+
4194
+ #wakeFollowUpReservationTransitionWaiters(): void {
4195
+ const waiters = [...this.#followUpReservationTransitionWaiters];
4196
+ this.#followUpReservationTransitionWaiters.clear();
4197
+ for (const waiter of waiters) waiter();
3561
4198
  }
3562
4199
 
3563
4200
  #endSessionTransition(): void {
3564
4201
  this.#sessionTransitionKind = undefined;
4202
+ const deferredAutoContinue = this.#deferredAutoContinueDuringTransition;
4203
+ this.#deferredAutoContinueDuringTransition = undefined;
4204
+ if (!this.#isDisposed && !this.#sessionAdmissionClosing) deferredAutoContinue?.();
4205
+ if (this.#queuedDeliveryPendingWhileTransition) {
4206
+ this.#queuedDeliveryPendingWhileTransition = false;
4207
+ if (!this.#isDisposed && !this.#sessionAdmissionClosing && !this.#cancelAndSubmitInProgress) {
4208
+ this.#scheduleQueuedDelivery();
4209
+ }
4210
+ }
4211
+ }
4212
+
4213
+ #runCommittedSuccessorHook<T>(body: () => Promise<T>): Promise<T> {
4214
+ const admission: SuccessorSessionAdmission = {
4215
+ generation: this.#coordinatorPersistGeneration,
4216
+ sessionId: this.sessionId,
4217
+ capability: Symbol("committed-successor-session-hook"),
4218
+ active: true,
4219
+ };
4220
+ return this.#successorSessionAdmissionContext.run(admission, async () => {
4221
+ try {
4222
+ return await body();
4223
+ } finally {
4224
+ admission.active = false;
4225
+ }
4226
+ });
4227
+ }
4228
+
4229
+ #hasCommittedSuccessorAdmission(): boolean {
4230
+ const admission = this.#successorSessionAdmissionContext.getStore();
4231
+ return (
4232
+ admission?.active === true &&
4233
+ admission.generation === this.#coordinatorPersistGeneration &&
4234
+ admission.sessionId === this.sessionId
4235
+ );
4236
+ }
4237
+ #hasCommittedSuccessorTrackedAdmission(options?: SendUserMessageDispatchOptions): boolean {
4238
+ return options?.trackSubmission === true && this.#hasCommittedSuccessorAdmission();
3565
4239
  }
3566
4240
 
3567
4241
  #activateNextSessionAdmission(): void {
@@ -3722,6 +4396,12 @@ export class AgentSession {
3722
4396
  code: "busy",
3723
4397
  });
3724
4398
  }
4399
+ if (kind === "prompt" && this.#sessionTransitionKind !== undefined) {
4400
+ throw Object.assign(
4401
+ new AgentBusyError(`Cannot start a turn while ${this.#sessionTransitionKind} is in progress.`),
4402
+ { code: "busy" },
4403
+ );
4404
+ }
3725
4405
 
3726
4406
  const entry: SessionAdmissionEntry = {
3727
4407
  kind,
@@ -3765,6 +4445,12 @@ export class AgentSession {
3765
4445
  code: "busy",
3766
4446
  });
3767
4447
  }
4448
+ if (kind === "prompt" && this.#sessionTransitionKind !== undefined) {
4449
+ throw Object.assign(
4450
+ new AgentBusyError(`Cannot start a turn while ${this.#sessionTransitionKind} is in progress.`),
4451
+ { code: "busy" },
4452
+ );
4453
+ }
3768
4454
 
3769
4455
  const release = () => {
3770
4456
  releaseEntry();
@@ -3932,6 +4618,7 @@ export class AgentSession {
3932
4618
  if (!pending) return undefined;
3933
4619
  const hold = Symbol("deferred-agent-end-continuation");
3934
4620
  this.#pendingAgentEndContinuationHolds.set(hold, pending);
4621
+ this.#deferredAgentEndWorkLeases.set(hold, this.#sessionWorkLease.acquire());
3935
4622
  return hold;
3936
4623
  }
3937
4624
 
@@ -3943,6 +4630,7 @@ export class AgentSession {
3943
4630
  this.#pendingAgentEndEmit = pending;
3944
4631
  const hold = Symbol("deferred-agent-end-continuation");
3945
4632
  this.#pendingAgentEndContinuationHolds.set(hold, pending);
4633
+ this.#deferredAgentEndWorkLeases.set(hold, this.#sessionWorkLease.acquire());
3946
4634
  return hold;
3947
4635
  }
3948
4636
 
@@ -3950,10 +4638,16 @@ export class AgentSession {
3950
4638
  if (!hold) return undefined;
3951
4639
  const pending = this.#pendingAgentEndContinuationHolds.get(hold);
3952
4640
  this.#pendingAgentEndContinuationHolds.delete(hold);
4641
+ this.#deferredAgentEndWorkLeases.get(hold)?.release();
4642
+ this.#deferredAgentEndWorkLeases.delete(hold);
3953
4643
  if (pending && this.#pendingAgentEndEmit === pending) {
3954
4644
  this.#pendingAgentEndEmit = undefined;
3955
4645
  for (const [candidate, candidatePending] of this.#pendingAgentEndContinuationHolds) {
3956
- if (candidatePending === pending) this.#pendingAgentEndContinuationHolds.delete(candidate);
4646
+ if (candidatePending === pending) {
4647
+ this.#pendingAgentEndContinuationHolds.delete(candidate);
4648
+ this.#deferredAgentEndWorkLeases.get(candidate)?.release();
4649
+ this.#deferredAgentEndWorkLeases.delete(candidate);
4650
+ }
3957
4651
  }
3958
4652
  }
3959
4653
  this.#resolveSessionSettlement();
@@ -3980,6 +4674,8 @@ export class AgentSession {
3980
4674
  if (!hold) return;
3981
4675
  const pending = this.#pendingAgentEndContinuationHolds.get(hold);
3982
4676
  this.#pendingAgentEndContinuationHolds.delete(hold);
4677
+ this.#deferredAgentEndWorkLeases.get(hold)?.release();
4678
+ this.#deferredAgentEndWorkLeases.delete(hold);
3983
4679
  this.#restoreDeferredAgentEndAfterContinuationFailure(pending);
3984
4680
  }
3985
4681
 
@@ -3990,6 +4686,8 @@ export class AgentSession {
3990
4686
  break;
3991
4687
  }
3992
4688
  this.#pendingAgentEndContinuationHolds.clear();
4689
+ for (const lease of this.#deferredAgentEndWorkLeases.values()) lease.release();
4690
+ this.#deferredAgentEndWorkLeases.clear();
3993
4691
  this.#restoreDeferredAgentEndAfterContinuationFailure(pending);
3994
4692
  }
3995
4693
 
@@ -4023,9 +4721,11 @@ export class AgentSession {
4023
4721
  fenceGeneration,
4024
4722
  (this.#selectionFenceDeferredContinuations.get(fenceGeneration) ?? 0) + 1,
4025
4723
  );
4724
+ const workLease = this.#sessionWorkLease.acquire();
4026
4725
  void deferred
4027
4726
  .catch(() => {})
4028
4727
  .finally(() => {
4728
+ workLease.release();
4029
4729
  this.#endSelectionFenceDeferralTracking(fenceGeneration);
4030
4730
  });
4031
4731
  }
@@ -4087,12 +4787,46 @@ export class AgentSession {
4087
4787
  ownEpoch: number,
4088
4788
  requesterSignal?: AbortSignal,
4089
4789
  admissionSignal?: AbortSignal,
4790
+ transitionGeneration = this.#coordinatorPersistGeneration,
4090
4791
  ): Promise<void> {
4091
4792
  while ([...this.#activeFollowUpReservationEpochs].some(epoch => epoch < ownEpoch)) {
4092
4793
  if (requesterSignal?.aborted || admissionSignal?.aborted) throw promptPreflightCancelledError();
4794
+ if (this.#coordinatorPersistGeneration !== transitionGeneration) {
4795
+ throw Object.assign(
4796
+ new AgentBusyError("Cannot submit queued input while a session state transition is in progress."),
4797
+ {
4798
+ code: "busy",
4799
+ },
4800
+ );
4801
+ }
4093
4802
  const drained = Promise.withResolvers<void>();
4094
4803
  this.#followUpReservationDrainWaiters.add(drained.resolve);
4095
- await drained.promise;
4804
+ const abort = Promise.withResolvers<void>();
4805
+ const transition = Promise.withResolvers<void>();
4806
+ const onAbort = () => abort.resolve();
4807
+ requesterSignal?.addEventListener("abort", onAbort, { once: true });
4808
+ admissionSignal?.addEventListener("abort", onAbort, { once: true });
4809
+ this.#followUpReservationTransitionWaiters.add(transition.resolve);
4810
+ // The transition/abort signal can change between the checks above and
4811
+ // registering these waiters. Re-check after registration so a wake that
4812
+ // happened in that gap cannot leave this reservation parked forever.
4813
+ if (requesterSignal?.aborted || admissionSignal?.aborted) abort.resolve();
4814
+ if (this.#coordinatorPersistGeneration !== transitionGeneration) transition.resolve();
4815
+ try {
4816
+ await Promise.race([drained.promise, abort.promise, transition.promise]);
4817
+ if (requesterSignal?.aborted || admissionSignal?.aborted) throw promptPreflightCancelledError();
4818
+ if (this.#coordinatorPersistGeneration !== transitionGeneration) {
4819
+ throw Object.assign(
4820
+ new AgentBusyError("Cannot submit queued input while a session state transition is in progress."),
4821
+ { code: "busy" },
4822
+ );
4823
+ }
4824
+ } finally {
4825
+ this.#followUpReservationDrainWaiters.delete(drained.resolve);
4826
+ this.#followUpReservationTransitionWaiters.delete(transition.resolve);
4827
+ requesterSignal?.removeEventListener("abort", onAbort);
4828
+ admissionSignal?.removeEventListener("abort", onAbort);
4829
+ }
4096
4830
  }
4097
4831
  }
4098
4832
 
@@ -4346,6 +5080,7 @@ export class AgentSession {
4346
5080
  // preserves transaction order; terminal publication is the user-visible
4347
5081
  // authority and must not be suppressed by a secondary persistence failure.
4348
5082
  const terminalPersistence = this.#queueCoordinatorRuntimeStatePersist(pending, true);
5083
+ this.#settleTrackedQueuedInputTerminal(publicationScope);
4349
5084
  this.#emit(pending);
4350
5085
  void terminalPersistence.then(
4351
5086
  () => {
@@ -4690,7 +5425,8 @@ export class AgentSession {
4690
5425
  this.#setGuardedAgentTools(this.agent.state.tools);
4691
5426
  this.#bindWorkflowGateEmitter();
4692
5427
  this.yieldQueue = new YieldQueue({
4693
- isStreaming: () => this.isStreaming || this.#handoffTransitionActive,
5428
+ isStreaming: () =>
5429
+ this.isStreaming || this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive,
4694
5430
  injectStreaming: message => {
4695
5431
  // Mandated boundary comment (corrected turn semantics): turn-scope
4696
5432
  // abort blocks only deliveries whose origin is a continuation of the
@@ -4719,7 +5455,19 @@ export class AgentSession {
4719
5455
  // #promptWithMessage (resetRetryReplaySafety), which allocates the
4720
5456
  // fresh attempt epoch at turn start. The idle injector, which calls
4721
5457
  // agent.prompt directly, allocates right before admission.
4722
- this.agent.followUp(message);
5458
+ const displayText =
5459
+ message.role === "custom"
5460
+ ? this.#getCustomMessageTextContent(message)
5461
+ : message.role === "user"
5462
+ ? this.#getUserMessageText(message)
5463
+ : "";
5464
+ void this.#queueFollowUpAfterReservation(message, displayText, {
5465
+ createDisplayEntry: false,
5466
+ trackExternalFollowUp: false,
5467
+ }).catch(error => {
5468
+ this.#settleDeliveredOwnedRegistrations([message]);
5469
+ logger.warn("Owned streaming follow-up was rejected", { error: String(error) });
5470
+ });
4723
5471
  },
4724
5472
  injectIdle: async (messages, signal) => {
4725
5473
  // Mandated boundary comment (corrected turn semantics): same origin
@@ -5193,8 +5941,13 @@ export class AgentSession {
5193
5941
  if (next && MCPManager.instance() === undefined) MCPManager.setInstance(next);
5194
5942
  }
5195
5943
 
5196
- /** Swap named custom/project tools after a cwd rescope. */
5197
- async replaceNamedCustomTools(previousNames: readonly string[], nextTools: CustomTool[]): Promise<void> {
5944
+ /** Swap named custom tools and their optional mandatory MCP selection. */
5945
+ async replaceNamedCustomTools(
5946
+ previousNames: readonly string[],
5947
+ nextTools: readonly CustomTool[],
5948
+ options?: { mandatoryMCPToolNames?: readonly string[]; activateNewTools?: boolean },
5949
+ ): Promise<void> {
5950
+ const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
5198
5951
  const previous = new Set(previousNames);
5199
5952
  const previousActive = this.getActiveToolNames();
5200
5953
  for (const name of previous) this.#toolRegistry.delete(name);
@@ -5208,11 +5961,21 @@ export class AgentSession {
5208
5961
  this.#toolRegistry.set(finalTool.name, finalTool);
5209
5962
  added.push(finalTool.name);
5210
5963
  }
5211
- this.#invalidateDiscoveryCaches();
5212
- await this.#applyActiveToolsByName([
5213
- ...previousActive.filter(name => !previous.has(name)),
5214
- ...added.filter(name => !previous.has(name) || previousActive.includes(name)),
5215
- ]);
5964
+ if (options?.mandatoryMCPToolNames) {
5965
+ this.#mandatoryMCPToolNames = new Set(
5966
+ options.mandatoryMCPToolNames.map(name => name.toLowerCase()).filter(name => this.#toolRegistry.has(name)),
5967
+ );
5968
+ }
5969
+ this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
5970
+ await this.#applyActiveToolsByName(
5971
+ [
5972
+ ...previousActive.filter(name => !previous.has(name)),
5973
+ ...added.filter(
5974
+ name => previousActive.includes(name) || (options?.activateNewTools !== false && !previous.has(name)),
5975
+ ),
5976
+ ],
5977
+ { previousSelectedMCPToolNames },
5978
+ );
5216
5979
  }
5217
5980
 
5218
5981
  /** Best-effort accessor for the active skill's `current_phase` field from
@@ -5673,6 +6436,7 @@ export class AgentSession {
5673
6436
  * Does NOT push to the agent's steering/followUp queue — that happens
5674
6437
  * separately inside `sendCustomMessage`. */
5675
6438
  enqueueCustomMessageDisplay(text: string, mode: "steer" | "followUp"): string {
6439
+ this.#assertExternalSessionIngress();
5676
6440
  const tag = `gjc-cmd-${Date.now()}-${++this.#customDisplayTagCounter}`;
5677
6441
  const displayText = text.trim();
5678
6442
  if (!displayText) return tag;
@@ -6011,6 +6775,10 @@ export class AgentSession {
6011
6775
  }
6012
6776
 
6013
6777
  #trackAgentEvent = (event: AgentEvent): Promise<void> => {
6778
+ const eventScope = (event as AgentEvent & { scope?: AttemptScope }).scope;
6779
+ if ((event.type === "agent_start" || event.type === "turn_start") && eventScope !== undefined) {
6780
+ this.#bindAttemptScopeToActiveRun(eventScope);
6781
+ }
6014
6782
  this.#agentEventAdmission.set(event, {
6015
6783
  scope: this.#activeAttemptScope,
6016
6784
  sdkRunToken: this.#activeSdkRunToken,
@@ -6369,6 +7137,7 @@ export class AgentSession {
6369
7137
  // re-enter prompt(), so a successor's running transition serializes after it.
6370
7138
  // Do not let a lock-hostile sidecar suppress the terminal event itself.
6371
7139
  const terminalPersistence = this.#queueCoordinatorRuntimeStatePersist(event);
7140
+ this.#settleTrackedQueuedInputTerminal(attemptScope);
6372
7141
  this.#emit(event);
6373
7142
  void terminalPersistence;
6374
7143
  await this.#emitExtensionEvent(event);
@@ -6556,16 +7325,23 @@ export class AgentSession {
6556
7325
  if (userDisplayDequeueAlreadyHandled) this.#displayDequeueAlreadyHandled = undefined;
6557
7326
  if (event.type === "message_start" && event.message.role === "user" && !userDisplayDequeueAlreadyHandled) {
6558
7327
  const messageText = userMessageText;
6559
- if (messageText) {
6560
- // Check steering queue first (match by .text on tagged records)
6561
- const steeringIndex = this.#steeringMessages.findIndex(e => e.text === messageText);
6562
- if (steeringIndex !== -1) {
6563
- this.#steeringMessages.splice(steeringIndex, 1);
6564
- } else {
6565
- // Check follow-up queue
6566
- const followUpIndex = this.#followUpMessages.findIndex(e => e.text === messageText);
6567
- if (followUpIndex !== -1) {
6568
- this.#followUpMessages.splice(followUpIndex, 1);
7328
+ const boundIndex = this.#steeringMessages.findIndex(entry => entry.message === event.message);
7329
+ if (boundIndex !== -1) {
7330
+ this.#steeringMessages.splice(boundIndex, 1);
7331
+ } else {
7332
+ const followUpBoundIndex = this.#followUpMessages.findIndex(entry => entry.message === event.message);
7333
+ if (followUpBoundIndex !== -1) this.#followUpMessages.splice(followUpBoundIndex, 1);
7334
+ else if (messageText) {
7335
+ // Legacy display rows without a bound executable message retain text fallback.
7336
+ const steeringIndex = this.#steeringMessages.findIndex(
7337
+ entry => entry.message === undefined && entry.text === messageText,
7338
+ );
7339
+ if (steeringIndex !== -1) this.#steeringMessages.splice(steeringIndex, 1);
7340
+ else {
7341
+ const followUpIndex = this.#followUpMessages.findIndex(
7342
+ entry => entry.message === undefined && entry.text === messageText,
7343
+ );
7344
+ if (followUpIndex !== -1) this.#followUpMessages.splice(followUpIndex, 1);
6569
7345
  }
6570
7346
  }
6571
7347
  }
@@ -6701,7 +7477,7 @@ export class AgentSession {
6701
7477
  text: [
6702
7478
  "Session transcript reached the managed per-file limit; this result could not be recorded durably.",
6703
7479
  committed,
6704
- "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.",
7480
+ `To continue, ${SESSION_LIMIT_RECOVERY_ACTIONS}; re-verify the edited file before relying on it.`,
6705
7481
  ].join("\n"),
6706
7482
  },
6707
7483
  ];
@@ -7138,6 +7914,10 @@ export class AgentSession {
7138
7914
  !(event.stopReason === "maintenance" && isContinuingMidRunMaintenanceOutcome(event.maintenanceOutcome))
7139
7915
  ) {
7140
7916
  this.#releaseDeferredSdkFollowUps();
7917
+ if (this.#compactionQueuedDeliveryArmed) {
7918
+ if (this.agent.hasQueuedMessages()) this.#scheduleQueuedDelivery();
7919
+ else this.#compactionQueuedDeliveryArmed = false;
7920
+ }
7141
7921
  }
7142
7922
 
7143
7923
  // Check auto-retry and auto-compaction after agent completes
@@ -7333,6 +8113,31 @@ export class AgentSession {
7333
8113
  this.#ttsrResumePromise = undefined;
7334
8114
  }
7335
8115
 
8116
+ #dropQueuedTtsrFollowUps(): void {
8117
+ const deferredTtsr = new Set(
8118
+ this.#deferredSdkFollowUps.filter(
8119
+ message => message.role === "custom" && message.customType === "ttsr-injection",
8120
+ ),
8121
+ );
8122
+ const queuedTtsr = [
8123
+ ...this.agent
8124
+ .snapshotFollowUp()
8125
+ .filter(message => message.role === "custom" && message.customType === "ttsr-injection"),
8126
+ ...deferredTtsr,
8127
+ ];
8128
+ if (queuedTtsr.length === 0) return;
8129
+ this.agent.removeQueuedMessages(
8130
+ message => message.role === "custom" && message.customType === "ttsr-injection",
8131
+ "followUp",
8132
+ );
8133
+ this.#deferredSdkFollowUps = this.#deferredSdkFollowUps.filter(message => !deferredTtsr.has(message));
8134
+ for (const message of deferredTtsr) this.#deferredFollowUpForceOneAtATime.delete(message);
8135
+ this.#followUpMessages = this.#followUpMessages.filter(
8136
+ entry => entry.message === undefined || !queuedTtsr.includes(entry.message),
8137
+ );
8138
+ this.#resolveTtsrResume();
8139
+ }
8140
+
7336
8141
  #ensurePostPromptTasksPromise(): void {
7337
8142
  if (this.#postPromptTasksPromise) return;
7338
8143
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -7355,6 +8160,7 @@ export class AgentSession {
7355
8160
  excludeFromRecovery = false,
7356
8161
  ): void {
7357
8162
  this.#postPromptTasks.add(task);
8163
+ this.#postPromptWorkLeases.set(task, this.#sessionWorkLease.acquire());
7358
8164
  this.#postPromptTaskSelectionFenceGenerations.set(task, selectionFenceGeneration);
7359
8165
  if (excludeFromRecovery) this.#postPromptTaskRecoveryExcluded.add(task);
7360
8166
  this.#ensurePostPromptTasksPromise();
@@ -7365,6 +8171,8 @@ export class AgentSession {
7365
8171
  void task
7366
8172
  .catch(() => {})
7367
8173
  .finally(() => {
8174
+ this.#postPromptWorkLeases.get(task)?.release();
8175
+ this.#postPromptWorkLeases.delete(task);
7368
8176
  this.#postPromptTasks.delete(task);
7369
8177
  this.#postPromptTaskSelectionFenceGenerations.delete(task);
7370
8178
  this.#postPromptTaskRecoveryExcluded.delete(task);
@@ -7564,6 +8372,12 @@ export class AgentSession {
7564
8372
  skip("queue_drained");
7565
8373
  return false;
7566
8374
  }
8375
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) {
8376
+ if (this.agent.hasQueuedMessages() || this.#deferredSdkFollowUps.length > 0)
8377
+ this.#queuedDeliveryPendingWhileTransition = true;
8378
+ skip("handoff_in_progress");
8379
+ return false;
8380
+ }
7567
8381
  if (options?.shouldContinue && !options.shouldContinue()) {
7568
8382
  skip("queue_drained");
7569
8383
  return false;
@@ -7703,8 +8517,10 @@ export class AgentSession {
7703
8517
  error: error.message,
7704
8518
  });
7705
8519
  }
7706
- if (!predecessorAccepted)
8520
+ if (!predecessorAccepted) {
8521
+ this.#settleTrackedOwnRunPromotionFailures();
7707
8522
  this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
8523
+ }
7708
8524
  throw error;
7709
8525
  }
7710
8526
  } catch (error) {
@@ -7823,6 +8639,18 @@ export class AgentSession {
7823
8639
  deferredPredecessorAgentEnd?: AgentSessionEvent,
7824
8640
  sdkRunToken?: string,
7825
8641
  ): void {
8642
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) {
8643
+ this.#deferredAutoContinueDuringTransition = () =>
8644
+ this.#scheduleAutoContinuePrompt(
8645
+ generation,
8646
+ requireUnfinishedWork,
8647
+ resourceRunId,
8648
+ deferredSelectionFenceGeneration,
8649
+ deferredPredecessorAgentEnd,
8650
+ sdkRunToken,
8651
+ );
8652
+ return;
8653
+ }
7826
8654
  const scheduledGeneration = generation;
7827
8655
  const scheduledSdkRunToken =
7828
8656
  sdkRunToken ??
@@ -7843,6 +8671,10 @@ export class AgentSession {
7843
8671
  );
7844
8672
  return false;
7845
8673
  }
8674
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) {
8675
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "session_transition");
8676
+ return false;
8677
+ }
7846
8678
  // A same-turn auto-continue of a terminally aborted turn is denied
7847
8679
  // (corrected turn semantics); owned-completion deliveries are not
7848
8680
  // affected — they flow through followUp as fresh turns.
@@ -7984,6 +8816,8 @@ export class AgentSession {
7984
8816
  #abandonPostPromptTasks(): void {
7985
8817
  this.#postPromptTasksAbortController.abort();
7986
8818
  this.#postPromptTasksAbortController = new AbortController();
8819
+ for (const lease of this.#postPromptWorkLeases.values()) lease.release();
8820
+ this.#postPromptWorkLeases.clear();
7987
8821
  this.#postPromptTasks.clear();
7988
8822
  this.#postPromptTaskSelectionFenceGenerations.clear();
7989
8823
  this.#postPromptTaskRecoveryExcluded.clear();
@@ -8186,7 +9020,9 @@ export class AgentSession {
8186
9020
  if (!injection) {
8187
9021
  return;
8188
9022
  }
8189
- this.agent.followUp({
9023
+ const ttsrAbortEpoch = this.#abortEpoch;
9024
+ const ttsrAbortSignal = this.#promptPreflightAbortController.signal;
9025
+ const message: CustomMessage = {
8190
9026
  role: "custom",
8191
9027
  customType: "ttsr-injection",
8192
9028
  content: injection.content,
@@ -8194,27 +9030,46 @@ export class AgentSession {
8194
9030
  details: { rules: injection.rules.map(rule => rule.name) },
8195
9031
  attribution: "agent",
8196
9032
  timestamp: Date.now(),
8197
- });
9033
+ };
8198
9034
  this.#ensureTtsrResumePromise();
8199
- // Mark as injected after this custom message is delivered and persisted (handled in message_end).
8200
- // followUp() only enqueues; resume on the next tick once streaming settles.
8201
- this.#scheduleAgentContinue({
8202
- delayMs: 1,
8203
- generation: this.#promptGeneration,
8204
- onSkip: () => {
8205
- this.#resolveTtsrResume();
9035
+ void this.#queueFollowUpAfterReservation(
9036
+ message,
9037
+ this.#getCustomMessageTextContent(message),
9038
+ {
9039
+ createDisplayEntry: false,
9040
+ trackExternalFollowUp: false,
8206
9041
  },
8207
- shouldContinue: () => {
8208
- if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
9042
+ ttsrAbortSignal,
9043
+ )
9044
+ .then(() => {
9045
+ if (this.#abortEpoch !== ttsrAbortEpoch || ttsrAbortSignal.aborted) {
8209
9046
  this.#resolveTtsrResume();
8210
- return false;
9047
+ return;
8211
9048
  }
8212
- return true;
8213
- },
8214
- onError: () => {
9049
+ // Mark as injected after this custom message is delivered and persisted (handled in message_end).
9050
+ // followUp() only enqueues; resume on the next tick once streaming settles.
9051
+ this.#scheduleAgentContinue({
9052
+ delayMs: 1,
9053
+ generation: this.#promptGeneration,
9054
+ onSkip: () => {
9055
+ this.#resolveTtsrResume();
9056
+ },
9057
+ shouldContinue: () => {
9058
+ if (this.agent.state.isStreaming || !this.agent.hasQueuedMessages()) {
9059
+ this.#resolveTtsrResume();
9060
+ return false;
9061
+ }
9062
+ return true;
9063
+ },
9064
+ onError: () => {
9065
+ this.#resolveTtsrResume();
9066
+ },
9067
+ });
9068
+ })
9069
+ .catch(error => {
8215
9070
  this.#resolveTtsrResume();
8216
- },
8217
- });
9071
+ logger.warn("TTSR follow-up injection was rejected", { error: String(error) });
9072
+ });
8218
9073
  }
8219
9074
 
8220
9075
  /** Build TTSR match context for tool call argument deltas. */
@@ -9036,6 +9891,16 @@ export class AgentSession {
9036
9891
  }
9037
9892
  }
9038
9893
 
9894
+ #bindAttemptScopeToActiveRun(scope: AttemptScope): void {
9895
+ this.#activeAttemptScope = scope;
9896
+ if (this.#activeLogicalRunId !== undefined) {
9897
+ this.#logicalRunIdByAttemptScope.set(scope, this.#activeLogicalRunId);
9898
+ }
9899
+ if (this.#activeSdkRunToken !== undefined) {
9900
+ this.#sdkRunTokensByAttemptScope.set(scope, this.#activeSdkRunToken);
9901
+ }
9902
+ }
9903
+
9039
9904
  /**
9040
9905
  * Subscribe to agent events.
9041
9906
  * Session persistence is handled internally (saves messages on message_end).
@@ -9063,6 +9928,7 @@ export class AgentSession {
9063
9928
  */
9064
9929
  #disconnectFromAgent(): void {
9065
9930
  this.#abortActiveMidRunBarriers();
9931
+ this.#compactionQueuedDeliveryArmed = false;
9066
9932
  if (this.#unsubscribeAgent) {
9067
9933
  this.#unsubscribeAgent();
9068
9934
  this.#unsubscribeAgent = undefined;
@@ -9204,21 +10070,27 @@ export class AgentSession {
9204
10070
  * Remove all listeners, flush pending writes, and disconnect from agent.
9205
10071
  * Call this when completely done with the session.
9206
10072
  */
9207
- dispose(): Promise<void> {
10073
+ dispose(options: { sessionShutdownReason?: "detached_idle" } = {}): Promise<void> {
9208
10074
  this.#evalExecutionDisposing = true;
9209
10075
  if (this.#disposeCompleted) return Promise.resolve();
9210
10076
  if (this.#disposeTerminalError !== undefined) return Promise.reject(this.#disposeTerminalError);
9211
10077
  if (this.#disposeCallerPromise) return this.#disposeCallerPromise;
9212
10078
  if (!this.#disposeRunPromise) {
10079
+ this.#sessionShutdownReason = options.sessionShutdownReason;
9213
10080
  this.#disposeDeadline = Date.now() + this.#disposeTimeoutMs;
9214
10081
  this.#disposeDeadlineExpired = Promise.withResolvers<void>();
9215
10082
  this.#disposeDeadlineTimer = setTimeout(() => this.#disposeDeadlineExpired?.resolve(), this.#disposeTimeoutMs);
9216
10083
  this.#disposeDeadlineTimer.unref?.();
9217
10084
  this.#abortAdmissionEpoch++;
9218
10085
  this.#isDisposed = true;
10086
+ // Dispose disconnects the Agent event bridge before bounded abort cleanup;
10087
+ // settle every accepted SDK handle now so consumed submissions cannot wait
10088
+ // forever for an agent_end that teardown deliberately suppresses.
10089
+ this.#terminalizeQueuedSdkWorkForSessionTransition(this.#queuedMessagesForSessionTransition());
9219
10090
  // Invalidate every coordinator event admitted before disposal. Handlers may
9220
10091
  // still unwind, but their captured generation can no longer enqueue a write.
9221
10092
  this.#coordinatorPersistGeneration += 1;
10093
+ this.#wakeFollowUpReservationTransitionWaiters();
9222
10094
  this.#disposeAbortController.abort();
9223
10095
  // Disposal owns a bounded Agent abort below. Waiting for the active prompt's
9224
10096
  // admission here would put that unbounded prompt ahead of the abort budget.
@@ -9383,7 +10255,10 @@ export class AgentSession {
9383
10255
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
9384
10256
  await awaitDisposeStep(
9385
10257
  "session shutdown handlers",
9386
- this.#extensionRunner.emit({ type: "session_shutdown" }),
10258
+ this.#extensionRunner.emit({
10259
+ type: "session_shutdown",
10260
+ ...(this.#sessionShutdownReason === undefined ? {} : { reason: this.#sessionShutdownReason }),
10261
+ }),
9387
10262
  );
9388
10263
  }
9389
10264
  } catch (error) {
@@ -9721,6 +10596,24 @@ export class AgentSession {
9721
10596
 
9722
10597
  /** Test seam: await all currently admitted coordinator sidecar writes. */
9723
10598
  async awaitCoordinatorRuntimeStatePersistenceForTests(): Promise<void> {
10599
+ // A terminal abort can schedule a preserved follow-up as a fresh turn after
10600
+ // the aborted run settles. Join that continuation before observing the
10601
+ // sidecar queue; otherwise teardown can race a rearmed run and wait on its
10602
+ // persistence while the test's manager is already being disposed.
10603
+ const idleStatus = await Promise.race([
10604
+ this.waitForIdle().then(
10605
+ () => ({ status: "settled" as const }),
10606
+ error => ({ status: "error" as const, error }),
10607
+ ),
10608
+ Bun.sleep(COORDINATOR_PERSISTENCE_TEST_IDLE_TIMEOUT_MS).then(() => ({ status: "timed_out" as const })),
10609
+ ]);
10610
+ if (idleStatus.status === "error") throw idleStatus.error;
10611
+ if (idleStatus.status === "timed_out") {
10612
+ logger.warn("Coordinator persistence test seam timed out waiting for session idle", {
10613
+ sessionId: this.sessionId,
10614
+ timeoutMs: COORDINATOR_PERSISTENCE_TEST_IDLE_TIMEOUT_MS,
10615
+ });
10616
+ }
9724
10617
  await this.#coordinatorPersistQueue;
9725
10618
  await this.#drainUnbarrieredCoordinatorPersists();
9726
10619
  }
@@ -10068,7 +10961,10 @@ export class AgentSession {
10068
10961
  reason: FoldReason = "chord",
10069
10962
  adapter?: FoldAdapter,
10070
10963
  ): Promise<ForegroundFoldOutcome> {
10071
- if (!this.#foldCoordinator.hasFoldableParticipant()) {
10964
+ // An explicit adapter (the steer watcher naming its own wait) is validated by
10965
+ // the coordinator's registration-bound identity check; only the implicit
10966
+ // chord / SDK-control path needs a targetable participant to exist.
10967
+ if (!adapter && !this.#foldCoordinator.hasFoldableParticipant()) {
10072
10968
  return this.#hasRunningFoldedJob() ? { status: "already_backgrounded" } : { status: "no_active_bash" };
10073
10969
  }
10074
10970
  try {
@@ -10106,6 +11002,240 @@ export class AgentSession {
10106
11002
  return Array.from(this.#toolRegistry.keys());
10107
11003
  }
10108
11004
 
11005
+ /**
11006
+ * Exact MCP controls: stock-workflow transactional successor revocation.
11007
+ *
11008
+ * Serialize an exact-config MCP control against both session mutexes.
11009
+ *
11010
+ * Admission serializes it with prompts and selection changes and fails closed
11011
+ * once dispose has closed admission; the transition lease excludes it from
11012
+ * new/switch/branch/fork/handoff in both directions. Neither side waits for
11013
+ * the other: whichever arrives second is rejected with a busy error, so a
11014
+ * control never observes or mutates state inside a transition window.
11015
+ */
11016
+ async #withExactMcpControlAdmission<T>(body: () => Promise<T>): Promise<T> {
11017
+ return this.#withSessionAdmission("mcp-control", async () => {
11018
+ this.#beginSessionTransition("mcp-control");
11019
+ try {
11020
+ return await body();
11021
+ } finally {
11022
+ this.#endSessionTransition();
11023
+ }
11024
+ });
11025
+ }
11026
+
11027
+ get hasExactMcpControls(): boolean {
11028
+ return getExactMcpControls(this) !== undefined;
11029
+ }
11030
+
11031
+ /**
11032
+ * Exact-config MCP status for the TUI `/mcp` command.
11033
+ *
11034
+ * Returns `undefined` unless this session holds the exact-config capability,
11035
+ * so a session built outside the root interactive entry point cannot read MCP
11036
+ * state through it. Data comes only from the session-owned tools-only manager
11037
+ * and the call never starts a deferred MCP startup or mutates connections.
11038
+ */
11039
+ async getExactMcpStatusSnapshot(): Promise<ExactMcpStatusSnapshot | undefined> {
11040
+ const controls = getExactMcpControls(this);
11041
+ if (!controls) return undefined;
11042
+ return this.#withExactMcpControlAdmission(async () => {
11043
+ const manager = this.#ownedMcpManager;
11044
+ const live = new Map<string, ExactMcpServerState>();
11045
+ if (manager) {
11046
+ for (const name of manager.getAllServerNames()) live.set(name, manager.getConnectionStatus(name));
11047
+ }
11048
+ const declared = await this.#readExactMcpDeclaredServers(controls.configPath);
11049
+ const names = declared ? [...declared.keys()] : [...live.keys()];
11050
+ if (names.length === 0) {
11051
+ return {
11052
+ startup: declared ? "no-servers-declared" : "not-started",
11053
+ servers: [],
11054
+ } satisfies ExactMcpStatusSnapshot;
11055
+ }
11056
+ const tools = manager?.getTools() ?? [];
11057
+ const servers = names.map(name => ({
11058
+ name,
11059
+ transport: declared?.get(name) ?? ("unknown" as const),
11060
+ state: manager?.isExactServerSuppressed(name)
11061
+ ? ("suspended" as const)
11062
+ : (live.get(name) ?? ("unavailable" as const)),
11063
+ toolCount: tools.reduce((count, tool) => (tool.mcpServerName === name ? count + 1 : count), 0),
11064
+ }));
11065
+ const startup =
11066
+ live.size === 0
11067
+ ? "not-started"
11068
+ : servers.some(server => server.state === "connecting")
11069
+ ? "in-flight"
11070
+ : "settled";
11071
+ return { startup, servers } satisfies ExactMcpStatusSnapshot;
11072
+ });
11073
+ }
11074
+
11075
+ /**
11076
+ * Apply one session-local exact-config control. Selection persistence is
11077
+ * deliberately bypassed: suppression is runtime state, not user authority.
11078
+ */
11079
+ async controlExactMcpServer(
11080
+ action: ExactMcpControlAction,
11081
+ name: string,
11082
+ ): Promise<ExactMcpControlResult | undefined> {
11083
+ const controls = getExactMcpControls(this);
11084
+ if (!controls) return undefined;
11085
+ return this.#withExactMcpControlAdmission(async () => {
11086
+ const manager = this.#ownedMcpManager;
11087
+ const declared = await this.#readExactMcpDeclaredServers(controls.configPath);
11088
+ const known = declared?.has(name) ?? manager?.getAllServerNames().includes(name) ?? false;
11089
+ if (!known) return { action, name, status: "unknown-server", toolCount: 0 };
11090
+ if (!manager) return { action, name, status: "unavailable", toolCount: 0 };
11091
+
11092
+ const selected = this.getSelectedMCPToolNames();
11093
+ const active = this.getActiveToolNames().filter(toolName => isMCPToolName(toolName));
11094
+ const serverToolNames = new Set(
11095
+ Array.from(this.#toolRegistry.values())
11096
+ .filter(
11097
+ tool =>
11098
+ isMCPBridgeTool(tool) && (tool as AgentTool & { mcpServerName?: string }).mcpServerName === name,
11099
+ )
11100
+ .map(tool => tool.name),
11101
+ );
11102
+
11103
+ const prepared = await manager.prepareExactServerControl(action, name);
11104
+ const result = prepared.result;
11105
+ if (result.status !== "suspended" && result.status !== "resumed" && result.status !== "reconnected") {
11106
+ await prepared.abort();
11107
+ return { action, ...result };
11108
+ }
11109
+ const snapshot = this.#captureExactMcpSessionState();
11110
+ const suspended = this.#exactMcpSuspendedTools.get(name);
11111
+ try {
11112
+ await this.refreshMCPTools(prepared.tools, {
11113
+ selectedMCPToolNames:
11114
+ action === "suspend"
11115
+ ? selected.filter(toolName => !serverToolNames.has(toolName))
11116
+ : action === "resume"
11117
+ ? [...selected, ...(suspended?.selected ?? [])]
11118
+ : selected,
11119
+ activeMCPToolNames:
11120
+ action === "suspend"
11121
+ ? active.filter(toolName => !serverToolNames.has(toolName))
11122
+ : action === "resume"
11123
+ ? [...active, ...(suspended?.active ?? [])]
11124
+ : active,
11125
+ persistMCPSelection: false,
11126
+ });
11127
+ await prepared.commit();
11128
+ if (action === "suspend") {
11129
+ this.#exactMcpSuspendedTools.set(name, {
11130
+ selected: selected.filter(toolName => serverToolNames.has(toolName)),
11131
+ active: active.filter(toolName => serverToolNames.has(toolName)),
11132
+ });
11133
+ } else if (action === "resume") {
11134
+ this.#exactMcpSuspendedTools.delete(name);
11135
+ }
11136
+ return { action, ...result };
11137
+ } catch (error) {
11138
+ this.#restoreExactMcpSessionState(snapshot);
11139
+ await prepared.abort();
11140
+ throw error;
11141
+ }
11142
+ });
11143
+ }
11144
+
11145
+ #captureExactMcpSessionState(): ExactMcpSessionStateSnapshot {
11146
+ return {
11147
+ toolRegistry: new Map(this.#toolRegistry),
11148
+ discoverableMCPTools: new Map(this.#discoverableMCPTools),
11149
+ selectedMCPToolNames: new Set(this.#selectedMCPToolNames),
11150
+ selectedDiscoveredToolNames: new Set(this.#selectedDiscoveredToolNames),
11151
+ tools: [...this.agent.state.tools],
11152
+ baseSystemPrompt: this.#baseSystemPrompt,
11153
+ systemPrompt: this.agent.state.systemPrompt,
11154
+ lastAppliedToolSignature: this.#lastAppliedToolSignature,
11155
+ pendingAppliedToolSignature: this.#pendingAppliedToolSignature,
11156
+ defaultModelSelectionMutationRevision: this.#defaultModelSelectionMutationRevision,
11157
+ baseSystemPromptGeneration: this.#baseSystemPromptGeneration,
11158
+ suspendedTools: new Map(this.#exactMcpSuspendedTools),
11159
+ };
11160
+ }
11161
+
11162
+ #restoreExactMcpSessionState(snapshot: ExactMcpSessionStateSnapshot): void {
11163
+ this.#toolRegistry.clear();
11164
+ for (const [name, tool] of snapshot.toolRegistry) this.#toolRegistry.set(name, tool);
11165
+ this.#discoverableMCPTools = snapshot.discoverableMCPTools;
11166
+ this.#selectedMCPToolNames = snapshot.selectedMCPToolNames;
11167
+ this.#selectedDiscoveredToolNames = snapshot.selectedDiscoveredToolNames;
11168
+ // These are the already-prepared predecessor objects from agent.state;
11169
+ // re-preparing them would stack execution guards and break identity.
11170
+ this.agent.setTools(snapshot.tools);
11171
+ this.#baseSystemPrompt = snapshot.baseSystemPrompt;
11172
+ this.agent.setSystemPrompt(snapshot.systemPrompt);
11173
+ this.#lastAppliedToolSignature = snapshot.lastAppliedToolSignature;
11174
+ this.#pendingAppliedToolSignature = snapshot.pendingAppliedToolSignature;
11175
+ this.#defaultModelSelectionMutationRevision = snapshot.defaultModelSelectionMutationRevision;
11176
+ this.#baseSystemPromptGeneration = snapshot.baseSystemPromptGeneration;
11177
+ this.#exactMcpSuspendedTools = snapshot.suspendedTools;
11178
+ this.#invalidateDiscoveryCaches();
11179
+ }
11180
+
11181
+ async #prepareExactMcpControlRetirement(): Promise<PreparedExactMcpRetirement | undefined> {
11182
+ const controls = getExactMcpControls(this);
11183
+ if (!controls) return undefined;
11184
+ const snapshot = this.#captureExactMcpSessionState();
11185
+ const prepared = await this.#ownedMcpManager?.prepareExactServerControlReset();
11186
+ const restoreControls = (): void => attachExactMcpControls(this, controls);
11187
+ try {
11188
+ if (prepared) await this.refreshMCPTools(prepared.tools, { persistMCPSelection: false });
11189
+ return {
11190
+ canCommit: () => prepared?.canCommit() ?? true,
11191
+ commit: async () => {
11192
+ try {
11193
+ await prepared?.commit();
11194
+ this.#exactMcpSuspendedTools.clear();
11195
+ revokeExactMcpControls(this);
11196
+ } catch (error) {
11197
+ this.#restoreExactMcpSessionState(snapshot);
11198
+ restoreControls();
11199
+ await prepared?.abort();
11200
+ throw error;
11201
+ }
11202
+ },
11203
+ abort: async () => {
11204
+ this.#restoreExactMcpSessionState(snapshot);
11205
+ restoreControls();
11206
+ await prepared?.abort();
11207
+ },
11208
+ };
11209
+ } catch (error) {
11210
+ this.#restoreExactMcpSessionState(snapshot);
11211
+ restoreControls();
11212
+ await prepared?.abort();
11213
+ throw error;
11214
+ }
11215
+ }
11216
+
11217
+ /**
11218
+ * Server names and transports the exact config declares, or `undefined` when
11219
+ * the file cannot be read or parsed. Only the name and the transport
11220
+ * discriminator are taken; command, args, url, headers, and env never leave
11221
+ * this method, and a read failure degrades to the manager's own view instead
11222
+ * of surfacing a parse error to the operator.
11223
+ */
11224
+ async #readExactMcpDeclaredServers(configPath: string): Promise<Map<string, ExactMcpTransport> | undefined> {
11225
+ try {
11226
+ const parsed: unknown = await Bun.file(configPath).json();
11227
+ const servers = (parsed as { mcpServers?: unknown } | null)?.mcpServers;
11228
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) return undefined;
11229
+ const declared = new Map<string, ExactMcpTransport>();
11230
+ for (const [name, entry] of Object.entries(servers as Record<string, unknown>)) {
11231
+ declared.set(name, exactMcpTransportOf(entry));
11232
+ }
11233
+ return declared;
11234
+ } catch {
11235
+ return undefined;
11236
+ }
11237
+ }
11238
+
10109
11239
  #getEditModeSession() {
10110
11240
  return {
10111
11241
  settings: this.settings,
@@ -10764,7 +11894,15 @@ export class AgentSession {
10764
11894
  * Replace MCP tools in the registry and recompute the visible MCP tool set immediately.
10765
11895
  * This allows /mcp add/remove/reauth to take effect without restarting the session.
10766
11896
  */
10767
- async refreshMCPTools(mcpTools: CustomTool[]): Promise<void> {
11897
+ async refreshMCPTools(
11898
+ mcpTools: CustomTool[],
11899
+ options: {
11900
+ selectedMCPToolNames?: string[];
11901
+ activeMCPToolNames?: string[];
11902
+ mandatoryMCPToolNames?: string[];
11903
+ persistMCPSelection?: boolean;
11904
+ } = {},
11905
+ ): Promise<void> {
10768
11906
  const previousSelectedMCPToolNames = this.getSelectedMCPToolNames();
10769
11907
  const existingNames = Array.from(this.#toolRegistry.keys());
10770
11908
  for (const name of existingNames) {
@@ -10796,17 +11934,27 @@ export class AgentSession {
10796
11934
  }
10797
11935
 
10798
11936
  this.#setDiscoverableMCPTools(this.#collectDiscoverableMCPToolsFromRegistry());
11937
+ if (options.mandatoryMCPToolNames !== undefined) {
11938
+ this.#mandatoryMCPToolNames = new Set(
11939
+ options.mandatoryMCPToolNames.map(name => name.toLowerCase()).filter(name => this.#toolRegistry.has(name)),
11940
+ );
11941
+ }
10799
11942
  this.#pruneSelectedMCPToolNames();
10800
11943
  const hasPersistedMCPToolSelection = this.buildDisplaySessionContext().hasPersistedMCPToolSelection;
10801
- if (!hasPersistedMCPToolSelection) {
11944
+ if (options.selectedMCPToolNames) {
11945
+ this.#selectedMCPToolNames = new Set(this.#filterSelectableMCPToolNames(options.selectedMCPToolNames));
11946
+ } else if (!hasPersistedMCPToolSelection) {
10802
11947
  this.#selectedMCPToolNames = new Set(
10803
11948
  this.#resolveConstructorMCPToolSelection() ?? this.#getConfiguredDefaultSelectedMCPToolNames(),
10804
11949
  );
10805
11950
  }
10806
- const nextActive = [...this.#getActiveNonMCPToolNames(), ...this.getSelectedMCPToolNames()];
11951
+ const nextActive = [
11952
+ ...this.#getActiveNonMCPToolNames(),
11953
+ ...(options.activeMCPToolNames ?? this.getSelectedMCPToolNames()),
11954
+ ];
10807
11955
  await this.#applyActiveToolsByName(nextActive, {
10808
11956
  previousSelectedMCPToolNames,
10809
- persistMCPSelection: hasPersistedMCPToolSelection,
11957
+ persistMCPSelection: options.persistMCPSelection ?? hasPersistedMCPToolSelection,
10810
11958
  });
10811
11959
  }
10812
11960
 
@@ -10946,6 +12094,11 @@ export class AgentSession {
10946
12094
  return this.#postPromptTasks.size > 0;
10947
12095
  }
10948
12096
 
12097
+ /** The host-liveness lease shared by admission, execution, and recovery work. */
12098
+ get sessionWorkLease(): SessionWorkLease {
12099
+ return this.#sessionWorkLease;
12100
+ }
12101
+
10949
12102
  /** Stable resource ownership identifier for the active prompt run. */
10950
12103
  get activePromptHandle(): string | undefined {
10951
12104
  return this.agent.activeResourceRunId;
@@ -11029,7 +12182,7 @@ export class AgentSession {
11029
12182
 
11030
12183
  /** Main startup calls this exactly once, after a strict open returned `kind: "opened"`. */
11031
12184
  async continuePersistedHistory(): Promise<void> {
11032
- this.#assertNoHandoffTransition();
12185
+ this.#assertExternalSessionIngress();
11033
12186
  this.#assertRecoveryHydrationPromoted();
11034
12187
  this.#removeEphemeralCustomMessages();
11035
12188
 
@@ -11072,7 +12225,7 @@ export class AgentSession {
11072
12225
  // Re-check after the awaited preparation: a handoff can engage during the
11073
12226
  // volatile-context/hindsight awaits above and this would otherwise start a
11074
12227
  // turn against the session being handed off.
11075
- this.#assertNoHandoffTransition();
12228
+ this.#assertExternalSessionIngress();
11076
12229
  await this.agent.continue({
11077
12230
  ...this.#managedFallbackPromptOptions(),
11078
12231
  onRunAccepted: (handle: AttemptRunHandle) => {
@@ -11500,7 +12653,26 @@ export class AgentSession {
11500
12653
  path.join(sessionStateDir(this.sessionManager.getCwd(), sessionId), "workflow-gates.json"),
11501
12654
  )
11502
12655
  : new MemoryGateStore();
11503
- return new BrokerWorkflowGateEmitter(sessionId, gateStore);
12656
+ let emitter: BrokerWorkflowGateEmitter;
12657
+ emitter = new BrokerWorkflowGateEmitter(sessionId, gateStore, {
12658
+ continuationLost: () => {
12659
+ if (
12660
+ !isCurrentWorkflowGateContinuation(
12661
+ this.sessionManager.getSessionId(),
12662
+ sessionId,
12663
+ this.#workflowGateEmitter,
12664
+ emitter,
12665
+ )
12666
+ )
12667
+ return;
12668
+ void this.abort({ cause: "internal", silent: true }).catch(error => {
12669
+ logger.warn("Failed to settle a workflow gate turn after continuation loss", {
12670
+ error: error instanceof Error ? error.message : String(error),
12671
+ });
12672
+ });
12673
+ },
12674
+ });
12675
+ return emitter;
11504
12676
  }
11505
12677
 
11506
12678
  /**
@@ -12061,11 +13233,14 @@ export class AgentSession {
12061
13233
  options?.images?.some(image => typeof image?.data === "string" && image.data.trim().length > 0) === true;
12062
13234
  if (typeof text !== "string" || (text.trim().length === 0 && !hasUsableImage))
12063
13235
  throw Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
13236
+ if (options?.synthetic !== true && options?.attribution !== "agent")
13237
+ invalidateSessionTitleGeneration(this.sessionManager);
12064
13238
  const sdkRunToken = readSdkRunCapability(options?.sdkRunCapability);
12065
13239
  const internalOptions: InternalPromptOptions | undefined = options
12066
13240
  ? { ...options, ...(sdkRunToken ? { sdkRunToken } : {}) }
12067
13241
  : undefined;
12068
13242
  this.#assertRecoveryHydrationPromoted();
13243
+ this.#assertExternalSessionIngress();
12069
13244
  const owner = this.#sessionAdmissionContext.getStore();
12070
13245
  if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
12071
13246
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
@@ -12174,7 +13349,7 @@ export class AgentSession {
12174
13349
  throw new AgentBusyError();
12175
13350
  }
12176
13351
  if (options.streamingBehavior === "followUp") {
12177
- await this.#queueFollowUp(expandedText, options?.images, {
13352
+ await this.#queueFollowUpTextAfterReservation(expandedText, options?.images, {
12178
13353
  forceOneAtATime: options.followUpQueuePolicy === "sequential",
12179
13354
  claimsGenuineUserIntent,
12180
13355
  });
@@ -12187,8 +13362,10 @@ export class AgentSession {
12187
13362
  if (workflowIntentDiff) {
12188
13363
  this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
12189
13364
  }
13365
+ this.#assertExternalSessionIngress();
12190
13366
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
12191
13367
  else options?.onPreflightAccepted?.();
13368
+ this.#assertExternalSessionIngress();
12192
13369
  return;
12193
13370
  }
12194
13371
 
@@ -12207,7 +13384,7 @@ export class AgentSession {
12207
13384
  ) {
12208
13385
  admission.release();
12209
13386
  if (options.streamingBehavior === "followUp") {
12210
- await this.#queueFollowUp(expandedText, options?.images, {
13387
+ await this.#queueFollowUpTextAfterReservation(expandedText, options?.images, {
12211
13388
  forceOneAtATime: options.followUpQueuePolicy === "sequential",
12212
13389
  claimsGenuineUserIntent,
12213
13390
  });
@@ -12220,8 +13397,10 @@ export class AgentSession {
12220
13397
  if (workflowIntentDiff) {
12221
13398
  this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
12222
13399
  }
13400
+ this.#assertExternalSessionIngress();
12223
13401
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
12224
13402
  else options?.onPreflightAccepted?.();
13403
+ this.#assertExternalSessionIngress();
12225
13404
  return;
12226
13405
  }
12227
13406
  this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
@@ -12409,6 +13588,7 @@ export class AgentSession {
12409
13588
  const internalOptions: InternalCustomMessageOptions | undefined = options
12410
13589
  ? { ...options, ...(sdkRunToken ? { sdkRunToken } : {}) }
12411
13590
  : undefined;
13591
+ this.#assertExternalSessionIngress();
12412
13592
  if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
12413
13593
  const textContent =
12414
13594
  typeof message.content === "string"
@@ -12468,9 +13648,11 @@ export class AgentSession {
12468
13648
  await activationSeed?.rollback();
12469
13649
  throw promptPreflightCancelledError();
12470
13650
  }
13651
+ this.#assertExternalSessionIngress();
12471
13652
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
12472
13653
  else options?.onPreflightAccepted?.();
12473
13654
  durableAcceptanceCompleted = true;
13655
+ this.#assertExternalSessionIngress();
12474
13656
  if (options?.preflightSignal?.aborted) {
12475
13657
  await activationSeed?.rollback();
12476
13658
  throw promptPreflightCancelledError();
@@ -12907,10 +14089,12 @@ export class AgentSession {
12907
14089
  await this.#promptAgentWithIdleRetry(preSubmit, agentPromptOptions, predecessorAgentEndHold, {
12908
14090
  signal: preflightSignal,
12909
14091
  resourceRunId: this.#runResourceLeaseContext.getStore()?.resourceRunId,
12910
- onPreflightAccepted: () => {
14092
+ onPreflightAccepted: async () => {
14093
+ this.#assertExternalSessionIngress({ allowCancelAndSubmit: true });
12911
14094
  this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
12912
- if (options?.onPreflightAcceptCommit) return options.onPreflightAcceptCommit();
12913
- options?.onPreflightAccepted?.();
14095
+ if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
14096
+ else options?.onPreflightAccepted?.();
14097
+ this.#assertExternalSessionIngress({ allowCancelAndSubmit: true });
12914
14098
  },
12915
14099
  });
12916
14100
  const activeTerminalScope = this.#activeAttemptScope;
@@ -13169,7 +14353,9 @@ export class AgentSession {
13169
14353
  images?.some(image => typeof image?.data === "string" && image.data.trim().length > 0) === true;
13170
14354
  if (typeof text !== "string" || (text.trim().length === 0 && !hasUsableImage))
13171
14355
  throw Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
14356
+ invalidateSessionTitleGeneration(this.sessionManager);
13172
14357
  this.#assertRecoveryHydrationPromoted();
14358
+ this.#assertExternalSessionIngress({ allowCancelAndSubmit: true });
13173
14359
  if (text.startsWith("/")) {
13174
14360
  this.#throwIfExtensionCommand(text);
13175
14361
  }
@@ -13178,7 +14364,10 @@ export class AgentSession {
13178
14364
  if (expandedText.trim().length === 0 && !hasUsableImage)
13179
14365
  throw Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
13180
14366
  assertImagePlaceholdersHavePayload(expandedText, images);
13181
- await this.#queueSteer(expandedText, images, { claimsGenuineUserIntent: true });
14367
+ await this.#queueSteer(expandedText, images, {
14368
+ claimsGenuineUserIntent: true,
14369
+ allowCancelAndSubmit: true,
14370
+ });
13182
14371
  }
13183
14372
 
13184
14373
  /**
@@ -13193,7 +14382,9 @@ export class AgentSession {
13193
14382
  images?.some(image => typeof image?.data === "string" && image.data.trim().length > 0) === true;
13194
14383
  if (typeof text !== "string" || (text.trim().length === 0 && !hasUsableImage))
13195
14384
  throw Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
14385
+ invalidateSessionTitleGeneration(this.sessionManager);
13196
14386
  this.#assertRecoveryHydrationPromoted();
14387
+ this.#assertExternalSessionIngress({ allowCancelAndSubmit: true });
13197
14388
  if (text.startsWith("/")) {
13198
14389
  this.#throwIfExtensionCommand(text);
13199
14390
  }
@@ -13202,9 +14393,10 @@ export class AgentSession {
13202
14393
  if (expandedText.trim().length === 0 && !hasUsableImage)
13203
14394
  throw Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
13204
14395
  assertImagePlaceholdersHavePayload(expandedText, images);
13205
- await this.#queueFollowUp(expandedText, images, {
14396
+ await this.#queueFollowUpTextAfterReservation(expandedText, images, {
13206
14397
  forceOneAtATime: options?.followUpQueuePolicy === "sequential",
13207
14398
  claimsGenuineUserIntent: true,
14399
+ allowCancelAndSubmit: true,
13208
14400
  });
13209
14401
  }
13210
14402
 
@@ -13217,12 +14409,19 @@ export class AgentSession {
13217
14409
  options?: {
13218
14410
  claimsGenuineUserIntent?: boolean;
13219
14411
  onPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
14412
+ onQueued?: (message: AgentMessage) => void;
14413
+ onQueuedAfterAdmission?: (message: AgentMessage, cancelQueued: () => boolean) => void;
13220
14414
  external?: boolean;
13221
14415
  sdkRunToken?: string;
13222
14416
  forceOneAtATime?: boolean;
14417
+ allowDuringSessionTransition?: boolean;
14418
+ allowCancelAndSubmit?: boolean;
13223
14419
  },
13224
- ): Promise<void> {
13225
- this.#assertNoHandoffTransition();
14420
+ ): Promise<QueuedFollowUpOwner> {
14421
+ this.#assertExternalSessionIngress({
14422
+ allowTrackedSuccessor: options?.allowDuringSessionTransition,
14423
+ allowCancelAndSubmit: options?.allowCancelAndSubmit,
14424
+ });
13226
14425
  assertImagePlaceholdersHavePayload(text, images);
13227
14426
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
13228
14427
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
@@ -13235,7 +14434,7 @@ export class AgentSession {
13235
14434
  // in a queue nobody owns.
13236
14435
  const admission = this.agent.steer(message, options?.forceOneAtATime ? { forceOneAtATime: true } : undefined);
13237
14436
  if (!admission.admitted) {
13238
- await this.#queueFollowUp(text, images, {
14437
+ const queuedFollowUp = await this.#queueFollowUpAfterReservation(message, displayText, {
13239
14438
  forceOneAtATime: options?.forceOneAtATime,
13240
14439
  claimsGenuineUserIntent: options?.claimsGenuineUserIntent,
13241
14440
  onPromoted: options?.onPromoted,
@@ -13243,16 +14442,21 @@ export class AgentSession {
13243
14442
  scheduleNonAdmittedWake: false,
13244
14443
  onQueued: message => {
13245
14444
  if (this.#abortUnwind && !options?.forceOneAtATime) this.#abortUnwindSteerFallbacks.push(message);
14445
+ options?.onQueued?.(message);
13246
14446
  },
14447
+ onQueuedAfterAdmission: options?.onQueuedAfterAdmission,
14448
+ allowDuringSessionTransition: options?.allowDuringSessionTransition,
14449
+ allowCancelAndSubmit: options?.allowCancelAndSubmit,
13247
14450
  });
13248
14451
  this.#scheduleNonAdmittedQueuedContinuation();
13249
- return;
14452
+ return queuedFollowUp;
13250
14453
  }
13251
14454
  if (options?.forceOneAtATime) this.#sequentialSteerMessages.add(message);
13252
14455
  this.#steeringMessages.push(this.#createQueuedDisplayEntry(displayText, undefined, message));
13253
14456
  if (options?.external) {
13254
14457
  this.#externalSteerMessages.add(message);
13255
14458
  this.#externalSteerAdmissionSeq.set(message, ++this.#steeringAdmissionSeq);
14459
+ this.#queuedAdmissionSeq.set(message, ++this.#queuedAdmissionSequence);
13256
14460
  if (options.onPromoted) this.#steerPromotionHooks.set(message, options.onPromoted);
13257
14461
  }
13258
14462
  if (options?.sdkRunToken) this.#sdkRunTokensByQueuedMessage.set(message, options.sdkRunToken);
@@ -13260,6 +14464,45 @@ export class AgentSession {
13260
14464
  const epoch = this.#claimDeepInterviewUserIntent();
13261
14465
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
13262
14466
  }
14467
+ options?.onQueued?.(message);
14468
+ const cancelQueuedSteer = (): boolean => {
14469
+ const removedQueues = this.agent.removeQueuedMessages(candidate => candidate === message);
14470
+ const removed = removedQueues.steering > 0 || removedQueues.followUp > 0;
14471
+ if (!removed) return false;
14472
+ this.#steeringMessages = this.#steeringMessages.filter(entry => entry.message !== message);
14473
+ this.#followUpMessages = this.#followUpMessages.filter(entry => entry.message !== message);
14474
+ this.#externalSteerMessages.delete(message);
14475
+ this.#externalFollowUps.delete(message);
14476
+ this.#sequentialSteerMessages.delete(message);
14477
+ this.#fireQueuedRemovalHooks([message]);
14478
+ this.#releaseDeferredSdkFollowUps();
14479
+ return true;
14480
+ };
14481
+ options?.onQueuedAfterAdmission?.(message, cancelQueuedSteer);
14482
+ return { cancel: cancelQueuedSteer };
14483
+ }
14484
+
14485
+ /**
14486
+ * One-shot preflight-abort binding: an invocation cancelled after its queue
14487
+ * admission must cancel the submission it admitted, so an aborted dispatch
14488
+ * can never execute later. Both explicit delivery paths (follow-up and
14489
+ * steer) share this helper so their cancellation semantics cannot diverge
14490
+ * (exact-head review P1).
14491
+ */
14492
+ #bindPreflightAbortCancellation(
14493
+ signal: AbortSignal | undefined,
14494
+ owner: QueuedFollowUpOwner,
14495
+ state?: TrackedQueuedInput,
14496
+ ): void {
14497
+ if (!signal) return;
14498
+ const cancelQueued = () => {
14499
+ if (state) state.submission.cancel();
14500
+ else owner.cancel();
14501
+ };
14502
+ const removeListener = () => signal.removeEventListener("abort", cancelQueued);
14503
+ if (state) state.removePreflightAbortListener = removeListener;
14504
+ signal.addEventListener("abort", cancelQueued, { once: true });
14505
+ if (signal.aborted) cancelQueued();
13263
14506
  }
13264
14507
 
13265
14508
  /**
@@ -13288,7 +14531,12 @@ export class AgentSession {
13288
14531
  * run that would have polled the queue is gone, so nothing else owns it.
13289
14532
  */
13290
14533
  #scheduleQueuedDelivery(delayMs?: number): void {
13291
- if (this.#cancelAndSubmitInProgress || !this.#canDeliverQueuedMessages()) return;
14534
+ if (this.#cancelAndSubmitInProgress) return;
14535
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) {
14536
+ this.#queuedDeliveryPendingWhileTransition = true;
14537
+ return;
14538
+ }
14539
+ if (!this.#canDeliverQueuedMessages()) return;
13292
14540
  this.#scheduleAgentContinue({
13293
14541
  ...(delayMs === undefined ? {} : { delayMs }),
13294
14542
  shouldContinue: () => this.#canDeliverQueuedMessages() && this.agent.hasQueuedMessages(),
@@ -13303,109 +14551,201 @@ export class AgentSession {
13303
14551
  async #queueFollowUp(
13304
14552
  text: string,
13305
14553
  images?: ImageContent[],
13306
- options?: {
13307
- forceOneAtATime?: boolean;
13308
- claimsGenuineUserIntent?: boolean;
13309
- onPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
13310
- sdkRunToken?: string;
13311
- onQueued?: (message: AgentMessage) => void;
13312
- scheduleNonAdmittedWake?: boolean;
13313
- },
14554
+ options?: QueueFollowUpOptions,
13314
14555
  ): Promise<QueuedFollowUpOwner> {
13315
- this.#assertNoHandoffTransition();
14556
+ this.#assertExternalSessionIngress({
14557
+ allowTrackedSuccessor: options?.allowDuringSessionTransition,
14558
+ allowCancelAndSubmit: options?.allowCancelAndSubmit,
14559
+ });
14560
+ assertImagePlaceholdersHavePayload(text, images);
14561
+ const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
14562
+ if (images && images.length > 0) content.push(...images);
14563
+ const message = { role: "user" as const, content, attribution: "user" as const, timestamp: Date.now() };
14564
+ return this.#queueFollowUpMessage(message, text || (images && images.length > 0 ? "[Image]" : ""), options);
14565
+ }
14566
+
14567
+ async #queueFollowUpTextAfterReservation(
14568
+ text: string,
14569
+ images?: ImageContent[],
14570
+ options?: QueueFollowUpOptions,
14571
+ ): Promise<QueuedFollowUpOwner> {
14572
+ this.#assertExternalSessionIngress({
14573
+ allowTrackedSuccessor: options?.allowDuringSessionTransition,
14574
+ allowCancelAndSubmit: options?.allowCancelAndSubmit,
14575
+ });
13316
14576
  assertImagePlaceholdersHavePayload(text, images);
13317
- const displayText = text || (images && images.length > 0 ? "[Image]" : "");
13318
- const queueWasEmpty = !this.agent.hasQueuedMessages();
13319
14577
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
13320
14578
  if (images && images.length > 0) content.push(...images);
13321
14579
  const message = { role: "user" as const, content, attribution: "user" as const, timestamp: Date.now() };
14580
+ return this.#queueFollowUpAfterReservation(
14581
+ message,
14582
+ text || (images && images.length > 0 ? "[Image]" : ""),
14583
+ options,
14584
+ );
14585
+ }
14586
+
14587
+ #queueFollowUpMessage(
14588
+ message: AgentMessage,
14589
+ displayText: string,
14590
+ options?: QueueFollowUpOptions,
14591
+ ): QueuedFollowUpOwner {
14592
+ const queueWasEmpty = !this.agent.hasQueuedMessages();
13322
14593
  options?.onQueued?.(message);
13323
- // Display entry carries the message identity so positional editing can never
13324
- // misaddress a deferred SDK follow-up held outside the Agent live queue.
13325
- const displayEntry = this.#createQueuedDisplayEntry(displayText, undefined, message);
13326
- this.#followUpMessages.push(displayEntry);
13327
- this.#externalFollowUps.add(message);
14594
+ const displayEntry =
14595
+ options?.createDisplayEntry === false
14596
+ ? undefined
14597
+ : this.#createQueuedDisplayEntry(displayText, undefined, message);
14598
+ if (displayEntry) this.#followUpMessages.push(displayEntry);
14599
+ if (options?.trackExternalFollowUp !== false) this.#externalFollowUps.add(message);
13328
14600
  if (options?.onPromoted) this.#followUpPromotionHooks.set(message, options.onPromoted);
14601
+ if (options?.sdkRunToken || options?.onQueuedAfterAdmission) {
14602
+ this.#queuedAdmissionSeq.set(message, ++this.#queuedAdmissionSequence);
14603
+ }
13329
14604
  if (options?.claimsGenuineUserIntent) {
13330
14605
  const epoch = this.#claimDeepInterviewUserIntent();
13331
14606
  this.#deepInterviewGenuineUserMessageEpochs.set(message, epoch);
13332
14607
  }
13333
14608
  if (options?.sdkRunToken) this.#sdkRunTokensByQueuedMessage.set(message, options.sdkRunToken);
13334
- if (options?.sdkRunToken && (this.agent.state.isStreaming || this.agent.hasQueuedMessages())) {
13335
- this.#deferredSdkFollowUps.push(message);
13336
- } else {
13337
- this.agent.followUp(message, options?.forceOneAtATime ? { forceOneAtATime: true } : undefined);
13338
- }
13339
- // When this is the first queued message and the session is in a resumable
13340
- // assistant-ended state, schedule an immediate continue so it is delivered
13341
- // without waiting for the next user turn. A later accepted follow-up must
13342
- // not start unrelated queued work ahead of it, because that work has a
13343
- // different cancellation and terminal owner.
13344
- if (queueWasEmpty) {
13345
- this.#scheduleQueuedFollowUpContinuation(() =>
13346
- this.agent.snapshotFollowUp().some(candidate => candidate === message),
13347
- );
13348
- if (options?.scheduleNonAdmittedWake !== false) this.#scheduleNonAdmittedQueuedContinuation();
14609
+ // If older deferred work is waiting while the Agent is idle, release
14610
+ // that head before parking this newer message. Otherwise the newer message
14611
+ // would enter the live queue first and invert SDK FIFO ownership.
14612
+ if (!this.agent.state.isStreaming && !this.agent.hasQueuedMessages() && this.#deferredSdkFollowUps.length > 0) {
14613
+ this.#releaseDeferredSdkFollowUps();
13349
14614
  }
13350
- return {
14615
+ const owner: QueuedFollowUpOwner = {
13351
14616
  cancel: () => {
13352
14617
  const deferredIndex = this.#deferredSdkFollowUps.indexOf(message);
13353
14618
  let removed = false;
13354
14619
  if (deferredIndex !== -1) {
13355
14620
  this.#deferredSdkFollowUps.splice(deferredIndex, 1);
14621
+ this.#forgetDeferredFollowUpMetadata(message);
13356
14622
  removed = true;
13357
14623
  } else {
13358
14624
  removed = this.agent.removeQueuedMessages(candidate => candidate === message).followUp > 0;
13359
- // This message was already released from the deferred queue; its
13360
- // scheduled continuation was cancelled before it started. No further
13361
- // agent_end may arrive to release the next deferred follow-up, so
13362
- // advance the queue here to keep the next accepted SDK request moving.
13363
- if (removed) this.#releaseDeferredSdkFollowUps();
13364
14625
  }
13365
14626
  if (removed) {
13366
- this.#followUpMessages = this.#followUpMessages.filter(entry => entry !== displayEntry);
13367
- this.#deepInterviewGenuineUserMessageEpochs.delete(message);
13368
- this.#sdkRunTokensByQueuedMessage.delete(message);
13369
- // A canceled follow-up never reaches the normal promotion boundary,
13370
- // so terminalize its SDK owner through the same removal disposition.
13371
- this.#followUpPromotionHooks.get(message)?.({ removed: true });
13372
- this.#followUpPromotionHooks.delete(message);
14627
+ this.#followUpMessages = displayEntry
14628
+ ? this.#followUpMessages.filter(entry => entry !== displayEntry)
14629
+ : this.#followUpMessages.filter(entry => entry.message !== message);
14630
+ this.#fireQueuedRemovalHooks([message]);
14631
+ // Either removal site can unblock the NEXT deferred SDK follow-up:
14632
+ // the cancelled entry may itself have been the deferred head, or it
14633
+ // may have been the live work that deferred head was waiting behind.
14634
+ // Without this release the successor stays accepted with no execution
14635
+ // or terminal settlement (exact-head review P1).
14636
+ this.#releaseDeferredSdkFollowUps();
13373
14637
  }
13374
14638
  return removed;
13375
14639
  },
13376
14640
  };
14641
+ options?.onQueuedAfterAdmission?.(message, owner.cancel);
14642
+ // Existing deferred work is part of the same FIFO even when the Agent
14643
+ // queue is currently empty. Enqueuing a newer successor directly into the
14644
+ // Agent in that state would let it run before the older deferred request.
14645
+ if (
14646
+ this.#deferredSdkFollowUps.length > 0 ||
14647
+ (options?.sdkRunToken && (this.agent.state.isStreaming || this.agent.hasQueuedMessages()))
14648
+ ) {
14649
+ if (options?.forceOneAtATime || options?.sdkRunToken) {
14650
+ this.#deferredFollowUpForceOneAtATime.add(message);
14651
+ }
14652
+ this.#deferredSdkFollowUps.push(message);
14653
+ } else {
14654
+ this.agent.followUp(message, options?.forceOneAtATime ? { forceOneAtATime: true } : undefined);
14655
+ }
14656
+ if (queueWasEmpty) {
14657
+ this.#scheduleQueuedFollowUpContinuation(() =>
14658
+ this.agent.snapshotFollowUp().some(candidate => candidate === message),
14659
+ );
14660
+ if (options?.scheduleNonAdmittedWake !== false) this.#scheduleNonAdmittedQueuedContinuation();
14661
+ }
14662
+ return owner;
14663
+ }
14664
+
14665
+ #forgetDeferredFollowUpMetadata(message: AgentMessage): void {
14666
+ this.#deferredFollowUpForceOneAtATime.delete(message);
14667
+ const batch = this.#deferredFollowUpBatches.get(message);
14668
+ if (!batch) return;
14669
+ const remaining = batch.filter(candidate => this.#deferredSdkFollowUps.includes(candidate));
14670
+ for (const member of batch) this.#deferredFollowUpBatches.delete(member);
14671
+ if (remaining.length > 1) for (const member of remaining) this.#deferredFollowUpBatches.set(member, remaining);
14672
+ }
14673
+
14674
+ async #queueFollowUpAfterReservation(
14675
+ message: AgentMessage,
14676
+ displayText: string,
14677
+ options?: QueueFollowUpOptions,
14678
+ reservationSignal?: AbortSignal,
14679
+ ): Promise<QueuedFollowUpOwner> {
14680
+ if (reservationSignal?.aborted) throw promptPreflightCancelledError();
14681
+ const transitionGeneration = this.#coordinatorPersistGeneration;
14682
+ const reservationEpoch = ++this.#followUpReservationEpoch;
14683
+ this.#activeFollowUpReservationEpochs.add(reservationEpoch);
14684
+ const releaseReservation = () => {
14685
+ this.#activeFollowUpReservationEpochs.delete(reservationEpoch);
14686
+ const waiters = [...this.#followUpReservationDrainWaiters];
14687
+ this.#followUpReservationDrainWaiters.clear();
14688
+ for (const waiter of waiters) waiter();
14689
+ };
14690
+ try {
14691
+ if ([...this.#activeFollowUpReservationEpochs].some(epoch => epoch < reservationEpoch)) {
14692
+ await this.#waitForEarlierFollowUpReservations(
14693
+ reservationEpoch,
14694
+ reservationSignal,
14695
+ undefined,
14696
+ transitionGeneration,
14697
+ );
14698
+ }
14699
+ if (reservationSignal?.aborted) throw promptPreflightCancelledError();
14700
+ this.#assertExternalSessionIngress({
14701
+ allowTrackedSuccessor: options?.allowDuringSessionTransition,
14702
+ allowCancelAndSubmit: options?.allowCancelAndSubmit,
14703
+ });
14704
+ return this.#queueFollowUpMessage(message, displayText, options);
14705
+ } finally {
14706
+ releaseReservation();
14707
+ }
13377
14708
  }
13378
- #releaseDeferredSdkFollowUps(): void {
14709
+
14710
+ #releaseDeferredSdkFollowUps(): boolean {
13379
14711
  // A deferred SDK follow-up must become the sole first message at the next
13380
14712
  // acceptance so its run token is bound to the agent_start. Releasing it
13381
14713
  // behind still-queued work reproduces the token-less mid-run consumption
13382
14714
  // hazard, so wait for the queue to drain; the next agent_end retries.
13383
- if (this.agent.hasQueuedMessages()) return;
13384
- const message = this.#deferredSdkFollowUps.shift();
13385
- if (!message) return;
13386
- this.agent.followUp(message, { forceOneAtATime: true });
14715
+ if (this.agent.state.isStreaming || this.agent.hasQueuedMessages()) return false;
14716
+ const message = this.#deferredSdkFollowUps[0];
14717
+ if (!message) return false;
14718
+ const batch = this.#deferredFollowUpBatches.get(message);
14719
+ const released =
14720
+ batch && batch.length > 1 && batch.every((candidate, index) => this.#deferredSdkFollowUps[index] === candidate)
14721
+ ? [...batch]
14722
+ : [message];
14723
+ const forceOneAtATime = this.#deferredFollowUpForceOneAtATime.has(message);
14724
+ this.#deferredSdkFollowUps.splice(0, released.length);
14725
+ for (const candidate of released) this.#forgetDeferredFollowUpMetadata(candidate);
14726
+ if (released.length > 1) {
14727
+ this.agent.markFollowUpBatch(released);
14728
+ this.agent.restoreFollowUp(released);
14729
+ } else {
14730
+ this.agent.followUp(message, forceOneAtATime ? { forceOneAtATime: true } : undefined);
14731
+ }
14732
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) {
14733
+ this.#queuedDeliveryPendingWhileTransition = true;
14734
+ return true;
14735
+ }
13387
14736
  this.#scheduleAgentContinue({
13388
- shouldContinue: () => this.#canStartDeferredSdkFollowUp() && this.agent.hasQueuedMessages(),
14737
+ shouldContinue: () => this.#canDeliverQueuedMessages() && this.agent.hasQueuedMessages(),
13389
14738
  rescheduleOnBusy: true,
13390
14739
  continueQueuedOnly: true,
13391
14740
  });
14741
+ return true;
13392
14742
  }
13393
- #canStartDeferredSdkFollowUp(): boolean {
13394
- if (this.agent.state.isStreaming) return false;
13395
- if (this.isCompacting) return false;
13396
- if (this.isBashRunning) return false;
13397
- if (this.isEvalRunning) return false;
13398
- if (this.isRetrying) return false;
13399
- const messages = this.agent.state.messages;
13400
- const last = messages[messages.length - 1];
13401
- return last?.role === "assistant" || last?.role === "bashExecution" || last?.role === "pythonExecution";
13402
- }
13403
-
13404
14743
  /**
13405
14744
  * Gate for idle-path follow-up auto-continue. See `#queueFollowUp` for rationale.
13406
14745
  */
13407
14746
  #canAutoContinueForFollowUp(): boolean {
13408
14747
  if (this.#abortUnwind) return false;
14748
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) return false;
13409
14749
  if (this.isStreaming) return false;
13410
14750
  if (this.isCompacting) return false;
13411
14751
  if (this.isBashRunning) return false;
@@ -13441,6 +14781,7 @@ export class AgentSession {
13441
14781
  */
13442
14782
  #canDeliverQueuedMessages(): boolean {
13443
14783
  if (this.#abortUnwind) return false;
14784
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) return false;
13444
14785
  if (this.agent.state.isStreaming) return false;
13445
14786
  if (this.isRetrying) return false;
13446
14787
  if (this.isCompacting) return false;
@@ -13458,6 +14799,7 @@ export class AgentSession {
13458
14799
  */
13459
14800
  #canAutoContinueForSteer(): boolean {
13460
14801
  if (this.#abortUnwind) return false;
14802
+ if (this.#sessionTransitionKind !== undefined || this.#handoffTransitionActive) return false;
13461
14803
  if (this.agent.state.isStreaming) return false;
13462
14804
  if (this.isRetrying) return false;
13463
14805
  const messages = this.agent.state.messages;
@@ -13473,11 +14815,13 @@ export class AgentSession {
13473
14815
  }
13474
14816
 
13475
14817
  queueDeferredMessage(message: CustomMessage): void {
13476
- this.#queueHiddenNextTurnMessage(message, true, "turn");
14818
+ this.#assertExternalSessionIngress({ allowCancelAndSubmit: true });
14819
+ this.#queueHiddenNextTurnMessage(message, true, "turn", true);
13477
14820
  }
13478
14821
 
13479
14822
  queueDeferredMessageForTests(message: CustomMessage, triggerTurn = true): void {
13480
- this.#queueHiddenNextTurnMessage(message, triggerTurn, "turn");
14823
+ this.#assertExternalSessionIngress({ allowCancelAndSubmit: true });
14824
+ this.#queueHiddenNextTurnMessage(message, triggerTurn, "turn", true);
13481
14825
  }
13482
14826
 
13483
14827
  /** Read-only test seam for the hidden next-turn context queue. */
@@ -13494,11 +14838,12 @@ export class AgentSession {
13494
14838
  message: CustomMessage,
13495
14839
  triggerTurn: boolean,
13496
14840
  origin: "turn" | "external" = "external",
14841
+ allowCancelAndSubmit = false,
13497
14842
  ): void {
13498
14843
  // A hidden next-turn message queued during a handoff transition would be
13499
14844
  // dropped when the successor clears predecessor queues; reject it as busy so
13500
14845
  // the caller can retry against the settled session.
13501
- this.#assertNoHandoffTransition();
14846
+ this.#assertExternalSessionIngress({ allowCancelAndSubmit });
13502
14847
  this.#pendingNextTurnMessages.push({ message, origin });
13503
14848
  if (!triggerTurn) return;
13504
14849
  const generation = this.#promptGeneration;
@@ -13662,7 +15007,7 @@ export class AgentSession {
13662
15007
  * monitor's registration, so removing it after an intermediate notification
13663
15008
  * would let a later scope:"owned" abort miss the monitor and make subsequent
13664
15009
  * notifications lose their envelope (review thread P1). */
13665
- #settleDeliveredOwnedRegistrations(messages: AgentMessage[]): void {
15010
+ #settleDeliveredOwnedRegistrations(messages: readonly AgentMessage[]): void {
13666
15011
  // Use the SESSION-OWNED manager first: the process-global instance is
13667
15012
  // the last-created session, so checking this session's delivery against
13668
15013
  // another session's manager (which lacks the same local job id) would
@@ -13771,6 +15116,7 @@ export class AgentSession {
13771
15116
  },
13772
15117
  ): Promise<void> {
13773
15118
  this.#assertRecoveryHydrationPromoted();
15119
+ this.#assertExternalSessionIngress();
13774
15120
  const appMessage: CustomMessage<T> = {
13775
15121
  role: "custom",
13776
15122
  customType: message.customType,
@@ -13817,7 +15163,11 @@ export class AgentSession {
13817
15163
  this.#settleDeliveredOwnedRegistrations([appMessage]);
13818
15164
  return;
13819
15165
  }
13820
- this.agent.followUp(appMessage, sequential);
15166
+ await this.#queueFollowUpAfterReservation(appMessage, this.#getCustomMessageTextContent(appMessage), {
15167
+ forceOneAtATime: sequential !== undefined,
15168
+ createDisplayEntry: false,
15169
+ trackExternalFollowUp: false,
15170
+ });
13821
15171
  if (this.#abortUnwind && !sequential) this.#abortUnwindSteerFallbacks.push(appMessage);
13822
15172
  // The chip now describes follow-up work: keep its mode and identity
13823
15173
  // aligned with where the executable message actually landed.
@@ -13843,10 +15193,11 @@ export class AgentSession {
13843
15193
  return;
13844
15194
  }
13845
15195
 
13846
- this.agent.followUp(
13847
- appMessage,
13848
- options?.followUpQueuePolicy === "sequential" ? { forceOneAtATime: true } : undefined,
13849
- );
15196
+ await this.#queueFollowUpAfterReservation(appMessage, this.#getCustomMessageTextContent(appMessage), {
15197
+ forceOneAtATime: options?.followUpQueuePolicy === "sequential",
15198
+ createDisplayEntry: false,
15199
+ trackExternalFollowUp: false,
15200
+ });
13850
15201
  this.#bindCustomDisplayEntry(appMessage, "followUp");
13851
15202
  // The session can report streaming while no agent loop owns the queue
13852
15203
  // (post-prompt unwind): without a scheduled delivery a cron/monitor
@@ -13952,13 +15303,22 @@ export class AgentSession {
13952
15303
  purgeQueuedCustomMessages(predicate: (message: CustomMessage) => boolean): PurgeQueuedCustomMessagesResult {
13953
15304
  const isMatch = (m: AgentMessage): boolean => m.role === "custom" && predicate(m as CustomMessage);
13954
15305
  const removedTags = new Set<string>();
13955
- for (const m of [...this.agent.snapshotSteering(), ...this.agent.snapshotFollowUp()]) {
15306
+ const steeringBefore = this.agent.snapshotSteering();
15307
+ const followUpBefore = this.agent.snapshotFollowUp();
15308
+ const deferredBefore = [...this.#deferredSdkFollowUps];
15309
+ for (const m of [...steeringBefore, ...followUpBefore, ...deferredBefore]) {
13956
15310
  if (isMatch(m)) {
13957
15311
  const tag = readPendingDisplayTag((m as CustomMessage).details);
13958
15312
  if (tag) removedTags.add(tag);
13959
15313
  }
13960
15314
  }
13961
15315
  const agentRemoved = this.agent.removeQueuedMessages(isMatch);
15316
+ const removedAgentMessages = [...steeringBefore, ...followUpBefore].filter(isMatch);
15317
+ const removedDeferred = deferredBefore.filter(isMatch);
15318
+ if (removedDeferred.length > 0) {
15319
+ this.#deferredSdkFollowUps = this.#deferredSdkFollowUps.filter(message => !isMatch(message));
15320
+ for (const message of removedDeferred) this.#forgetDeferredFollowUpMetadata(message);
15321
+ }
13962
15322
  const beforeNext = this.#pendingNextTurnMessages.length;
13963
15323
  for (const entry of this.#pendingNextTurnMessages) {
13964
15324
  if (predicate(entry.message)) {
@@ -13968,6 +15328,7 @@ export class AgentSession {
13968
15328
  }
13969
15329
  this.#pendingNextTurnMessages = this.#pendingNextTurnMessages.filter(entry => !predicate(entry.message));
13970
15330
  const pendingNextTurn = beforeNext - this.#pendingNextTurnMessages.length;
15331
+ this.#fireQueuedRemovalHooks([...removedAgentMessages, ...removedDeferred]);
13971
15332
  let displaySteering = 0;
13972
15333
  let displayFollowUp = 0;
13973
15334
  if (removedTags.size > 0) {
@@ -13978,6 +15339,7 @@ export class AgentSession {
13978
15339
  this.#followUpMessages = this.#followUpMessages.filter(e => !(e.tag && removedTags.has(e.tag)));
13979
15340
  displayFollowUp = beforeF - this.#followUpMessages.length;
13980
15341
  }
15342
+ if (agentRemoved.total > 0 || removedDeferred.length > 0) this.#releaseDeferredSdkFollowUps();
13981
15343
  return {
13982
15344
  agentSteering: agentRemoved.steering,
13983
15345
  agentFollowUp: agentRemoved.followUp,
@@ -13997,26 +15359,45 @@ export class AgentSession {
13997
15359
  */
13998
15360
  async sendUserMessage(
13999
15361
  content: string | (TextContent | ImageContent)[],
14000
- options?: {
14001
- deliverAs?: "steer" | "followUp";
14002
- /** Preserve a busy SDK dispatch as queued work across an admission fence. */
14003
- queuedAtDispatch?: boolean;
14004
- onPreflightAccepted?: () => void;
14005
- onPreflightAcceptCommit?: () => void | Promise<void>;
14006
- /** Fired when a queued submission (steering or follow-up) is promoted to its own run (SDK ownership correlation). */
14007
- onQueuedPromoted?: (promotion: { startsOwnRun?: boolean; removed?: boolean }) => void;
14008
- /** Internal dispatch disposition used before actual queue consumption. */
14009
- onDispatchDisposition?: (promotion: { startsOwnRun: boolean }) => void;
14010
- preflightSignal?: AbortSignal;
14011
- sdkRunCapability?: unknown;
14012
- },
15362
+ options?: SendUserMessageOptions,
15363
+ ): Promise<void>;
15364
+ async sendUserMessage(
15365
+ content: string | (TextContent | ImageContent)[],
15366
+ options?: SendUserMessageOptions,
14013
15367
  ): Promise<void> {
15368
+ assertLegacySendUserMessageOptions(options);
15369
+ await this.#sendUserMessage(content, options);
15370
+ }
15371
+
15372
+ /**
15373
+ * Queue one steer or follow-up and return a stable lifecycle handle for that
15374
+ * exact submission. Unlike `sendUserMessage`, this method resolves when the
15375
+ * queue admission receipt is available; use the returned promises to await
15376
+ * execution and the owning terminal boundary.
15377
+ */
15378
+ async submitUserMessage(
15379
+ content: string | (TextContent | ImageContent)[],
15380
+ options: TrackedSendUserMessageOptions,
15381
+ ): Promise<QueuedInputSubmission> {
15382
+ assertTrackedSendUserMessageOptions(options);
15383
+ const submission = await this.#sendUserMessage(content, options);
15384
+ if (submission === undefined) throw new Error("Tracked user message did not produce a submission handle.");
15385
+ return submission;
15386
+ }
15387
+
15388
+ async #sendUserMessage(
15389
+ content: string | (TextContent | ImageContent)[],
15390
+ options?: SendUserMessageDispatchOptions,
15391
+ ): Promise<undefined | QueuedInputSubmission> {
15392
+ assertQueuedInputQueuePolicy(options?.queuePolicy);
14014
15393
  const sdkRunToken = readSdkRunCapability(options?.sdkRunCapability);
15394
+ const submissionTransitionGeneration = this.#coordinatorPersistGeneration;
14015
15395
  const internalOptions = options ? { ...options, ...(sdkRunToken ? { sdkRunToken } : {}) } : undefined;
14016
15396
  this.#assertRecoveryHydrationPromoted();
15397
+ const trackedSuccessorAdmission = this.#hasCommittedSuccessorTrackedAdmission(options);
15398
+ this.#assertExternalSessionIngress({ allowTrackedSuccessor: trackedSuccessorAdmission });
14017
15399
  const owner = this.#sessionAdmissionContext.getStore();
14018
- if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
14019
- this.#assertSessionAdmissionOpen();
15400
+ if (owner && !owner.released && !trackedSuccessorAdmission) throw this.#sessionAdmissionBusyError();
14020
15401
  if (options?.preflightSignal?.aborted) throw promptPreflightCancelledError();
14021
15402
  if (typeof content !== "string" && !Array.isArray(content)) {
14022
15403
  throw Object.assign(new Error("sendUserMessage requires string or content-array content."), {
@@ -14054,6 +15435,16 @@ export class AgentSession {
14054
15435
  images?.some(image => typeof image?.data === "string" && image.data.trim().length > 0) === true;
14055
15436
  if (text.trim().length === 0 && !hasUsableImage)
14056
15437
  throw Object.assign(new Error("Prompt must not be empty."), { code: "invalid_input" });
15438
+ if (options?.trackSubmission === true && options.deliverAs === undefined)
15439
+ throw Object.assign(new Error("trackSubmission requires deliverAs: steer or followUp."), {
15440
+ code: "invalid_input",
15441
+ });
15442
+ let trackedQueuedInput: TrackedQueuedInput | undefined;
15443
+ let onTrackedQueued: ((message: AgentMessage, cancelQueued: () => boolean) => void) | undefined;
15444
+ const onQueuedPromoted = (promotion: { startsOwnRun?: boolean; removed?: boolean }): void => {
15445
+ if (trackedQueuedInput) this.#handleTrackedQueuedInputPromotion(trackedQueuedInput, promotion);
15446
+ options?.onQueuedPromoted?.(promotion);
15447
+ };
14057
15448
 
14058
15449
  let admissionSignal = options?.preflightSignal
14059
15450
  ? AbortSignal.any([this.#promptPreflightAbortController.signal, options.preflightSignal])
@@ -14120,7 +15511,19 @@ export class AgentSession {
14120
15511
  await awaitPromptInvocationPreflight(this.#selectionFenceTail, admissionSignal);
14121
15512
  }
14122
15513
  const assertPreflightStillOpen = () => {
14123
- this.#assertSessionAdmissionOpen();
15514
+ this.#assertExternalSessionIngress({ allowTrackedSuccessor: trackedSuccessorAdmission });
15515
+ if (
15516
+ (this.#sessionTransitionKind !== undefined ||
15517
+ this.#coordinatorPersistGeneration !== submissionTransitionGeneration) &&
15518
+ !this.#hasCommittedSuccessorTrackedAdmission(options)
15519
+ ) {
15520
+ throw Object.assign(
15521
+ new AgentBusyError(
15522
+ `Cannot submit queued input while ${this.#sessionTransitionKind ?? "a session state transition"} is in progress.`,
15523
+ ),
15524
+ { code: "busy" },
15525
+ );
15526
+ }
14124
15527
  if (
14125
15528
  options?.preflightSignal?.aborted ||
14126
15529
  this.#promptPreflightCancellationGeneration !== preflightCancellationGeneration
@@ -14139,33 +15542,69 @@ export class AgentSession {
14139
15542
  followUpReservationEpoch,
14140
15543
  options?.preflightSignal,
14141
15544
  admissionSignal,
15545
+ submissionTransitionGeneration,
14142
15546
  );
14143
15547
  }
15548
+ assertPreflightStillOpen();
14144
15549
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
14145
15550
  assertPreflightStillOpen();
15551
+ if (options?.trackSubmission === true) {
15552
+ trackedQueuedInput = this.#createTrackedQueuedInput(deliverAs, options.queuePolicy ?? "respect-mode");
15553
+ onTrackedQueued = (message: AgentMessage, cancelQueued: () => boolean) =>
15554
+ this.#admitTrackedQueuedInput(trackedQueuedInput!, message, cancelQueued);
15555
+ }
14146
15556
  const queuedFollowUp = await this.#queueFollowUp(text, images, {
14147
15557
  claimsGenuineUserIntent: true,
14148
- forceOneAtATime: Boolean(options?.preflightSignal || options?.queuedAtDispatch),
14149
- onPromoted: options?.onQueuedPromoted,
15558
+ forceOneAtATime: Boolean(options?.queuedAtDispatch || options?.queuePolicy === "sequential"),
15559
+ onPromoted: onQueuedPromoted,
14150
15560
  sdkRunToken: internalOptions?.sdkRunToken,
15561
+ onQueuedAfterAdmission: onTrackedQueued,
15562
+ allowDuringSessionTransition: this.#hasCommittedSuccessorTrackedAdmission(options),
14151
15563
  });
14152
- const cancelQueuedFollowUp = () => queuedFollowUp.cancel();
14153
- options?.preflightSignal?.addEventListener("abort", cancelQueuedFollowUp, { once: true });
14154
- if (options?.preflightSignal?.aborted) cancelQueuedFollowUp();
14155
- options?.onPreflightAccepted?.();
14156
- return;
15564
+ this.#bindPreflightAbortCancellation(options?.preflightSignal, queuedFollowUp, trackedQueuedInput);
15565
+ try {
15566
+ assertPreflightStillOpen();
15567
+ options?.onPreflightAccepted?.();
15568
+ assertPreflightStillOpen();
15569
+ } catch (error) {
15570
+ if (trackedQueuedInput) this.#settleTrackedAdmissionCallbackFailure(trackedQueuedInput, queuedFollowUp);
15571
+ else queuedFollowUp.cancel();
15572
+ throw error;
15573
+ }
15574
+ return trackedQueuedInput?.submission;
14157
15575
  }
14158
15576
  if (deliverAs === "steer") {
14159
15577
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
14160
15578
  assertPreflightStillOpen();
14161
- await this.#queueSteer(text, images, {
15579
+ if (options?.trackSubmission === true) {
15580
+ trackedQueuedInput = this.#createTrackedQueuedInput(deliverAs, options.queuePolicy ?? "respect-mode");
15581
+ onTrackedQueued = (message: AgentMessage, cancelQueued: () => boolean) =>
15582
+ this.#admitTrackedQueuedInput(trackedQueuedInput!, message, cancelQueued);
15583
+ }
15584
+ const queuedSteer = await this.#queueSteer(text, images, {
14162
15585
  claimsGenuineUserIntent: true,
14163
- onPromoted: options?.onQueuedPromoted,
15586
+ onPromoted: onQueuedPromoted,
14164
15587
  external: true,
14165
15588
  sdkRunToken: internalOptions?.sdkRunToken,
15589
+ forceOneAtATime: options?.queuePolicy === "sequential",
15590
+ onQueuedAfterAdmission: onTrackedQueued,
15591
+ allowDuringSessionTransition: this.#hasCommittedSuccessorTrackedAdmission(options),
14166
15592
  });
14167
- options?.onPreflightAccepted?.();
14168
- return;
15593
+ // An explicit steer is admitted to the live loop immediately, so an
15594
+ // aborted invocation must cancel the exact submission it admitted:
15595
+ // otherwise the steer stays executable after its caller gave up
15596
+ // (exact-head review P1).
15597
+ this.#bindPreflightAbortCancellation(options?.preflightSignal, queuedSteer, trackedQueuedInput);
15598
+ try {
15599
+ assertPreflightStillOpen();
15600
+ options?.onPreflightAccepted?.();
15601
+ assertPreflightStillOpen();
15602
+ } catch (error) {
15603
+ if (trackedQueuedInput) this.#settleTrackedAdmissionCallbackFailure(trackedQueuedInput, queuedSteer);
15604
+ else queuedSteer.cancel();
15605
+ throw error;
15606
+ }
15607
+ return trackedQueuedInput?.submission;
14169
15608
  }
14170
15609
 
14171
15610
  // No explicit delivery mode: only a live stream makes prompt() throw
@@ -14225,6 +15664,13 @@ export class AgentSession {
14225
15664
  preflightSignal: options?.preflightSignal,
14226
15665
  } as InternalPromptOptions);
14227
15666
  } finally {
15667
+ // A preflight fence or queue admission error can occur after the tracked
15668
+ // state is allocated but before a queue callback binds its exact message.
15669
+ // No handle escaped in that case, so discard the unreachable state rather
15670
+ // than retaining an unresolved submission forever.
15671
+ if (trackedQueuedInput?.message === undefined) {
15672
+ if (trackedQueuedInput) this.#settleTrackedQueuedInputRemoved(trackedQueuedInput, "removed");
15673
+ }
14228
15674
  releaseFollowUpReservation();
14229
15675
  }
14230
15676
  }
@@ -14346,15 +15792,36 @@ export class AgentSession {
14346
15792
  const deferredIndex = this.#deferredSdkFollowUps.indexOf(removedMessage);
14347
15793
  if (deferredIndex !== -1) {
14348
15794
  this.#deferredSdkFollowUps.splice(deferredIndex, 1);
15795
+ this.#forgetDeferredFollowUpMetadata(removedMessage);
14349
15796
  } else {
14350
15797
  this.agent.removeQueuedMessages(candidate => candidate === removedMessage);
14351
15798
  }
14352
15799
  } else if (index >= 0) {
14353
- // Legacy entries without an identity link keep positional removal.
14354
- const positional =
14355
- resolvedMode === "steer" ? this.agent.removeSteerAt(index) : this.agent.removeFollowUpAt(index);
14356
- if (positional !== undefined) removedMessage = positional;
14357
- }
15800
+ // Legacy entries without an identity link use a content/tag lookup. The
15801
+ // visible index is not an Agent-queue index because hidden messages may
15802
+ // occupy executable slots ahead of the row.
15803
+ const executable = resolvedMode === "steer" ? this.agent.snapshotSteering() : this.agent.snapshotFollowUp();
15804
+ const legacyIndex = executable.findIndex(candidate => {
15805
+ if (entry?.tag !== undefined && candidate.role === "custom") {
15806
+ return readPendingDisplayTag(candidate.details) === entry.tag;
15807
+ }
15808
+ if (entry?.text === undefined) return false;
15809
+ if (candidate.role === "user") return this.#getUserMessageText(candidate) === entry.text;
15810
+ if (candidate.role === "custom") return this.#getCustomMessageTextContent(candidate) === entry.text;
15811
+ return false;
15812
+ });
15813
+ if (legacyIndex !== -1) {
15814
+ removedMessage =
15815
+ resolvedMode === "steer"
15816
+ ? this.agent.removeSteerAt(legacyIndex)
15817
+ : this.agent.removeFollowUpAt(legacyIndex);
15818
+ }
15819
+ }
15820
+ // Removing a deferred SDK follow-up — or the live follow-up it was waiting
15821
+ // behind — must release the NEXT deferred submission, exactly like the
15822
+ // cancellation path does, or it stays accepted with no execution or
15823
+ // terminal settlement (exact-head review P1).
15824
+ if (removedMessage !== undefined) this.#releaseDeferredSdkFollowUps();
14358
15825
  // The removed message left its queue WITHOUT consumption: its accepted SDK
14359
15826
  // submission must terminalize boundedly, not strand accepted forever
14360
15827
  // (#4668 review P1).
@@ -14705,11 +16172,13 @@ export class AgentSession {
14705
16172
  | undefined;
14706
16173
 
14707
16174
  if (this.#extensionRunner && savedCompactionEntry) {
14708
- await this.#extensionRunner.emit({
14709
- type: "session_compact",
14710
- compactionEntry: savedCompactionEntry,
14711
- fromExtension: fromExtension ?? false,
14712
- });
16175
+ await this.#runCommittedSuccessorHook(() =>
16176
+ this.#extensionRunner!.emit({
16177
+ type: "session_compact",
16178
+ compactionEntry: savedCompactionEntry,
16179
+ fromExtension: fromExtension ?? false,
16180
+ }),
16181
+ );
14713
16182
  }
14714
16183
 
14715
16184
  return savedCompactionEntry;
@@ -14797,6 +16266,7 @@ export class AgentSession {
14797
16266
  this.#promptPreflightCancellationGeneration++;
14798
16267
  this.#promptPreflightAbortController.abort();
14799
16268
  this.#promptPreflightAbortController = new AbortController();
16269
+ this.#dropQueuedTtsrFollowUps();
14800
16270
  this.#scheduledHiddenNextTurnGeneration = undefined;
14801
16271
  if (options?.preserveCompaction) this.#autoCompactionAbortController?.abort();
14802
16272
  else this.abortCompaction();
@@ -14872,6 +16342,7 @@ export class AgentSession {
14872
16342
  this.#promptPreflightCancellationGeneration++;
14873
16343
  this.#promptPreflightAbortController.abort();
14874
16344
  this.#promptPreflightAbortController = new AbortController();
16345
+ this.#dropQueuedTtsrFollowUps();
14875
16346
  this.#drainTerminalOwnedYieldEntries();
14876
16347
  if (options?.preserveCompaction !== true) this.abortCompaction();
14877
16348
  const overlappingPostPromptDrain = this.#cancelPostPromptTasks();
@@ -15019,6 +16490,7 @@ export class AgentSession {
15019
16490
  this.#promptPreflightCancellationGeneration++;
15020
16491
  this.#promptPreflightAbortController.abort();
15021
16492
  this.#promptPreflightAbortController = new AbortController();
16493
+ this.#dropQueuedTtsrFollowUps();
15022
16494
  }
15023
16495
  /**
15024
16496
  * Capture the steering admission snapshot at abort ADMISSION: the host
@@ -15105,6 +16577,31 @@ export class AgentSession {
15105
16577
  const manager = this.#ownedAsyncJobManager ?? AsyncJobManager.instance();
15106
16578
  return AsyncJobManager.endpointIdOf(manager) ?? this.sessionManager.getSessionId() ?? "local";
15107
16579
  }
16580
+ /**
16581
+ * Tool call ids the run resource ledger currently holds for `handle` (#5637).
16582
+ *
16583
+ * The authoritative answer to "is a tool running?": AgentLoop reserves the
16584
+ * `kind: "tool"` lease SYNCHRONOUSLY inside its dispatch loop, before the
16585
+ * tool's `execute` is invoked, and the lease settles when the call really
16586
+ * ends — whereas `tool_execution_start` only reaches an extension after an
16587
+ * asynchronous fanout. Strictly read-only: it takes a snapshot and never
16588
+ * claims, reserves, seals or quarantines. An unknown run reads as empty,
16589
+ * matching `RunResourceLedger.pending`.
16590
+ */
16591
+ pendingToolExecutions(handle: string): readonly string[] {
16592
+ const ids = new Set<string>();
16593
+ for (const entry of this.agent.resourceLedger.pending(handle)) {
16594
+ if (entry.kind !== "tool") continue;
16595
+ // AgentLoop labels a tool reservation `<toolName>:<toolCallId>`, and
16596
+ // registers the reservation and its tracked task under the SAME label, so
16597
+ // the set also collapses that pair to one id. A label without a separator
16598
+ // is passed through rather than dropped: over-reporting a running tool
16599
+ // only costs a bounded wait, under-reporting kills it mid-write.
16600
+ const separator = entry.label.indexOf(":");
16601
+ ids.add(separator < 0 ? entry.label : entry.label.slice(separator + 1));
16602
+ }
16603
+ return [...ids];
16604
+ }
15108
16605
  async abortPromptAndWait(
15109
16606
  handle: string,
15110
16607
  options: {
@@ -15237,16 +16734,21 @@ export class AgentSession {
15237
16734
  // Purged ordinary follow-ups leave without consumption: terminalize
15238
16735
  // their accepted SDK submissions boundedly (#4668 review P1).
15239
16736
  this.#fireQueuedRemovalHooks(followUpBeforePurge.filter(purgedByAbort));
15240
- // Mirror the follow-up purge in the display list so stale entries
15241
- // cannot misalign the positional editing APIs (review thread P2).
15242
- const followUpAfterPurge = new Set(this.agent.snapshotFollowUp());
15243
- const keptFollowUpDisplays: QueuedDisplayEntry[] = [];
15244
- for (let displayIndex = 0; displayIndex < followUpBeforePurge.length; displayIndex++) {
15245
- if (followUpAfterPurge.has(followUpBeforePurge[displayIndex]!)) {
15246
- keptFollowUpDisplays.push(this.#followUpMessages[displayIndex]!);
15247
- }
15248
- }
15249
- this.#followUpMessages = keptFollowUpDisplays;
16737
+ // Mirror the follow-up purge in the display list by bound message identity,
16738
+ // not by Agent-array position. Deferred SDK follow-ups and tag-only custom
16739
+ // entries are not represented one-for-one in followUpBeforePurge.
16740
+ const keptFollowUps = new Set([...this.agent.snapshotFollowUp(), ...this.#deferredSdkFollowUps]);
16741
+ const keptCustomTags = new Set(
16742
+ [...keptFollowUps].flatMap(message => {
16743
+ if (message.role !== "custom") return [];
16744
+ const tag = readPendingDisplayTag(message.details);
16745
+ return tag === undefined ? [] : [tag];
16746
+ }),
16747
+ );
16748
+ this.#followUpMessages = this.#followUpMessages.filter(entry => {
16749
+ if (entry.message !== undefined) return keptFollowUps.has(entry.message);
16750
+ return entry.tag !== undefined && keptCustomTags.has(entry.tag);
16751
+ });
15250
16752
  }
15251
16753
  // Do NOT advance the attempt epoch here: the terminal scope must stay
15252
16754
  // keyed to the aborted epoch so #isTurnContinuationBlocked (which
@@ -15260,6 +16762,7 @@ export class AgentSession {
15260
16762
  this.#promptPreflightCancellationGeneration++;
15261
16763
  this.#promptPreflightAbortController.abort();
15262
16764
  this.#promptPreflightAbortController = new AbortController();
16765
+ this.#dropQueuedTtsrFollowUps();
15263
16766
  }
15264
16767
  const aborted = this.#runCancellationDomains.abort(handle);
15265
16768
  if (!aborted.ok) {
@@ -15376,6 +16879,7 @@ export class AgentSession {
15376
16879
  const queueSnapshot = this.agent.snapshotQueues();
15377
16880
  const steeringDisplaySnapshot = [...this.#steeringMessages];
15378
16881
  const followUpDisplaySnapshot = [...this.#followUpMessages];
16882
+ const deferredFollowUpSnapshot = [...this.#deferredSdkFollowUps];
15379
16883
  const pendingNextTurnSnapshot = [...this.#pendingNextTurnMessages];
15380
16884
  const additionsSince = <T>(current: readonly T[], baseline: readonly T[]): T[] => {
15381
16885
  const remaining = new Map<T, number>();
@@ -15387,7 +16891,7 @@ export class AgentSession {
15387
16891
  return false;
15388
16892
  });
15389
16893
  };
15390
- const selected = (() => {
16894
+ let selected = (() => {
15391
16895
  if (options?.queuedEntryId === undefined) return undefined;
15392
16896
  const [mode, sequenceText] = options.queuedEntryId.split(":");
15393
16897
  const sequence = Number(sequenceText);
@@ -15395,21 +16899,63 @@ export class AgentSession {
15395
16899
  const displays = mode === "steer" ? steeringDisplaySnapshot : followUpDisplaySnapshot;
15396
16900
  const index = displays.findIndex(entry => entry.sequence === sequence);
15397
16901
  if (index === -1) return undefined;
16902
+ const display = displays[index]!;
16903
+ const queue = mode === "steer" ? queueSnapshot.steering : queueSnapshot.followUp;
16904
+ const message =
16905
+ display.message ??
16906
+ queue.find(candidate => {
16907
+ if (candidate.role !== "custom" || display.tag === undefined) return false;
16908
+ return readPendingDisplayTag(candidate.details) === display.tag;
16909
+ });
16910
+ const deferred =
16911
+ mode === "followUp" && message !== undefined && deferredFollowUpSnapshot.includes(message);
15398
16912
  return {
15399
- display: displays[index]!,
15400
- message: (mode === "steer" ? queueSnapshot.steering : queueSnapshot.followUp)[index],
16913
+ display,
16914
+ message,
15401
16915
  mode,
15402
- index,
16916
+ deferred,
15403
16917
  };
15404
16918
  })();
16919
+ let cancelledSelection: typeof selected;
16920
+ const selectedPromotionHook = selected?.message
16921
+ ? (this.#followUpPromotionHooks.get(selected.message) ?? this.#steerPromotionHooks.get(selected.message))
16922
+ : undefined;
16923
+ if (selected?.deferred && selected.message !== undefined) {
16924
+ const selectedDeferredIndex = this.#deferredSdkFollowUps.indexOf(selected.message);
16925
+ if (selectedDeferredIndex !== -1) {
16926
+ this.#deferredSdkFollowUps.splice(selectedDeferredIndex, 1);
16927
+ }
16928
+ }
16929
+ const revalidateSelected = (): void => {
16930
+ if (selected === undefined || selected.message === undefined) return;
16931
+ const stillDisplayed =
16932
+ this.#steeringMessages.includes(selected.display) ||
16933
+ this.#followUpMessages.includes(selected.display);
16934
+ // An abort temporarily removes steering from Agent's live queue and
16935
+ // returns it in the agent_end disowned batch; the display mirror is the
16936
+ // stable ownership marker across that boundary. A successful external
16937
+ // cancellation removes the display entry as part of the same operation.
16938
+ if (!stillDisplayed) {
16939
+ cancelledSelection = selected;
16940
+ selected = undefined;
16941
+ }
16942
+ };
15405
16943
  let runAccepted = false;
15406
16944
  const restore = () => {
15407
16945
  const queues = this.agent.snapshotQueues();
15408
16946
  const queueBaseline = [...queueSnapshot.steering, ...queueSnapshot.followUp];
15409
16947
  const displayBaseline = [...steeringDisplaySnapshot, ...followUpDisplaySnapshot];
16948
+ const cancelledMessage = cancelledSelection?.message;
16949
+ const cancelledDisplay = cancelledSelection?.display;
15410
16950
  this.agent.restoreQueues({
15411
- steering: [...queueSnapshot.steering, ...additionsSince(queues.steering, queueBaseline)],
15412
- followUp: [...queueSnapshot.followUp, ...additionsSince(queues.followUp, queueBaseline)],
16951
+ steering: [
16952
+ ...queueSnapshot.steering.filter(message => message !== cancelledMessage),
16953
+ ...additionsSince(queues.steering, queueBaseline).filter(message => message !== cancelledMessage),
16954
+ ],
16955
+ followUp: [
16956
+ ...queueSnapshot.followUp.filter(message => message !== cancelledMessage),
16957
+ ...additionsSince(queues.followUp, queueBaseline).filter(message => message !== cancelledMessage),
16958
+ ],
15413
16959
  });
15414
16960
  this.#pendingNextTurnMessages = [
15415
16961
  ...pendingNextTurnSnapshot,
@@ -15418,13 +16964,23 @@ export class AgentSession {
15418
16964
  this.#cancelAndSubmitPendingNextTurnDrained ? [] : pendingNextTurnSnapshot,
15419
16965
  ),
15420
16966
  ];
16967
+ this.#deferredSdkFollowUps = [
16968
+ ...deferredFollowUpSnapshot.filter(message => message !== cancelledMessage),
16969
+ ...additionsSince(this.#deferredSdkFollowUps, deferredFollowUpSnapshot).filter(
16970
+ message => message !== cancelledMessage,
16971
+ ),
16972
+ ];
15421
16973
  this.#steeringMessages = [
15422
- ...steeringDisplaySnapshot,
15423
- ...additionsSince(this.#steeringMessages, displayBaseline),
16974
+ ...steeringDisplaySnapshot.filter(entry => entry !== cancelledDisplay),
16975
+ ...additionsSince(this.#steeringMessages, displayBaseline).filter(
16976
+ entry => entry !== cancelledDisplay,
16977
+ ),
15424
16978
  ];
15425
16979
  this.#followUpMessages = [
15426
- ...followUpDisplaySnapshot,
15427
- ...additionsSince(this.#followUpMessages, displayBaseline),
16980
+ ...followUpDisplaySnapshot.filter(entry => entry !== cancelledDisplay),
16981
+ ...additionsSince(this.#followUpMessages, displayBaseline).filter(
16982
+ entry => entry !== cancelledDisplay,
16983
+ ),
15428
16984
  ];
15429
16985
  };
15430
16986
  try {
@@ -15436,6 +16992,7 @@ export class AgentSession {
15436
16992
  ? await this.#cancelAndSubmitAbortOutcomeProviderForTests()
15437
16993
  : await this.#abortWithOutcome({ cause: "user_interrupt", timeoutMs: 5_000 });
15438
16994
  this.#disownedSteeringDisposition = undefined;
16995
+ revalidateSelected();
15439
16996
  if (outcome.kind !== "settled") {
15440
16997
  restore();
15441
16998
  if (outcome.kind === "error") {
@@ -15456,22 +17013,21 @@ export class AgentSession {
15456
17013
  };
15457
17014
  const steeringDisplaysDuringWindow = additionsSince(this.#steeringMessages, steeringDisplaySnapshot);
15458
17015
  const followUpDisplaysDuringWindow = additionsSince(this.#followUpMessages, followUpDisplaySnapshot);
15459
- const selectedMessage = selected?.message;
15460
- const heldFollowUp = selected
15461
- ? [
15462
- ...queueSnapshot.steering.filter(
15463
- (_, index) => selected.mode !== "steer" || index !== selected.index,
15464
- ),
15465
- ...queueSnapshot.followUp.filter(
15466
- (_, index) => selected.mode !== "followUp" || index !== selected.index,
15467
- ),
15468
- ]
17016
+ const committedSelection = selected;
17017
+ const selectedMessage = committedSelection?.message;
17018
+ const reclassifiedSteering = committedSelection
17019
+ ? queueSnapshot.steering.filter(message => message !== selectedMessage)
17020
+ : [];
17021
+ const heldFollowUp = committedSelection
17022
+ ? [...reclassifiedSteering, ...queueSnapshot.followUp.filter(message => message !== selectedMessage)]
15469
17023
  : [];
15470
17024
  let heldQueueRestored = false;
15471
17025
  const restoreHeldQueue = () => {
15472
- if (!selected || heldQueueRestored) return;
17026
+ if (!committedSelection || heldQueueRestored) return;
15473
17027
  heldQueueRestored = true;
15474
17028
  const current = this.agent.snapshotQueues();
17029
+ this.#markSteeringAsFollowUpPolicy(reclassifiedSteering);
17030
+ this.#markTrackedFollowUpSequential(heldFollowUp);
15475
17031
  this.agent.restoreQueues({
15476
17032
  steering: current.steering,
15477
17033
  followUp: [...heldFollowUp, ...current.followUp],
@@ -15481,25 +17037,49 @@ export class AgentSession {
15481
17037
  // restored once the new run is accepted (below), so the Agent
15482
17038
  // admits them into a live run instead of re-labelling them as
15483
17039
  // follow-ups drained turn after turn. Follow-ups keep their queue.
15484
- const heldSteering = selected ? [] : queueSnapshot.steering;
15485
- const heldSteeringDisplays = selected ? [] : steeringDisplaySnapshot;
17040
+ const cancelledMessage = cancelledSelection?.message;
17041
+ const cancelledDisplay = cancelledSelection?.display;
17042
+ const heldSteering = committedSelection
17043
+ ? []
17044
+ : queueSnapshot.steering.filter(message => message !== cancelledMessage);
17045
+ const heldSteeringDisplays = committedSelection
17046
+ ? []
17047
+ : steeringDisplaySnapshot.filter(entry => entry !== cancelledDisplay);
15486
17048
  this.agent.restoreQueues({
15487
- steering: [...queuedDuringWindow.steering],
15488
- followUp: selected
15489
- ? [...queuedDuringWindow.followUp]
15490
- : [...queueSnapshot.followUp, ...queuedDuringWindow.followUp],
17049
+ steering: queuedDuringWindow.steering.filter(
17050
+ message => message !== selectedMessage && message !== cancelledMessage,
17051
+ ),
17052
+ followUp: committedSelection
17053
+ ? queuedDuringWindow.followUp.filter(message => message !== selectedMessage)
17054
+ : [
17055
+ ...queueSnapshot.followUp.filter(message => message !== cancelledMessage),
17056
+ ...queuedDuringWindow.followUp.filter(message => message !== cancelledMessage),
17057
+ ],
15491
17058
  });
15492
17059
  this.#steeringMessages = steeringDisplaysDuringWindow;
15493
17060
  // With a selected entry the held (unselected) messages are re-queued
15494
17061
  // as follow-ups by restoreHeldQueue once the new run is accepted;
15495
17062
  // mirror them in the display in the same order.
15496
- this.#followUpMessages = selected
17063
+ this.#followUpMessages = committedSelection
15497
17064
  ? [
15498
- ...steeringDisplaySnapshot.filter(entry => entry !== selected.display),
15499
- ...followUpDisplaySnapshot.filter(entry => entry !== selected.display),
17065
+ ...steeringDisplaySnapshot.filter(entry => entry !== committedSelection.display),
17066
+ ...followUpDisplaySnapshot.filter(entry => entry !== committedSelection.display),
15500
17067
  ...followUpDisplaysDuringWindow,
15501
17068
  ]
15502
- : [...followUpDisplaySnapshot, ...followUpDisplaysDuringWindow];
17069
+ : [
17070
+ ...followUpDisplaySnapshot.filter(entry => entry !== cancelledDisplay),
17071
+ ...followUpDisplaysDuringWindow.filter(entry => entry !== cancelledDisplay),
17072
+ ];
17073
+ if (committedSelection?.deferred && selectedMessage !== undefined) {
17074
+ this.#deferredSdkFollowUps = this.#deferredSdkFollowUps.filter(
17075
+ message => message !== selectedMessage,
17076
+ );
17077
+ }
17078
+ if (selectedMessage !== undefined && selectedPromotionHook !== undefined) {
17079
+ if (committedSelection?.mode === "steer")
17080
+ this.#steerPromotionHooks.set(selectedMessage, selectedPromotionHook);
17081
+ else this.#followUpPromotionHooks.set(selectedMessage, selectedPromotionHook);
17082
+ }
15503
17083
  const message = selectedMessage ?? {
15504
17084
  role: "user" as const,
15505
17085
  content: [{ type: "text" as const, text }],
@@ -15517,7 +17097,7 @@ export class AgentSession {
15517
17097
  : text;
15518
17098
  await this.refreshGjcSubskillTools();
15519
17099
  if (message.role === "custom") await this.#syncSkillPromptActiveStateSafely(message, true);
15520
- if (selected) {
17100
+ if (committedSelection) {
15521
17101
  const displayTag = message.role === "custom" ? readPendingDisplayTag(message.details) : undefined;
15522
17102
  if (displayTag) this.#displayDequeueAlreadyHandled = { role: "custom", tag: displayTag };
15523
17103
  else if (message.role === "user")
@@ -15529,7 +17109,7 @@ export class AgentSession {
15529
17109
  resetRetryReplaySafety: true,
15530
17110
  sdkRunToken: selectedSdkRunToken,
15531
17111
  skipInitialSteeringPoll: heldSteering.length > 0,
15532
- onRunAccepted: () => {
17112
+ onRunAccepted: (handle: AttemptRunHandle) => {
15533
17113
  runAccepted = true;
15534
17114
  if (heldSteering.length > 0) {
15535
17115
  // Re-admit the aborted turn's steers into the replacement run the
@@ -15541,10 +17121,17 @@ export class AgentSession {
15541
17121
  this.agent.restoreSteering(heldSteering);
15542
17122
  this.#steeringMessages = [...heldSteeringDisplays, ...this.#steeringMessages];
15543
17123
  }
15544
- if (selected) this.#fireQueuedPromotionHooks([message], { startsOwnRun: true });
15545
- if (selected) {
15546
- this.#steeringMessages = this.#steeringMessages.filter(entry => entry !== selected.display);
15547
- this.#followUpMessages = this.#followUpMessages.filter(entry => entry !== selected.display);
17124
+ if (committedSelection) {
17125
+ this.#fireQueuedPromotionHooks([message], { startsOwnRun: true });
17126
+ this.#settleTrackedOwnRunPromotions(handle);
17127
+ }
17128
+ if (committedSelection) {
17129
+ this.#steeringMessages = this.#steeringMessages.filter(
17130
+ entry => entry !== committedSelection.display,
17131
+ );
17132
+ this.#followUpMessages = this.#followUpMessages.filter(
17133
+ entry => entry !== committedSelection.display,
17134
+ );
15548
17135
  }
15549
17136
  },
15550
17137
  });
@@ -15559,6 +17146,7 @@ export class AgentSession {
15559
17146
  if (runAccepted) {
15560
17147
  return { kind: "submitted" };
15561
17148
  }
17149
+ revalidateSelected();
15562
17150
  this.#displayDequeueAlreadyHandled = undefined;
15563
17151
  restore();
15564
17152
  logger.error("Cancel-and-submit prompt failed before run acceptance", { cause });
@@ -15659,17 +17247,23 @@ export class AgentSession {
15659
17247
  const noLeasePreviousSessionIdentity = this.sessionManager.getSessionId();
15660
17248
  const noLeasePreviousSessionFile = this.sessionManager.getSessionFile();
15661
17249
  const prepared = await this.sessionManager.prepareNewSession(options);
17250
+ let exactRetirement: PreparedExactMcpRetirement | undefined;
15662
17251
  try {
15663
17252
  // Last fallible gate while public getters still show the predecessor (#3138).
15664
17253
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
15665
17254
  this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
17255
+ exactRetirement = await this.#prepareExactMcpControlRetirement();
17256
+ if (exactRetirement && !exactRetirement.canCommit())
17257
+ throw new Error("Exact MCP control retirement changed before session adoption");
15666
17258
  this.sessionManager.commitPreparedNewSession(prepared);
17259
+ await exactRetirement?.commit();
15667
17260
  // Endpoint identity committed to the successor: re-register the
15668
17261
  // manager so post-transition lineage bindings resolve and owned
15669
17262
  // aborts classify in the successor session (review thread P1).
15670
17263
  this.#rekeyJobManagerForSessionIdentity(noLeasePreviousSessionIdentity, noLeasePreviousSessionFile);
15671
17264
  await this.#runToolSessionTransitionCleanups();
15672
17265
  } catch (error) {
17266
+ await exactRetirement?.abort();
15673
17267
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
15674
17268
  }
15675
17269
  this.setTodoPhases([]);
@@ -15735,17 +17329,23 @@ export class AgentSession {
15735
17329
  throw new Error("Owned async jobs did not settle before session replacement.");
15736
17330
  }
15737
17331
  const prepared = await this.sessionManager.prepareNewSession(options);
17332
+ let exactRetirement: PreparedExactMcpRetirement | undefined;
15738
17333
  try {
15739
17334
  // Last fallible gate while public getters still show the predecessor (#3138).
15740
17335
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
15741
17336
  this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
17337
+ exactRetirement = await this.#prepareExactMcpControlRetirement();
17338
+ if (exactRetirement && !exactRetirement.canCommit())
17339
+ throw new Error("Exact MCP control retirement changed before session adoption");
15742
17340
  this.sessionManager.commitPreparedNewSession(prepared);
17341
+ await exactRetirement?.commit();
15743
17342
  // Endpoint identity committed to the successor: re-register the
15744
17343
  // manager so post-transition lineage bindings resolve and owned
15745
17344
  // aborts classify in the successor session (review thread P1).
15746
17345
  this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionIdentityFile);
15747
17346
  await this.#runToolSessionTransitionCleanups();
15748
17347
  } catch (error) {
17348
+ await exactRetirement?.abort();
15749
17349
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
15750
17350
  }
15751
17351
  this.#disconnectFromAgent();
@@ -15851,11 +17451,13 @@ export class AgentSession {
15851
17451
  this.#reconnectToAgent();
15852
17452
  this.#resetIrcRosterDeliveryState();
15853
17453
  if (this.#extensionRunner) {
15854
- await this.#extensionRunner.emit({
15855
- type: "session_switch",
15856
- reason: "new",
15857
- previousSessionFile,
15858
- });
17454
+ await this.#runCommittedSuccessorHook(() =>
17455
+ this.#extensionRunner!.emit({
17456
+ type: "session_switch",
17457
+ reason: "new",
17458
+ previousSessionFile,
17459
+ }),
17460
+ );
15859
17461
  } else {
15860
17462
  }
15861
17463
  }
@@ -15923,6 +17525,17 @@ export class AgentSession {
15923
17525
  const previousSessionFile = this.sessionFile;
15924
17526
  const previousWorkflowGateSessionId = this.sessionId;
15925
17527
  const previousSessionIdentity = this.sessionManager.getSessionId();
17528
+ const predecessorQueuedSdkWork = this.#queuedMessagesForSessionTransition();
17529
+ const settleForkPredecessorWork = (): void => {
17530
+ this.#terminalizeQueuedSdkWorkForSessionTransition(predecessorQueuedSdkWork);
17531
+ this.#deferredSdkFollowUps = [];
17532
+ this.#pendingNextTurnMessages = [];
17533
+ this.#scheduledHiddenNextTurnGeneration = undefined;
17534
+ this.agent.clearAllQueues();
17535
+ this.#steeringMessages = [];
17536
+ this.#followUpMessages = [];
17537
+ this.#resetActiveSdkRunOwnership();
17538
+ };
15926
17539
 
15927
17540
  // Emit session_before_switch event with reason "fork" (can be cancelled)
15928
17541
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
@@ -15936,6 +17549,10 @@ export class AgentSession {
15936
17549
  }
15937
17550
  }
15938
17551
 
17552
+ this.#settleTrackedQueuedInputsBeforeAgentDisconnect();
17553
+ this.#disconnectFromAgent();
17554
+ await this.#settleActivePromptForHistoryTransition();
17555
+
15939
17556
  // Flush current session to ensure all entries are written
15940
17557
  await this.sessionManager.flush();
15941
17558
 
@@ -15951,6 +17568,8 @@ export class AgentSession {
15951
17568
  "copy-retain",
15952
17569
  this.settings.get("sessionMemory.mode"),
15953
17570
  );
17571
+ let exactRetirement: PreparedExactMcpRetirement | undefined;
17572
+ let adopted = false;
15954
17573
  try {
15955
17574
  await initializeLocalRoot({
15956
17575
  getArtifactsDir: () => forkedManager.getArtifactsDir(),
@@ -15960,7 +17579,15 @@ export class AgentSession {
15960
17579
  });
15961
17580
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
15962
17581
  this.#assertJobManagerEndpointAdmission(forkedManager.getSessionId(), forkedManager.getSessionFile());
17582
+ exactRetirement = await this.#prepareExactMcpControlRetirement();
17583
+ if (exactRetirement && !exactRetirement.canCommit())
17584
+ throw new Error("Exact MCP control retirement changed before session adoption");
17585
+ this.sessionManager = forkedManager;
17586
+ adopted = true;
17587
+ await exactRetirement?.commit();
15963
17588
  } catch (error) {
17589
+ await exactRetirement?.abort();
17590
+ if (adopted) this.sessionManager = previousManager;
15964
17591
  const forkedFile = forkedManager.getSessionFile();
15965
17592
  const cleanupErrors: unknown[] = [];
15966
17593
  try {
@@ -15989,8 +17616,8 @@ export class AgentSession {
15989
17616
  }
15990
17617
  throw error;
15991
17618
  }
15992
- this.sessionManager = forkedManager;
15993
17619
  this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
17620
+ settleForkPredecessorWork();
15994
17621
  try {
15995
17622
  await previousManager.close();
15996
17623
  } catch (error) {
@@ -16004,16 +17631,23 @@ export class AgentSession {
16004
17631
  // public manager getters remain bound to the predecessor.
16005
17632
  const prepared = await this.sessionManager.prepareFork();
16006
17633
  if (!prepared) return false;
17634
+ let exactRetirement: PreparedExactMcpRetirement | undefined;
16007
17635
  try {
16008
17636
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
16009
17637
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
16010
17638
  this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
17639
+ exactRetirement = await this.#prepareExactMcpControlRetirement();
17640
+ if (exactRetirement && !exactRetirement.canCommit())
17641
+ throw new Error("Exact MCP control retirement changed before session adoption");
16011
17642
  this.sessionManager.commitPreparedNewSession(prepared);
17643
+ await exactRetirement?.commit();
16012
17644
  // Fork commits a successor endpoint identity; re-register the
16013
17645
  // manager under it (review thread P1).
16014
17646
  this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
17647
+ settleForkPredecessorWork();
16015
17648
  await this.#runToolSessionTransitionCleanups();
16016
17649
  } catch (error) {
17650
+ await exactRetirement?.abort();
16017
17651
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
16018
17652
  }
16019
17653
  }
@@ -16025,15 +17659,18 @@ export class AgentSession {
16025
17659
 
16026
17660
  // Emit session_switch event with reason "fork" to hooks
16027
17661
  if (this.#extensionRunner) {
16028
- await this.#extensionRunner.emit({
16029
- type: "session_switch",
16030
- reason: "fork",
16031
- previousSessionFile,
16032
- });
17662
+ await this.#runCommittedSuccessorHook(() =>
17663
+ this.#extensionRunner!.emit({
17664
+ type: "session_switch",
17665
+ reason: "fork",
17666
+ previousSessionFile,
17667
+ }),
17668
+ );
16033
17669
  }
16034
17670
 
16035
17671
  return true;
16036
17672
  } finally {
17673
+ this.#reconnectToAgent();
16037
17674
  this.#endSessionTransition();
16038
17675
  }
16039
17676
  }
@@ -16142,15 +17779,42 @@ export class AgentSession {
16142
17779
  await this.refreshBaseSystemPrompt();
16143
17780
  }
16144
17781
 
16145
- /** Resolver intent only for assignments owned by the active profile. */
17782
+ /** Resolver intent only for default assignments owned by an active or runtime-recovered durable profile. */
16146
17783
  #persistedModelProfileAliasIntent(role: string): { aliasIntent: "preset-equivalent" } | undefined {
16147
- if (!this.#activeModelProfile) return undefined;
16148
- const profile = this.#modelRegistry.getModelProfile?.(this.#activeModelProfile);
17784
+ const runtimeDefaultIdentity = this.#defaultFallbackController?.chain;
17785
+ const profileName =
17786
+ this.#activeModelProfile ??
17787
+ (role === "default" &&
17788
+ runtimeDefaultIdentity?.origin === "runtime" &&
17789
+ runtimeDefaultIdentity.identity === this.settings.get("modelProfile.default")
17790
+ ? runtimeDefaultIdentity.identity
17791
+ : undefined);
17792
+ if (!profileName) return undefined;
17793
+ const profile = this.#modelRegistry.getModelProfile?.(profileName);
16149
17794
  if (!profile) return undefined;
16150
17795
  const bindings = resolveProfileBindings(profile);
17796
+ const configuredDefault = role === "default" ? this.getConfiguredModelChain("default")?.[0] : undefined;
17797
+ const profileDefault = Array.isArray(bindings.defaultSelector)
17798
+ ? bindings.defaultSelector[0]
17799
+ : bindings.defaultSelector;
17800
+ const configuredDefaultIdentity = configuredDefault
17801
+ ? splitSelectorThinkingSuffix(configuredDefault).selector.trim().toLowerCase()
17802
+ : undefined;
17803
+ const profileDefaultIdentity = profileDefault
17804
+ ? splitSelectorThinkingSuffix(profileDefault).selector.trim().toLowerCase()
17805
+ : undefined;
17806
+ const configuredDefaultModel = configuredDefaultIdentity
17807
+ ? parseModelString(configuredDefaultIdentity)
17808
+ : undefined;
17809
+ const profileDefaultModel = profileDefaultIdentity ? parseModelString(profileDefaultIdentity) : undefined;
17810
+ const ownsConfiguredDefault =
17811
+ configuredDefaultModel && profileDefaultModel
17812
+ ? configuredDefaultModel.provider === profileDefaultModel.provider &&
17813
+ configuredDefaultModel.id === profileDefaultModel.id
17814
+ : profileDefaultIdentity !== undefined && configuredDefaultIdentity === profileDefaultIdentity;
16151
17815
  const owned =
16152
17816
  role === "default"
16153
- ? bindings.defaultSelector !== undefined
17817
+ ? runtimeDefaultIdentity?.origin === "runtime" || ownsConfiguredDefault
16154
17818
  : Object.hasOwn(bindings.modelRoles, role) || Object.hasOwn(bindings.agentModelOverrides, role);
16155
17819
  return owned ? { aliasIntent: "preset-equivalent" } : undefined;
16156
17820
  }
@@ -16172,9 +17836,10 @@ export class AgentSession {
16172
17836
  * configured `modelBindings` (also installed into these two override slots
16173
17837
  * once at startup) are not profile-owned and must survive the transition.
16174
17838
  */
16175
- #resetSessionScopedModelProfileState(): void {
17839
+ #resetSessionScopedModelProfileState(options?: { preserveDefaultConfiguredChain?: boolean; force?: boolean }): void {
16176
17840
  const persistedProfile = this.settings.get("modelProfile.default");
16177
- if (persistedProfile !== undefined && persistedProfile === this.getActiveModelProfile()) return;
17841
+ if (!options?.force && persistedProfile !== undefined && persistedProfile === this.getActiveModelProfile())
17842
+ return;
16178
17843
  const hadInstalledKeys =
16179
17844
  this.#activeProfileInstalledRoles.size > 0 || this.#activeProfileInstalledAgentOverrides.size > 0;
16180
17845
  if (hadInstalledKeys) {
@@ -16197,7 +17862,7 @@ export class AgentSession {
16197
17862
  this.#modelRegistry.reapplyConfiguredModelBindings(this.settings);
16198
17863
  }
16199
17864
  const defaultChain = getSessionContextForInternalRead(this.sessionManager).configuredModelChains.default;
16200
- if (defaultChain && defaultChain.identity !== undefined) {
17865
+ if (!options?.preserveDefaultConfiguredChain && defaultChain && defaultChain.identity !== undefined) {
16201
17866
  this.setConfiguredModelChain("default", [], "user-selection");
16202
17867
  }
16203
17868
  this.setActiveModelProfile(undefined);
@@ -16439,11 +18104,36 @@ export class AgentSession {
16439
18104
  * must not mutate the persisted configured intent.
16440
18105
  */
16441
18106
  setDefaultFallbackRuntimeModel(selector: string): void {
18107
+ this.installRecoveredDefaultFallbackChain([selector], undefined, 0, []);
18108
+ }
18109
+
18110
+ /** Install a runtime-only durable fallback without changing saved session intent. */
18111
+ installRecoveredDefaultFallbackChain(
18112
+ entries: readonly string[],
18113
+ identity: string | undefined,
18114
+ activeIndex: number,
18115
+ skips: Array<{ selector: string; reason: string }>,
18116
+ options?: { emitResolutionEvent?: boolean },
18117
+ ): void {
16442
18118
  this.#defaultFallbackController = new FallbackChainController(
16443
- { role: "default", entries: [selector], origin: "runtime", explicitHead: true },
18119
+ { role: "default", entries: [...entries], origin: "runtime", identity, explicitHead: true },
16444
18120
  this.settings.get("fallback.maxAttempts"),
16445
18121
  );
16446
18122
  this.#defaultFallbackExhaustedLastTurn = false;
18123
+ this.#seedDefaultFallbackResolutionForController(
18124
+ this.#defaultFallbackController,
18125
+ activeIndex,
18126
+ skips,
18127
+ options?.emitResolutionEvent ?? true,
18128
+ );
18129
+ }
18130
+
18131
+ markStartupRecoveryBindingsRequired(): void {
18132
+ this.#startupRecoveryBindingsRequired = true;
18133
+ }
18134
+
18135
+ hasRecoveredDefaultFallbackChain(): boolean {
18136
+ return this.#startupRecoveryBindingsRequired || this.#defaultFallbackController?.chain.origin === "runtime";
16447
18137
  }
16448
18138
 
16449
18139
  /**
@@ -16451,9 +18141,18 @@ export class AgentSession {
16451
18141
  * The configured chain's role, origin, and identity are retained by the controller.
16452
18142
  */
16453
18143
  seedDefaultFallbackResolution(activeIndex: number, skips: Array<{ selector: string; reason: string }>): void {
16454
- const controller = this.#defaultFallbackChain();
18144
+ this.#seedDefaultFallbackResolutionForController(this.#defaultFallbackChain(), activeIndex, skips);
18145
+ }
18146
+
18147
+ /** Seed an already-selected runtime controller without legacy-chain materialization. */
18148
+ #seedDefaultFallbackResolutionForController(
18149
+ controller: FallbackChainController,
18150
+ activeIndex: number,
18151
+ skips: Array<{ selector: string; reason: string }>,
18152
+ emitResolutionEvent = true,
18153
+ ): void {
16455
18154
  controller.seedResolution(activeIndex, skips);
16456
- this.#emitResolutionFallbackSwitch(controller);
18155
+ if (emitResolutionEvent) this.#emitResolutionFallbackSwitch(controller);
16457
18156
  }
16458
18157
 
16459
18158
  getDefaultFallbackRuntimeState(): DefaultFallbackRuntimeState {
@@ -16531,6 +18230,7 @@ export class AgentSession {
16531
18230
  reason?: TemporaryModelReason;
16532
18231
  providerSessionScope?: TemporaryProviderSessionScope;
16533
18232
  signal?: AbortSignal;
18233
+ onMutationStarted?: () => void;
16534
18234
  },
16535
18235
  // biome-ignore lint/suspicious/noConfusingVoidType: Existing session adapters return Promise<void>; a scope is optional.
16536
18236
  ): Promise<TemporaryProviderSessionScope | void> {
@@ -16548,6 +18248,7 @@ export class AgentSession {
16548
18248
  throw new Error(`No API key for ${model.provider}/${model.id}`);
16549
18249
  }
16550
18250
  if (suppliedScope && this.#temporaryProviderSessionScopes.at(-1)?.token !== suppliedScope) return;
18251
+ options?.onMutationStarted?.();
16551
18252
 
16552
18253
  const isTemporaryOperation = options?.cause === undefined || options.cause === "temporary-operation";
16553
18254
  const autoCreateScope = isTemporaryOperation && !suppliedScope;
@@ -16599,13 +18300,18 @@ export class AgentSession {
16599
18300
  model: Model | undefined,
16600
18301
  thinkingLevel: ThinkingLevel | undefined,
16601
18302
  ): Promise<void> {
18303
+ const previousEditMode = this.#resolveActiveEditMode();
16602
18304
  if (model) {
16603
- await this.setModelTemporary(model, thinkingLevel, { cause: "rollback", reason: "other" });
16604
- return;
18305
+ // Restoring a captured live model is compensation, not a new selection:
18306
+ // the old credential may have disappeared after the forward mutation.
18307
+ this.#setModelAuthoritatively(model, "rollback");
18308
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "temporary");
18309
+ this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
18310
+ } else {
18311
+ this.#clearActiveRetryFallback();
18312
+ this.#setModelWithProviderSessionReset(undefined);
18313
+ this.#syncAppendOnlyContext(undefined);
16605
18314
  }
16606
- const previousEditMode = this.#resolveActiveEditMode();
16607
- this.#clearActiveRetryFallback();
16608
- this.#setModelWithProviderSessionReset(undefined);
16609
18315
  this.setThinkingLevel(thinkingLevel);
16610
18316
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
16611
18317
  }
@@ -17942,6 +19648,14 @@ export class AgentSession {
17942
19648
  */
17943
19649
  async compact(customInstructions?: string, options?: CompactOptions): Promise<CompactionResult> {
17944
19650
  this.#assertSessionAdmissionOpen();
19651
+ // Automatic context maintenance owns the transition lease while its
19652
+ // post-append hooks run. A manual request must join behind that owner so
19653
+ // it can cancel/wait for the automatic work rather than racing its lease.
19654
+ if (this.#sessionTransitionKind === "auto-compaction") {
19655
+ this.abortCompaction();
19656
+ await this.#waitForAutoCompactionCompletions();
19657
+ await this.#cancelPostPromptTasks();
19658
+ }
17945
19659
  // Serialize with every other session-identity transition via the shared lease
17946
19660
  // (bidirectional mutual exclusion with handoff/new/switch/branch/clear/fork/
17947
19661
  // navigateTree). Released in the outer finally below.
@@ -17954,11 +19668,66 @@ export class AgentSession {
17954
19668
  }
17955
19669
  const compactionAbortController = new AbortController();
17956
19670
  this.#compactionAbortController = compactionAbortController;
19671
+ const steeringBeforeCompaction = this.agent.snapshotSteering();
19672
+ const steeringDisplaysBeforeCompaction = [...this.#steeringMessages];
19673
+ const trackedSteersBeforeCompaction = new Map(
19674
+ [...this.#trackedQueuedInputs.values()]
19675
+ .filter(state => state.message !== undefined && steeringBeforeCompaction.includes(state.message))
19676
+ .map(state => [state.message as AgentMessage, state] as const),
19677
+ );
19678
+ const followUpBeforeCompaction = this.agent.snapshotFollowUp();
19679
+ const followUpDisplaysBeforeCompaction = [...this.#followUpMessages];
19680
+ const trackedFollowUpsBeforeCompaction = new Map(
19681
+ [...this.#trackedQueuedInputs.values()]
19682
+ .filter(state => state.message !== undefined && followUpBeforeCompaction.includes(state.message))
19683
+ .map(state => [state.message as AgentMessage, state] as const),
19684
+ );
19685
+ // Compaction intentionally preserves still-queued inputs, but it drops the
19686
+ // Agent event bridge before aborting the active run. Settle submissions that
19687
+ // already crossed the queue boundary now; they cannot receive agent_end later.
19688
+ this.#settleTrackedQueuedInputsBeforeAgentDisconnect();
17957
19689
  this.#disconnectFromAgent();
17958
19690
  try {
17959
- await this.abort({ cause: "compaction", preserveCompaction: true });
19691
+ try {
19692
+ await this.abort({ cause: "compaction", preserveCompaction: true });
19693
+ } finally {
19694
+ const steeringAfterAbort = new Set(this.agent.snapshotSteering());
19695
+ const missingSteering = steeringBeforeCompaction.filter(message => !steeringAfterAbort.has(message));
19696
+ const restorableSteering: AgentMessage[] = [];
19697
+ for (const message of missingSteering) {
19698
+ const trackedState = trackedSteersBeforeCompaction.get(message);
19699
+ if (trackedState && (trackedState.terminalSettled || trackedState.cancelRequested)) continue;
19700
+ restorableSteering.push(message);
19701
+ }
19702
+ if (restorableSteering.length > 0) this.agent.restoreSteering(restorableSteering);
19703
+ this.#reconcileCompactionQueueDisplay(steeringDisplaysBeforeCompaction, "steering");
19704
+ const followUpAfterAbort = new Set(this.agent.snapshotFollowUp());
19705
+ const missingFollowUp = followUpBeforeCompaction.filter(message => !followUpAfterAbort.has(message));
19706
+ const restorableFollowUp: AgentMessage[] = [];
19707
+ for (const message of missingFollowUp) {
19708
+ const trackedState = trackedFollowUpsBeforeCompaction.get(message);
19709
+ if (trackedState) {
19710
+ const state = trackedState;
19711
+ if (state && !state.terminalSettled) this.#settleTrackedQueuedInputRemoved(state, "removed");
19712
+ continue;
19713
+ }
19714
+ restorableFollowUp.push(message);
19715
+ }
19716
+ if (restorableFollowUp.length > 0) this.agent.restoreFollowUp(restorableFollowUp);
19717
+ this.#reconcileCompactionQueueDisplay(followUpDisplaysBeforeCompaction, "followUp");
19718
+ // The disconnected bridge cannot run finishAttempt for the aborted
19719
+ // predecessor. Do not let its SDK token or attempt identity bleed into
19720
+ // the first ordinary successor prompt.
19721
+ this.#resetActiveSdkRunOwnership();
19722
+ }
17960
19723
  } catch (error) {
17961
19724
  this.#compactionAbortController = undefined;
19725
+ if (!this.#isDisposed && !this.#sessionAdmissionClosing) {
19726
+ this.#reconnectToAgent();
19727
+ const releasedDeferred = this.#releaseDeferredSdkFollowUps();
19728
+ this.#compactionQueuedDeliveryArmed = this.agent.hasQueuedMessages();
19729
+ if (!releasedDeferred && this.#compactionQueuedDeliveryArmed) this.#scheduleQueuedDelivery();
19730
+ }
17962
19731
  throw error;
17963
19732
  }
17964
19733
  await this.#waitForAutoCompactionCompletions();
@@ -18106,7 +19875,12 @@ export class AgentSession {
18106
19875
  if (this.#compactionAbortController === compactionAbortController) {
18107
19876
  this.#compactionAbortController = undefined;
18108
19877
  }
18109
- if (!this.#isDisposed && !this.#sessionAdmissionClosing) this.#reconnectToAgent();
19878
+ if (!this.#isDisposed && !this.#sessionAdmissionClosing) {
19879
+ this.#reconnectToAgent();
19880
+ const releasedDeferred = this.#releaseDeferredSdkFollowUps();
19881
+ this.#compactionQueuedDeliveryArmed = this.agent.hasQueuedMessages();
19882
+ if (!releasedDeferred && this.#compactionQueuedDeliveryArmed) this.#scheduleQueuedDelivery();
19883
+ }
18110
19884
  }
18111
19885
  } finally {
18112
19886
  completion.resolve();
@@ -18350,6 +20124,7 @@ export class AgentSession {
18350
20124
  let savedPath: string | undefined;
18351
20125
  let committed = false;
18352
20126
  let prepared: PreparedNewSession | undefined;
20127
+ let exactRetirement: PreparedExactMcpRetirement | undefined;
18353
20128
  try {
18354
20129
  // Prepare successor entries, persistence, display state, and gate
18355
20130
  // construction without publishing manager identity. Managed local-root
@@ -18375,9 +20150,13 @@ export class AgentSession {
18375
20150
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
18376
20151
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
18377
20152
  this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
20153
+ exactRetirement = await this.#prepareExactMcpControlRetirement();
20154
+ if (exactRetirement && !exactRetirement.canCommit())
20155
+ throw new Error("Exact MCP control retirement changed before session adoption");
18378
20156
 
18379
20157
  // --- Commit boundary: synchronous adoption is the sole identity publication.
18380
20158
  this.sessionManager.commitPreparedNewSession(prepared);
20159
+ await exactRetirement?.commit();
18381
20160
  // Handoff commits a successor endpoint identity; re-register the
18382
20161
  // manager under it (review thread P1).
18383
20162
  this.#rekeyJobManagerForSessionIdentity(rollbackSessionState.sessionId, rollbackSessionState.sessionFile);
@@ -18387,6 +20166,7 @@ export class AgentSession {
18387
20166
  ...rollbackAgentSteeringQueue,
18388
20167
  ...rollbackAgentFollowUpQueue,
18389
20168
  ...rollbackDeferredSdkFollowUps,
20169
+ ...rollbackPendingNextTurnMessages.map(entry => entry.message),
18390
20170
  ]);
18391
20171
  this.#resetActiveSdkRunOwnership();
18392
20172
  this.agent.reset();
@@ -18437,11 +20217,13 @@ export class AgentSession {
18437
20217
  // errors are isolated by ExtensionRunner and must not roll back the
18438
20218
  // already-committed switch.
18439
20219
  if (this.#extensionRunner) {
18440
- await this.#extensionRunner.emit({
18441
- type: "session_switch",
18442
- reason: "new",
18443
- previousSessionFile,
18444
- });
20220
+ await this.#runCommittedSuccessorHook(() =>
20221
+ this.#extensionRunner!.emit({
20222
+ type: "session_switch",
20223
+ reason: "new",
20224
+ previousSessionFile,
20225
+ }),
20226
+ );
18445
20227
  }
18446
20228
 
18447
20229
  return { document: handoffText, savedPath };
@@ -18458,6 +20240,7 @@ export class AgentSession {
18458
20240
  // failure is non-destructive. Predecessor gate emitter, provider
18459
20241
  // sessions, async jobs, IRC/plan bookkeeping, and injection signatures
18460
20242
  // were never mutated before commit, so they survive intact.
20243
+ await exactRetirement?.abort();
18461
20244
  await this.sessionManager.restoreRollbackState(rollbackSessionState);
18462
20245
  this.#syncAgentSessionId(rollbackSessionState.sessionId);
18463
20246
  this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -19884,33 +21667,79 @@ export class AgentSession {
19884
21667
  addCandidate(this.#resolveRoleModelFull(role, availableModels, currentModel).model);
19885
21668
  }
19886
21669
 
19887
- // Last-resort fallback: the largest-context model that shares the ACTIVE
19888
- // model's provider. Scoping this to the current provider keeps auto-
19889
- // compaction on the user's configured/custom route instead of silently
19890
- // defaulting to an unrelated provider (e.g. a stray OpenAI credential
19891
- // with no remaining credit) just because it happens to be in the bundled
19892
- // catalog. Cross-provider compaction stays possible, but only when the
19893
- // user opts in explicitly via modelRoles (handled by the loop above).
19894
- const fallbackProvider = currentModel?.provider;
19895
- const sortedByContext = [...availableModels]
19896
- .filter(model => fallbackProvider === undefined || model.provider === fallbackProvider)
19897
- .sort((a, b) => b.contextWindow - a.contextWindow);
19898
- for (const model of sortedByContext) {
19899
- if (!seen.has(this.#getModelKey(model))) {
19900
- addCandidate(model);
19901
- break;
19902
- }
19903
- }
19904
-
19905
21670
  return candidates;
19906
21671
  }
21672
+ #compactionCandidateSource(candidate: Model): string {
21673
+ const currentModel = this.model;
21674
+ if (currentModel && modelsAreEqual(candidate, currentModel)) return "active session model";
21675
+ for (const role of MODEL_ROLE_IDS) {
21676
+ const roleModel = this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), currentModel).model;
21677
+ if (roleModel && modelsAreEqual(candidate, roleModel)) return `modelRoles.${role}`;
21678
+ }
21679
+ return "configured compaction fallback";
21680
+ }
21681
+ #compactionFailureReason(error: unknown): string {
21682
+ const message = sanitizeCompactionCandidateFailureMessage(error instanceof Error ? error.message : String(error));
21683
+ if (/stream stalled while waiting for the next event/i.test(message)) {
21684
+ return "SSE stream stalled while waiting for the next event";
21685
+ }
21686
+ if (/timed? out while waiting for the first event/i.test(message)) {
21687
+ return "SSE stream timed out while waiting for the first event";
21688
+ }
21689
+ if (/auth_unavailable|no auth available|credential/i.test(message)) {
21690
+ return "candidate credentials unavailable";
21691
+ }
21692
+ return message.length > 160 ? `${message.slice(0, 157)}...` : message;
21693
+ }
21694
+ async #emitCompactionModelSubstitution(from: Model, to: Model, reason: string): Promise<void> {
21695
+ const fromKey = this.#getModelKey(from);
21696
+ const toKey = this.#getModelKey(to);
21697
+ const source = this.#compactionCandidateSource(to);
21698
+ try {
21699
+ await this.#emitSessionEvent({
21700
+ type: "notice",
21701
+ level: "warning",
21702
+ source: "compaction-recovery",
21703
+ message: `Compaction recovery switched model from ${fromKey} to ${toKey} (${source}) after ${reason}.`,
21704
+ });
21705
+ } catch (error) {
21706
+ logger.warn("Failed to publish compaction model substitution", {
21707
+ from: fromKey,
21708
+ to: toKey,
21709
+ error: String(error),
21710
+ });
21711
+ }
21712
+ }
21713
+ async #resolveCompactionCandidate(
21714
+ candidate: Model,
21715
+ previousCandidate: Model | undefined,
21716
+ previousFailure: unknown,
21717
+ ): Promise<{ apiKey: string; authError?: undefined } | { apiKey: undefined; authError: Error }> {
21718
+ const apiKey = await this.#modelRegistry.getApiKey(candidate, this.credentialSessionId);
21719
+ if (!apiKey) return { apiKey: undefined, authError: this.#buildCompactionAuthError(candidate) };
21720
+ if (previousCandidate) {
21721
+ await this.#emitCompactionModelSubstitution(
21722
+ previousCandidate,
21723
+ candidate,
21724
+ this.#compactionFailureReason(previousFailure),
21725
+ );
21726
+ }
21727
+ return { apiKey, authError: undefined };
21728
+ }
19907
21729
  #isCompactionAuthFailure(error: unknown): boolean {
19908
21730
  if (!(error instanceof Error)) return false;
21731
+ if (
21732
+ /stream stalled while waiting for the next event|timed? out while waiting for the first event/i.test(
21733
+ error.message,
21734
+ )
21735
+ ) {
21736
+ return false;
21737
+ }
19909
21738
  return /auth_unavailable|no auth available/i.test(error.message);
19910
21739
  }
19911
21740
 
19912
- #buildCompactionAuthError(): Error {
19913
- const currentModel = this.model;
21741
+ #buildCompactionAuthError(candidate?: Model): Error {
21742
+ const currentModel = candidate ?? this.model;
19914
21743
  if (!currentModel) {
19915
21744
  return new Error(
19916
21745
  "Compaction requires a model with usable credentials, but no authenticated compaction model is available.",
@@ -19960,10 +21789,20 @@ export class AgentSession {
19960
21789
  ): Promise<CompactionResult> {
19961
21790
  const candidates = this.#getCompactionModelCandidates(this.#modelRegistry.getAvailable());
19962
21791
  const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
21792
+ let previousCandidate: Model | undefined;
21793
+ let previousFailure: unknown;
19963
21794
 
19964
21795
  for (const candidate of candidates) {
19965
- const apiKey = await this.#modelRegistry.getApiKey(candidate, this.credentialSessionId);
19966
- if (!apiKey) continue;
21796
+ const { apiKey, authError } = await this.#resolveCompactionCandidate(
21797
+ candidate,
21798
+ previousCandidate,
21799
+ previousFailure,
21800
+ );
21801
+ if (apiKey === undefined) {
21802
+ previousCandidate = candidate;
21803
+ previousFailure = authError;
21804
+ continue;
21805
+ }
19967
21806
 
19968
21807
  try {
19969
21808
  return await compact(preparation, candidate, apiKey, customInstructions, signal, {
@@ -19981,10 +21820,12 @@ export class AgentSession {
19981
21820
  if (!this.#isCompactionAuthFailure(error)) {
19982
21821
  throw error;
19983
21822
  }
21823
+ previousCandidate = candidate;
21824
+ previousFailure = this.#buildCompactionAuthError(candidate);
19984
21825
  }
19985
21826
  }
19986
21827
 
19987
- throw this.#buildCompactionAuthError();
21828
+ throw previousFailure instanceof Error ? previousFailure : this.#buildCompactionAuthError();
19988
21829
  }
19989
21830
 
19990
21831
  async #prepareCompactionFromHooks(
@@ -20144,6 +21985,15 @@ export class AgentSession {
20144
21985
  let action: "context-full" | "handoff" =
20145
21986
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
20146
21987
  const continueAfterMaintenance = options?.continueAfterMaintenance !== false;
21988
+ let ownsAutoCompactionTransition = false;
21989
+ const acquireAutoCompactionTransition = (): boolean => {
21990
+ if (ownsAutoCompactionTransition) return true;
21991
+ if (this.#sessionTransitionKind !== undefined || this.#cancelAndSubmitInProgress) return false;
21992
+ this.#beginSessionTransition("auto-compaction");
21993
+ ownsAutoCompactionTransition = true;
21994
+ return true;
21995
+ };
21996
+ if (action === "context-full" && !acquireAutoCompactionTransition()) return { kind: "skipped" };
20147
21997
  // Register the controller before the observable start event so an abort from
20148
21998
  // a local subscriber, extension, dispose, or caller signal owns this run.
20149
21999
  this.#autoCompactionAbortController?.abort();
@@ -20168,12 +22018,21 @@ export class AgentSession {
20168
22018
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
20169
22019
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
20170
22020
  if (autoCompactionSignal.aborted) return await emitAborted();
20171
- const compactionStateSnapshot = await this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true });
20172
- if (autoCompactionSignal.aborted || this.#isDisposed || this.#promptGeneration !== generation) {
20173
- return await emitAborted();
20174
- }
22021
+ // Start the workflow projection in parallel with the synchronous compaction
22022
+ // preparation. Overflow recovery often has no eligible history after the
22023
+ // failed assistant is removed; waiting for this filesystem projection before
22024
+ // discovering that no-op would delay the terminal auto_compaction_end event.
22025
+ // The promise is still awaited below so zero-progress tracking remains a
22026
+ // completed side effect for every compaction observation.
22027
+ const compactionStateSnapshotPromise = this.#compactionStateSnapshot({
22028
+ trackWorkflowRecoveryProgress: true,
22029
+ });
20175
22030
 
20176
22031
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
22032
+ await compactionStateSnapshotPromise;
22033
+ if (autoCompactionSignal.aborted || this.#isDisposed || this.#promptGeneration !== generation) {
22034
+ return await emitAborted();
22035
+ }
20177
22036
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
20178
22037
  const handoffResult = await this.handoff(handoffFocus, {
20179
22038
  autoTriggered: true,
@@ -20196,6 +22055,7 @@ export class AgentSession {
20196
22055
  reason,
20197
22056
  });
20198
22057
  action = "context-full";
22058
+ if (!acquireAutoCompactionTransition()) return { kind: "skipped" };
20199
22059
  }
20200
22060
  if (autoCompactionSignal.aborted) return await emitAborted();
20201
22061
 
@@ -20218,28 +22078,14 @@ export class AgentSession {
20218
22078
  }
20219
22079
 
20220
22080
  if (!this.model) {
20221
- await this.#emitSessionEvent({
20222
- type: "auto_compaction_end",
20223
- action,
20224
- result: undefined,
20225
- aborted: false,
20226
- willRetry: false,
20227
- skipped: true,
20228
- });
20229
- return { kind: "skipped" };
22081
+ await compactionStateSnapshotPromise;
22082
+ throw new Error("No model selected");
20230
22083
  }
20231
22084
 
20232
22085
  const availableModels = this.#modelRegistry.getAvailable();
20233
22086
  if (availableModels.length === 0) {
20234
- await this.#emitSessionEvent({
20235
- type: "auto_compaction_end",
20236
- action,
20237
- result: undefined,
20238
- aborted: false,
20239
- willRetry: false,
20240
- skipped: true,
20241
- });
20242
- return { kind: "skipped" };
22087
+ await compactionStateSnapshotPromise;
22088
+ throw this.#buildCompactionAuthError(this.model);
20243
22089
  }
20244
22090
 
20245
22091
  if (autoCompactionSignal.aborted) return await emitAborted();
@@ -20287,6 +22133,7 @@ export class AgentSession {
20287
22133
  skipped: true,
20288
22134
  continuationSkipReason,
20289
22135
  });
22136
+ await compactionStateSnapshotPromise;
20290
22137
  if (overflowNoopWouldReplay) {
20291
22138
  if (continueAfterMaintenance && this.agent.hasQueuedMessages()) {
20292
22139
  this.#scheduleAgentContinue({
@@ -20327,6 +22174,11 @@ export class AgentSession {
20327
22174
  return { kind: "skipped" };
20328
22175
  }
20329
22176
 
22177
+ const compactionStateSnapshot = await compactionStateSnapshotPromise;
22178
+ if (autoCompactionSignal.aborted || this.#isDisposed || this.#promptGeneration !== generation) {
22179
+ return await emitAborted();
22180
+ }
22181
+
20330
22182
  let hookCompaction: CompactionResult | undefined;
20331
22183
  let fromExtension = false;
20332
22184
  let preserveData: Record<string, unknown> | undefined;
@@ -20421,14 +22273,29 @@ export class AgentSession {
20421
22273
  const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
20422
22274
  let compactResult: CompactionResult | undefined;
20423
22275
  let lastError: unknown;
20424
- // Every candidate that was tried and failed, in order. Only the last failure
20425
- // used to reach the user, which named whichever same-provider fallback the
20426
- // chain ended on and hid that the session model itself had already failed.
22276
+ // Every candidate that was tried and failed, in order. Keep the first
22277
+ // failure visible, and retain candidate-specific diagnostics for explicit
22278
+ // role fallbacks instead of collapsing the chain to its final error.
20427
22279
  const candidateFailures: Array<{ model: string; message: string }> = [];
22280
+ let previousCandidate: Model | undefined;
22281
+ let previousFailure: unknown;
20428
22282
 
20429
22283
  for (const candidate of candidates) {
20430
- const apiKey = await this.#modelRegistry.getApiKey(candidate, this.credentialSessionId);
20431
- if (!apiKey) continue;
22284
+ const { apiKey, authError } = await this.#resolveCompactionCandidate(
22285
+ candidate,
22286
+ previousCandidate,
22287
+ previousFailure,
22288
+ );
22289
+ if (apiKey === undefined) {
22290
+ lastError = authError;
22291
+ previousCandidate = candidate;
22292
+ previousFailure = authError;
22293
+ candidateFailures.push({
22294
+ model: `${candidate.provider}/${candidate.id}`,
22295
+ message: authError.message,
22296
+ });
22297
+ continue;
22298
+ }
20432
22299
 
20433
22300
  let attempt = 0;
20434
22301
  while (true) {
@@ -20457,8 +22324,14 @@ export class AgentSession {
20457
22324
 
20458
22325
  const message = error instanceof Error ? error.message : String(error);
20459
22326
  if (this.#isCompactionAuthFailure(error)) {
20460
- lastError = this.#buildCompactionAuthError();
20461
- candidateFailures.push({ model: `${candidate.provider}/${candidate.id}`, message });
22327
+ const authError = this.#buildCompactionAuthError(candidate);
22328
+ lastError = authError;
22329
+ previousCandidate = candidate;
22330
+ previousFailure = authError;
22331
+ candidateFailures.push({
22332
+ model: `${candidate.provider}/${candidate.id}`,
22333
+ message: authError.message,
22334
+ });
20462
22335
  break;
20463
22336
  }
20464
22337
  const retryAfterMs = this.#parseRetryAfterMsFromError(message);
@@ -20471,6 +22344,8 @@ export class AgentSession {
20471
22344
  isUsageLimitError(message));
20472
22345
  if (!shouldRetry) {
20473
22346
  lastError = error;
22347
+ previousCandidate = candidate;
22348
+ previousFailure = error;
20474
22349
  candidateFailures.push({ model: `${candidate.provider}/${candidate.id}`, message });
20475
22350
  break;
20476
22351
  }
@@ -20496,6 +22371,8 @@ export class AgentSession {
20496
22371
  model: `${candidate.provider}/${candidate.id}`,
20497
22372
  });
20498
22373
  lastError = error;
22374
+ previousCandidate = candidate;
22375
+ previousFailure = error;
20499
22376
  candidateFailures.push({ model: `${candidate.provider}/${candidate.id}`, message });
20500
22377
  break; // Exit retry loop, continue to next candidate
20501
22378
  }
@@ -20643,6 +22520,7 @@ export class AgentSession {
20643
22520
  if (this.#autoCompactionAbortController === autoCompactionAbortController) {
20644
22521
  this.#autoCompactionAbortController = undefined;
20645
22522
  }
22523
+ if (ownsAutoCompactionTransition) this.#endSessionTransition();
20646
22524
  }
20647
22525
  }
20648
22526
 
@@ -20911,6 +22789,11 @@ export class AgentSession {
20911
22789
  if (message.errorMessage?.startsWith("Managed fallback retried the escaped non-ASCII")) return "terminal";
20912
22790
  if (message.stopReason !== "error") return "none";
20913
22791
  if (message.errorKind === "provider_safety_stop") return "terminal";
22792
+ // A decode loop is deterministic for the submitted context: replaying the
22793
+ // identical conversation re-trips the guard and re-bills the full context.
22794
+ // Without this the message carries no transport facts and would fall
22795
+ // through to "unknown", which is admitted for bounded retry (#5627).
22796
+ if (message.errorCode === REPETITION_GUARD_ERROR_CODE) return "terminal";
20914
22797
  if (message.errorKind === "local_snapshot_failure") return "local_snapshot";
20915
22798
  if (message.errorKind === "local_buffer_overflow") return "local_buffer_overflow";
20916
22799
  if (this.#isTypedFirstEventTimeout(message)) return "first_event_timeout";
@@ -21459,6 +23342,10 @@ export class AgentSession {
21459
23342
  if (classifyContextOverflow(message, transportFailure, this.model?.contextWindow ?? 0)) return undefined;
21460
23343
  const transport = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
21461
23344
  if (transport.class !== "other") return transport;
23345
+ // HTTP/2 observations explain a terminal failure; they do not authorize replay.
23346
+ if (transportFailure?.http2RstCode !== undefined || transportFailure?.nativeErrorCode !== undefined) {
23347
+ return undefined;
23348
+ }
21462
23349
  // Managed fallback receives authoritative transport facts from the request
21463
23350
  // boundary. Once those facts classify as other, error prose must not upgrade
21464
23351
  // the failure into an unbounded transient or quota retry.
@@ -23744,8 +25631,16 @@ export class AgentSession {
23744
25631
  const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
23745
25632
  const previousScheduledHiddenNextTurnGeneration = this.#scheduledHiddenNextTurnGeneration;
23746
25633
  const previousModel = this.model;
25634
+ const previousDefaultFallbackRuntimeState = this.getDefaultFallbackRuntimeState();
23747
25635
  const previousThinkingLevel = this.#thinkingLevel;
23748
25636
  const previousActiveModelProfile = this.#activeModelProfile;
25637
+ const previousModelRolesOverride = structuredClone(this.settings.getOverride("modelRoles"));
25638
+ const previousAgentModelOverridesOverride = structuredClone(
25639
+ this.settings.getOverride("task.agentModelOverrides"),
25640
+ );
25641
+ const previousActiveProfileInstalledRoles = new Map(this.#activeProfileInstalledRoles);
25642
+ const previousActiveProfileInstalledAgentOverrides = new Map(this.#activeProfileInstalledAgentOverrides);
25643
+ const previousPreProfileModel = this.#preProfileModel;
23749
25644
  const previousServiceTier = this.agent.serviceTier;
23750
25645
  const previousSelectedMCPToolNames = new Set(this.#selectedMCPToolNames);
23751
25646
  const previousTools = [...this.agent.state.tools];
@@ -23754,6 +25649,7 @@ export class AgentSession {
23754
25649
  const previousAgentSteeringQueue = this.agent.snapshotSteering();
23755
25650
  const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
23756
25651
  const previousDeferredSdkFollowUps = [...this.#deferredSdkFollowUps];
25652
+ const previousTrackedQueuedInputs = [...this.#trackedQueuedInputs.values()];
23757
25653
  const previousActiveSdkRunToken = this.#activeSdkRunToken;
23758
25654
  const previousActiveAttemptScope = this.#activeAttemptScope;
23759
25655
  const previousActiveLogicalRunId = this.#activeLogicalRunId;
@@ -23766,7 +25662,11 @@ export class AgentSession {
23766
25662
  ? this.#suspendWorkflowGateEmitter(previousSessionState.sessionId)
23767
25663
  : undefined;
23768
25664
  let unavailableDefaultChainMessage: string | undefined;
25665
+ let recoveredDefaultChainMessage: string | undefined;
25666
+ let recoveredThinkingLevel: ThinkingLevel | undefined;
25667
+ let durableDefaultRecoveryError: string | undefined;
23769
25668
  let transitionCleanupCommitted = false;
25669
+ let exactRetirement: PreparedExactMcpRetirement | undefined;
23770
25670
 
23771
25671
  try {
23772
25672
  await this.sessionManager.setSessionFile(sessionPath, {
@@ -23822,12 +25722,6 @@ export class AgentSession {
23822
25722
 
23823
25723
  const resumeModelBehavior = this.settings.get("session.resumeModelBehavior");
23824
25724
  const configuredDefaultChain = sessionContext.configuredModelChains.default;
23825
- const settingsDefaultEntries = normalizeModelSelectorValue(this.settings.getModelRole("default"));
23826
- const defaultEntries =
23827
- resumeModelBehavior === "useCurrentDefault"
23828
- ? settingsDefaultEntries
23829
- : (configuredDefaultChain?.entries ??
23830
- (sessionContext.models.default ? [sessionContext.models.default] : []));
23831
25725
  const profileDefinitions = this.#modelRegistry.getModelProfiles?.() ?? new Map<string, unknown>();
23832
25726
  const configuredProfileName = this.settings.get("modelProfile.default");
23833
25727
  const configuredProfileIdentity = configuredProfileName
@@ -23839,18 +25733,45 @@ export class AgentSession {
23839
25733
  const liveProfileIdentity = previousActiveModelProfile
23840
25734
  ? resolveModelProfileName(previousActiveModelProfile, profileDefinitions)
23841
25735
  : undefined;
23842
- this.#activeModelProfile =
25736
+ let targetActiveModelProfile =
23843
25737
  resumeModelBehavior === "useCurrentDefault"
23844
- ? liveProfileIdentity && profileDefinitions.has(liveProfileIdentity)
23845
- ? liveProfileIdentity
23846
- : configuredProfileIdentity && profileDefinitions.has(configuredProfileIdentity)
25738
+ ? switchingToDifferentSession
25739
+ ? configuredProfileIdentity && profileDefinitions.has(configuredProfileIdentity)
23847
25740
  ? configuredProfileIdentity
23848
25741
  : undefined
25742
+ : liveProfileIdentity && profileDefinitions.has(liveProfileIdentity)
25743
+ ? liveProfileIdentity
25744
+ : configuredProfileIdentity && profileDefinitions.has(configuredProfileIdentity)
25745
+ ? configuredProfileIdentity
25746
+ : undefined
23849
25747
  : configuredDefaultChain?.origin === "profile-activation" &&
23850
25748
  persistedProfileIdentity &&
23851
25749
  profileDefinitions.has(persistedProfileIdentity)
23852
25750
  ? persistedProfileIdentity
23853
25751
  : undefined;
25752
+ // Keep a durable profile's existing runtime layer when the successor
25753
+ // resolves to that same profile. A cross-file transition has no separate
25754
+ // profile activation to reinstall its role and delegation bindings. Any
25755
+ // session-only or different predecessor profile must still be removed
25756
+ // before resolving the successor's default chain.
25757
+ const retainsDurableProfileLayer =
25758
+ switchingToDifferentSession &&
25759
+ targetActiveModelProfile !== undefined &&
25760
+ targetActiveModelProfile === configuredProfileIdentity &&
25761
+ liveProfileIdentity === targetActiveModelProfile;
25762
+ let targetProfileRuntimeInstalled = !switchingToDifferentSession || retainsDurableProfileLayer;
25763
+ if (switchingToDifferentSession && !retainsDurableProfileLayer)
25764
+ this.#resetSessionScopedModelProfileState({
25765
+ preserveDefaultConfiguredChain: true,
25766
+ force: true,
25767
+ });
25768
+ const settingsDefaultEntries = normalizeModelSelectorValue(this.settings.getModelRole("default"));
25769
+ const defaultEntries =
25770
+ resumeModelBehavior === "useCurrentDefault"
25771
+ ? settingsDefaultEntries
25772
+ : (configuredDefaultChain?.entries ??
25773
+ (sessionContext.models.default ? [sessionContext.models.default] : []));
25774
+ this.#activeModelProfile = targetActiveModelProfile;
23854
25775
  this.#defaultFallbackController = undefined;
23855
25776
  if (defaultEntries.length > 0) {
23856
25777
  const resolution = await resolveModelChainWithAuth(
@@ -23864,14 +25785,77 @@ export class AgentSession {
23864
25785
  ...(this.#persistedModelProfileAliasIntent("default") ?? {}),
23865
25786
  },
23866
25787
  );
23867
- const controller = this.#defaultFallbackChain();
25788
+ let controller = this.#defaultFallbackChain();
23868
25789
  this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
23869
- if (!resolution.model) {
23870
- unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
23871
- throw new Error(unavailableDefaultChainMessage);
25790
+ let resolvedModel = resolution.model;
25791
+ if (!resolvedModel) {
25792
+ if (resumeModelBehavior === "keepSessionModel") {
25793
+ try {
25794
+ const recovery = await resolveMissingSessionModelRecovery({
25795
+ modelRegistry: this.#modelRegistry,
25796
+ settings: this.settings,
25797
+ defaultEntries,
25798
+ skips: resolution.skips,
25799
+ savedDefault: sessionContext.models.default,
25800
+ credentialSessionId: this.credentialSessionId,
25801
+ ...(this.#persistedModelProfileAliasIntent("default") ?? {}),
25802
+ });
25803
+ if (recovery?.model) {
25804
+ const installedProfileKeys =
25805
+ this.#activeProfileInstalledRoles.size > 0 ||
25806
+ this.#activeProfileInstalledAgentOverrides.size > 0;
25807
+ const recoveryNeedsBindings =
25808
+ switchingToDifferentSession ||
25809
+ targetActiveModelProfile !== recovery.profileName ||
25810
+ !installedProfileKeys;
25811
+ if (recoveryNeedsBindings) {
25812
+ if (!switchingToDifferentSession)
25813
+ this.#resetSessionScopedModelProfileState({
25814
+ preserveDefaultConfiguredChain: true,
25815
+ force: true,
25816
+ });
25817
+ await applyModelProfileRuntimeBindings({
25818
+ session: this,
25819
+ modelRegistry: this.#modelRegistry,
25820
+ settings: this.settings,
25821
+ profileName: recovery.profileName,
25822
+ });
25823
+ targetActiveModelProfile = recovery.profileName;
25824
+ targetProfileRuntimeInstalled = true;
25825
+ }
25826
+ this.installRecoveredDefaultFallbackChain(
25827
+ recovery.entries,
25828
+ recovery.profileName,
25829
+ recovery.activeIndex,
25830
+ recovery.skips,
25831
+ { emitResolutionEvent: false },
25832
+ );
25833
+ controller = this.#defaultFallbackChain(false);
25834
+ resolvedModel = recovery.model;
25835
+ recoveredThinkingLevel = recovery.explicitThinkingLevel ? recovery.thinkingLevel : undefined;
25836
+ recoveredDefaultChainMessage =
25837
+ "Saved session model is no longer registered; restored the durable default preset instead.";
25838
+ } else if (recovery) {
25839
+ durableDefaultRecoveryError = "durable default preset resolution failed";
25840
+ }
25841
+ } catch (error) {
25842
+ // The saved chain remains authoritative on recovery failure, but the
25843
+ // durable preset's diagnostic is actionable and must not disappear.
25844
+ logger.warn("Failed to recover saved session model through durable default", {
25845
+ error: safeErrorDescription(error),
25846
+ });
25847
+ durableDefaultRecoveryError = "durable default preset resolution failed";
25848
+ }
25849
+ }
25850
+ if (!resolvedModel) {
25851
+ unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
25852
+ if (durableDefaultRecoveryError)
25853
+ unavailableDefaultChainMessage += `; durable default preset recovery failed: ${durableDefaultRecoveryError}`;
25854
+ throw new Error(unavailableDefaultChainMessage);
25855
+ }
23872
25856
  }
23873
- if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
23874
- this.#setModelAuthoritatively(resolution.model, "restore");
25857
+ if (!this.model || !modelsAreEqual(this.model, resolvedModel)) {
25858
+ this.#setModelAuthoritatively(resolvedModel, "restore");
23875
25859
  }
23876
25860
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
23877
25861
  // No thinking-level write here: the recompute below is the single
@@ -23879,6 +25863,11 @@ export class AgentSession {
23879
25863
  // would append a stray thinking_level_change entry that flips
23880
25864
  // hasThinkingEntry and changes what the recompute restores.
23881
25865
  }
25866
+ // The saved chain may need its profile identity for alias resolution, but
25867
+ // a cross-file transition must not advertise that profile after its
25868
+ // runtime role layer was removed. Otherwise delegation prompt state and
25869
+ // actual role routing diverge.
25870
+ if (!targetProfileRuntimeInstalled) this.#activeModelProfile = undefined;
23882
25871
 
23883
25872
  const hasThinkingEntry = this.sessionManager
23884
25873
  .getBranch()
@@ -23887,10 +25876,17 @@ export class AgentSession {
23887
25876
  .getBranch()
23888
25877
  .some(entry => entry.type === "service_tier_change");
23889
25878
  const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
25879
+ const hasExplicitDefaultThinkingLevel = this.settings.has("defaultThinkingLevel");
23890
25880
  const configuredServiceTier = this.settings.get("serviceTier");
23891
- const persistedThinkingLevel = hasThinkingEntry
23892
- ? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
23893
- : defaultThinkingLevel;
25881
+ const sessionThinkingLevel = sessionContext.thinkingLevel as ThinkingLevel | undefined;
25882
+ const recoveredModelThinkingLevel =
25883
+ recoveredDefaultChainMessage && !hasExplicitDefaultThinkingLevel
25884
+ ? this.model?.thinking?.defaultLevel
25885
+ : undefined;
25886
+ const persistedThinkingLevel =
25887
+ hasThinkingEntry && sessionThinkingLevel !== ThinkingLevel.Inherit
25888
+ ? sessionThinkingLevel
25889
+ : (recoveredThinkingLevel ?? recoveredModelThinkingLevel ?? defaultThinkingLevel);
23894
25890
  const nextThinkingLevel = resolveThinkingLevelForModel(
23895
25891
  this.model,
23896
25892
  persistedThinkingLevel === ThinkingLevel.Inherit
@@ -23910,16 +25906,15 @@ export class AgentSession {
23910
25906
  : configuredServiceTier === "none"
23911
25907
  ? undefined
23912
25908
  : configuredServiceTier;
23913
- // Switching to another session file must not carry the predecessor's
23914
- // profile marker or role overrides into the successor; the successor's
23915
- // own configured model is restored above.
23916
- if (switchingToDifferentSession) this.#resetSessionScopedModelProfileState();
23917
25909
  // Establish the successor's durable session identity only after every
23918
25910
  // restored state facet is live. Identity-bound extension hooks run below.
23919
25911
  await this.sessionManager.ensureOnDisk();
23920
25912
  if (!switchingToDifferentSession) await this.#initializeLocalRootForLoadedSession();
23921
25913
 
23922
25914
  if (switchingToDifferentSession) {
25915
+ // Profile cleanup changed the active role layer; refresh eager task
25916
+ // delegation and its base prompt before reconnecting the successor.
25917
+ await this.syncEagerDelegation();
23923
25918
  // The local:// migration gate for this successor already ran above,
23924
25919
  // before the identity was published (#2797 / #2925).
23925
25920
  this.#resetHindsightConversationTrackingIfHindsight();
@@ -23931,6 +25926,11 @@ export class AgentSession {
23931
25926
  previousSessionState.sessionId,
23932
25927
  previousSessionState.sessionFile,
23933
25928
  );
25929
+ exactRetirement = await this.#prepareExactMcpControlRetirement();
25930
+ if (exactRetirement && !exactRetirement.canCommit())
25931
+ throw new Error("Exact MCP control retirement changed before session switch commit");
25932
+ await exactRetirement?.commit();
25933
+ transitionCleanupCommitted = true;
23934
25934
  let ownerShutdownSettled = true;
23935
25935
  if (ownerShutdownManager && ownerShutdownLease && ownerId) {
23936
25936
  try {
@@ -23959,7 +25959,18 @@ export class AgentSession {
23959
25959
  );
23960
25960
  }
23961
25961
  }
23962
- transitionCleanupCommitted = true;
25962
+ // The successor identity is committed. Settle consumed tracked inputs
25963
+ // before reconnecting the successor or running session-switch hooks,
25964
+ // either of which can outlive the predecessor event bridge.
25965
+ this.#terminalizeQueuedSdkWorkForSessionTransition(
25966
+ [
25967
+ ...previousAgentSteeringQueue,
25968
+ ...previousAgentFollowUpQueue,
25969
+ ...previousDeferredSdkFollowUps,
25970
+ ...previousPendingNextTurnMessages.map(entry => entry.message),
25971
+ ],
25972
+ previousTrackedQueuedInputs,
25973
+ );
23963
25974
  // Different files may intentionally carry the same copied session id; pathname transition is the commit signal.
23964
25975
  ownerShutdownTransitionCommitted = true;
23965
25976
  if (ownerShutdownSettled) {
@@ -23982,22 +25993,39 @@ export class AgentSession {
23982
25993
  // messages, model state, MCP selections, the agent subscription, and
23983
25994
  // session-scoped tool cleanup are complete.
23984
25995
  if (this.#extensionRunner) {
23985
- await this.#extensionRunner.emit({
23986
- type: "session_switch",
23987
- reason: "resume",
23988
- previousSessionFile,
23989
- ...(options?.transition ? { transition: options.transition } : {}),
23990
- });
25996
+ await this.#runCommittedSuccessorHook(() =>
25997
+ this.#extensionRunner!.emit({
25998
+ type: "session_switch",
25999
+ reason: "resume",
26000
+ previousSessionFile,
26001
+ ...(options?.transition ? { transition: options.transition } : {}),
26002
+ }),
26003
+ );
23991
26004
  }
23992
- this.#terminalizeQueuedSdkWorkForSessionTransition([
23993
- ...previousAgentSteeringQueue,
23994
- ...previousAgentFollowUpQueue,
23995
- ...previousDeferredSdkFollowUps,
23996
- ]);
23997
- this.#deferredSdkFollowUps = [];
26005
+ this.#terminalizeQueuedSdkWorkForSessionTransition(
26006
+ [
26007
+ ...previousAgentSteeringQueue,
26008
+ ...previousAgentFollowUpQueue,
26009
+ ...previousDeferredSdkFollowUps,
26010
+ ...previousPendingNextTurnMessages.map(entry => entry.message),
26011
+ ],
26012
+ previousTrackedQueuedInputs,
26013
+ );
26014
+ const predecessorDeferred = new Set(previousDeferredSdkFollowUps);
26015
+ this.#deferredSdkFollowUps = this.#deferredSdkFollowUps.filter(
26016
+ message => !predecessorDeferred.has(message),
26017
+ );
26018
+ if (recoveredDefaultChainMessage) this.#emitResolutionFallbackSwitch(this.#defaultFallbackChain(false));
26019
+ if (recoveredDefaultChainMessage) this.emitNotice("warning", recoveredDefaultChainMessage, "fallback");
23998
26020
  return true;
23999
26021
  } catch (error) {
24000
26022
  if (transitionCleanupCommitted) throw error;
26023
+ let exactRetirementAbortError: unknown;
26024
+ try {
26025
+ await exactRetirement?.abort();
26026
+ } catch (abortError) {
26027
+ exactRetirementAbortError = abortError;
26028
+ }
24001
26029
  // The switch never committed: rotate the manager's endpoint
24002
26030
  // registration back to the predecessor before restoring it
24003
26031
  // (review thread P1 — the map key must track the session id).
@@ -24027,9 +26055,22 @@ export class AgentSession {
24027
26055
  `Session switch rollback aborted: predecessor endpoint "${previousSessionState.sessionId}" is no longer owned by this session.`,
24028
26056
  );
24029
26057
  }
24030
- await this.sessionManager.restoreRollbackState(previousSessionState);
24031
- this.#defaultFallbackController = undefined;
26058
+ let sessionRestoreError: unknown;
26059
+ try {
26060
+ await this.sessionManager.restoreRollbackState(previousSessionState);
26061
+ } catch (restoreError) {
26062
+ sessionRestoreError = restoreError;
26063
+ }
26064
+ this.restoreDefaultFallbackRuntimeState(previousDefaultFallbackRuntimeState);
24032
26065
  this.#syncAgentSessionId(previousSessionState.sessionId);
26066
+ if (previousModelRolesOverride === undefined) this.settings.clearOverride("modelRoles");
26067
+ else this.settings.override("modelRoles", previousModelRolesOverride);
26068
+ if (previousAgentModelOverridesOverride === undefined)
26069
+ this.settings.clearOverride("task.agentModelOverrides");
26070
+ else this.settings.override("task.agentModelOverrides", previousAgentModelOverridesOverride);
26071
+ this.#activeProfileInstalledRoles = new Map(previousActiveProfileInstalledRoles);
26072
+ this.#activeProfileInstalledAgentOverrides = new Map(previousActiveProfileInstalledAgentOverrides);
26073
+ this.#preProfileModel = previousPreProfileModel;
24033
26074
  this.#activeModelProfile = previousActiveModelProfile;
24034
26075
  this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
24035
26076
  this.#rekeyHindsightMemoryForCurrentSessionId();
@@ -24067,9 +26108,8 @@ export class AgentSession {
24067
26108
  this.agent.clearAllQueues();
24068
26109
  this.agent.restoreSteering(previousAgentSteeringQueue);
24069
26110
  this.agent.restoreFollowUp(previousAgentFollowUpQueue);
24070
- if (previousModel) {
24071
- this.#setAgentModelWithReasoningContext(previousModel);
24072
- }
26111
+ this.#setAgentModelWithReasoningContext(previousModel);
26112
+ this.#syncAppendOnlyContext(previousModel);
24073
26113
  this.#thinkingLevelMutationRevision++;
24074
26114
  this.#thinkingLevelLiveMutationRevision++;
24075
26115
  this.#pendingThinkingLevelControlSuccess = undefined;
@@ -24081,8 +26121,17 @@ export class AgentSession {
24081
26121
  this.agent.serviceTier = previousServiceTier;
24082
26122
  this.#syncTodoPhasesFromBranch();
24083
26123
  this.#reconnectToAgent();
24084
- if (restoreMcpError) {
24085
- throw restoreMcpError;
26124
+ const rollbackErrors = [exactRetirementAbortError, sessionRestoreError, restoreMcpError].filter(
26125
+ value => value !== undefined,
26126
+ );
26127
+ if (rollbackErrors.length > 0) {
26128
+ if (rollbackErrors.length > 1) {
26129
+ throw new AggregateError(
26130
+ [error, ...rollbackErrors],
26131
+ "Session switch and exact MCP rollback both failed",
26132
+ );
26133
+ }
26134
+ throw new AggregateError([error, rollbackErrors[0]], "Session switch rollback failed");
24086
26135
  }
24087
26136
  if (unavailableDefaultChainMessage) {
24088
26137
  this.emitNotice(
@@ -24120,19 +26169,19 @@ export class AgentSession {
24120
26169
  }> {
24121
26170
  this.#beginSessionTransition("branch");
24122
26171
  try {
24123
- const previousSessionFile = this.sessionFile;
24124
- const previousWorkflowGateSessionId = this.sessionId;
24125
- const previousSessionIdentity = this.sessionManager.getSessionId();
24126
26172
  const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
24127
-
24128
26173
  if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
24129
26174
  throw new Error("Invalid entry ID for branching");
24130
26175
  }
26176
+ const previousSessionFile = this.sessionFile;
26177
+ const previousWorkflowGateSessionId = this.sessionId;
26178
+ const previousSessionIdentity = this.sessionManager.getSessionId();
24131
26179
 
24132
26180
  const selectedText = this.#extractUserMessageText(selectedEntry.message.content);
24133
26181
  const previousAgentSteeringQueue = this.agent.snapshotSteering();
24134
26182
  const previousAgentFollowUpQueue = this.agent.snapshotFollowUp();
24135
26183
  const previousDeferredSdkFollowUps = [...this.#deferredSdkFollowUps];
26184
+ const previousPendingNextTurnMessages = [...this.#pendingNextTurnMessages];
24136
26185
 
24137
26186
  let skipConversationRestore = false;
24138
26187
 
@@ -24149,21 +26198,27 @@ export class AgentSession {
24149
26198
  skipConversationRestore = result?.skipConversationRestore ?? false;
24150
26199
  }
24151
26200
 
26201
+ this.#settleTrackedQueuedInputsBeforeAgentDisconnect();
26202
+ this.#disconnectFromAgent();
26203
+ await this.#settleActivePromptForHistoryTransition();
26204
+
24152
26205
  // Flush pending writes before preparing the successor.
24153
26206
  await this.sessionManager.flush();
24154
26207
  const prepared = selectedEntry.parentId
24155
26208
  ? await this.sessionManager.prepareBranchedSession(selectedEntry.parentId)
24156
26209
  : await this.sessionManager.prepareNewSession({ parentSession: previousSessionFile });
26210
+ let exactRetirement: PreparedExactMcpRetirement | undefined;
24157
26211
  try {
24158
26212
  await initializeLocalRoot(this.#localProtocolOptions(prepared));
24159
26213
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
24160
26214
  this.#assertJobManagerEndpointAdmission(prepared.sessionId, prepared.sessionFile);
26215
+ exactRetirement = await this.#prepareExactMcpControlRetirement();
26216
+ if (exactRetirement && !exactRetirement.canCommit())
26217
+ throw new Error("Exact MCP control retirement changed before session adoption");
24161
26218
  this.sessionManager.commitPreparedNewSession(prepared);
24162
- // Branch commits a successor endpoint identity; re-register the
24163
- // manager under it (review thread P1).
24164
- this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
24165
- await this.#runToolSessionTransitionCleanups();
26219
+ await exactRetirement?.commit();
24166
26220
  } catch (error) {
26221
+ await exactRetirement?.abort();
24167
26222
  throw await discardPreparedNewSessionAfterFailure(this.sessionManager, prepared, error);
24168
26223
  }
24169
26224
  this.#pendingNextTurnMessages = [];
@@ -24172,6 +26227,7 @@ export class AgentSession {
24172
26227
  ...previousAgentSteeringQueue,
24173
26228
  ...previousAgentFollowUpQueue,
24174
26229
  ...previousDeferredSdkFollowUps,
26230
+ ...previousPendingNextTurnMessages.map(entry => entry.message),
24175
26231
  ]);
24176
26232
  this.#deferredSdkFollowUps = [];
24177
26233
  this.#resetActiveSdkRunOwnership();
@@ -24190,8 +26246,6 @@ export class AgentSession {
24190
26246
  // Reload messages from entries (works for both file and in-memory mode)
24191
26247
  const sessionContext = this.buildDisplaySessionContext();
24192
26248
 
24193
- await this.#restoreMCPSelectionsForSessionContext(sessionContext);
24194
-
24195
26249
  if (!skipConversationRestore) {
24196
26250
  this.agent.replaceMessages(sessionContext.messages, {
24197
26251
  historyRewrite: { reason: "session-branch", preserveSeededPrefix: true },
@@ -24201,17 +26255,26 @@ export class AgentSession {
24201
26255
  }
24202
26256
 
24203
26257
  this.#resetIrcRosterDeliveryState();
26258
+ // Once committed, establish the successor's identity and prompt boundary
26259
+ // before fallible post-commit integrations. A cleanup or MCP failure must
26260
+ // not leave the next turn running with the parent's messages/session id.
26261
+ this.#rekeyJobManagerForSessionIdentity(previousSessionIdentity, previousSessionFile);
26262
+ await this.#runToolSessionTransitionCleanups();
26263
+ await this.#restoreMCPSelectionsForSessionContext(sessionContext);
24204
26264
  // session_branch is the post-commit identity signal. Publish it only after
24205
26265
  // the successor's messages and MCP selections are restored.
24206
26266
  if (this.#extensionRunner) {
24207
- await this.#extensionRunner.emit({
24208
- type: "session_branch",
24209
- previousSessionFile,
24210
- });
26267
+ await this.#runCommittedSuccessorHook(() =>
26268
+ this.#extensionRunner!.emit({
26269
+ type: "session_branch",
26270
+ previousSessionFile,
26271
+ }),
26272
+ );
24211
26273
  }
24212
26274
 
24213
26275
  return { selectedText, cancelled: false };
24214
26276
  } finally {
26277
+ this.#reconnectToAgent();
24215
26278
  this.#endSessionTransition();
24216
26279
  }
24217
26280
  }
@@ -24246,22 +26309,14 @@ export class AgentSession {
24246
26309
  this.#beginSessionTransition("navigate-tree");
24247
26310
  try {
24248
26311
  const oldLeafId = this.sessionManager.getLeafId();
24249
-
24250
- // No-op if already at target
24251
- if (targetId === oldLeafId) {
24252
- return { cancelled: false };
24253
- }
24254
-
24255
- // Model required for summarization
26312
+ if (targetId === oldLeafId) return { cancelled: false };
24256
26313
  if (options.summarize && !this.model) {
24257
26314
  throw new Error("No model available for summarization");
24258
26315
  }
24259
-
24260
26316
  const targetEntry = this.sessionManager.getEntryForFidelity(targetId);
24261
26317
  if (!targetEntry) {
24262
26318
  throw new Error(`Entry ${targetId} not found`);
24263
26319
  }
24264
-
24265
26320
  // Collect entries to summarize (from old leaf to common ancestor).
24266
26321
  const { entries: collectedEntriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
24267
26322
  this.sessionManager,
@@ -24302,6 +26357,10 @@ export class AgentSession {
24302
26357
  }
24303
26358
  }
24304
26359
 
26360
+ this.#settleTrackedQueuedInputsBeforeAgentDisconnect();
26361
+ this.#disconnectFromAgent();
26362
+ await this.#settleActivePromptForHistoryTransition();
26363
+
24305
26364
  // Run default summarizer if needed
24306
26365
  let summaryText: string | undefined;
24307
26366
  let summaryDetails: unknown;
@@ -24388,6 +26447,9 @@ export class AgentSession {
24388
26447
  const queuedSdkWork = this.#queuedMessagesForSessionTransition();
24389
26448
  this.#terminalizeQueuedSdkWorkForSessionTransition(queuedSdkWork);
24390
26449
  this.#deferredSdkFollowUps = [];
26450
+ this.#pendingNextTurnMessages = [];
26451
+ this.#scheduledHiddenNextTurnGeneration = undefined;
26452
+ this.#resetActiveSdkRunOwnership();
24391
26453
  this.agent.clearAllQueues();
24392
26454
  this.#steeringMessages = [];
24393
26455
  this.#followUpMessages = [];
@@ -24409,18 +26471,21 @@ export class AgentSession {
24409
26471
  // the emit and the context rebuild when no handlers are registered (mirrors
24410
26472
  // the session_before_tree guard above).
24411
26473
  if (this.#extensionRunner?.hasHandlers("session_tree")) {
24412
- await this.#extensionRunner.emit({
24413
- type: "session_tree",
24414
- newLeafId: this.sessionManager.getLeafId(),
24415
- oldLeafId,
24416
- summaryEntry,
24417
- fromExtension: summaryText ? fromExtension : undefined,
24418
- });
26474
+ await this.#runCommittedSuccessorHook(() =>
26475
+ this.#extensionRunner!.emit({
26476
+ type: "session_tree",
26477
+ newLeafId: this.sessionManager.getLeafId(),
26478
+ oldLeafId,
26479
+ summaryEntry,
26480
+ fromExtension: summaryText ? fromExtension : undefined,
26481
+ }),
26482
+ );
24419
26483
  const refreshedContext = this.buildDisplaySessionContext();
24420
26484
  return { editorText, cancelled: false, summaryEntry, sessionContext: refreshedContext };
24421
26485
  }
24422
26486
  return { editorText, cancelled: false, summaryEntry, sessionContext: displayContext };
24423
26487
  } finally {
26488
+ this.#reconnectToAgent();
24424
26489
  this.#endSessionTransition();
24425
26490
  }
24426
26491
  }