@gajae-code/coding-agent 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (465) hide show
  1. package/CHANGELOG.md +50 -2
  2. package/dist/types/append-only-mode.d.ts +1 -1
  3. package/dist/types/cli/args.d.ts +4 -5
  4. package/dist/types/cli/daemon-cli.d.ts +12 -1
  5. package/dist/types/cli/notify-cli.d.ts +21 -1
  6. package/dist/types/cli.d.ts +3 -0
  7. package/dist/types/commands/daemon.d.ts +39 -0
  8. package/dist/types/commands/launch.d.ts +3 -0
  9. package/dist/types/commands/notify.d.ts +28 -1
  10. package/dist/types/commands/sdk.d.ts +33 -0
  11. package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
  12. package/dist/types/config/model-profile-activation.d.ts +35 -15
  13. package/dist/types/config/model-profiles.d.ts +11 -5
  14. package/dist/types/config/model-registry.d.ts +3 -3
  15. package/dist/types/config/model-resolver.d.ts +39 -6
  16. package/dist/types/config/model-selector-value.d.ts +17 -0
  17. package/dist/types/config/models-config-schema.d.ts +10 -6
  18. package/dist/types/config/settings-schema.d.ts +133 -6
  19. package/dist/types/config/settings.d.ts +63 -35
  20. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
  21. package/dist/types/coordinator-mcp/server.d.ts +6 -49
  22. package/dist/types/daemon/builtin.d.ts +6 -5
  23. package/dist/types/daemon/control-types.d.ts +1 -1
  24. package/dist/types/discovery/mcp-json.d.ts +12 -1
  25. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  26. package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
  27. package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
  28. package/dist/types/extensibility/extensions/loader.d.ts +10 -1
  29. package/dist/types/extensibility/extensions/runner.d.ts +3 -3
  30. package/dist/types/extensibility/extensions/types.d.ts +141 -16
  31. package/dist/types/extensibility/hooks/types.d.ts +1 -1
  32. package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
  33. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  35. package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
  36. package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
  37. package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
  38. package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
  39. package/dist/types/harness-control-plane/operate.d.ts +5 -5
  40. package/dist/types/harness-control-plane/owner.d.ts +18 -4
  41. package/dist/types/harness-control-plane/receipts.d.ts +0 -1
  42. package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
  43. package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
  44. package/dist/types/harness-control-plane/types.d.ts +3 -4
  45. package/dist/types/lsp/index.d.ts +1 -1
  46. package/dist/types/main.d.ts +32 -38
  47. package/dist/types/modes/acp/acp-agent.d.ts +224 -53
  48. package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
  49. package/dist/types/modes/acp/acp-mode.d.ts +6 -15
  50. package/dist/types/modes/acp/permission-mode.d.ts +4 -0
  51. package/dist/types/modes/acp/startup-options.d.ts +6 -0
  52. package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
  53. package/dist/types/modes/components/model-selector.d.ts +1 -0
  54. package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
  55. package/dist/types/modes/components/settings-selector.d.ts +6 -4
  56. package/dist/types/modes/components/welcome.d.ts +1 -1
  57. package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
  58. package/dist/types/modes/index.d.ts +0 -4
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/runtime-init.d.ts +2 -2
  61. package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
  62. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
  63. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
  64. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
  65. package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
  66. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +169 -30
  67. package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
  68. package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
  69. package/dist/types/modes/types.d.ts +5 -0
  70. package/dist/types/runtime-mcp/client.d.ts +2 -0
  71. package/dist/types/runtime-mcp/config.d.ts +5 -1
  72. package/dist/types/runtime-mcp/manager.d.ts +8 -1
  73. package/dist/types/runtime-mcp/types.d.ts +4 -0
  74. package/dist/types/sdk/acp/adapter.d.ts +67 -0
  75. package/dist/types/sdk/acp/index.d.ts +2 -0
  76. package/dist/types/sdk/broker/broker.d.ts +53 -0
  77. package/dist/types/sdk/broker/discovery.d.ts +30 -0
  78. package/dist/types/sdk/broker/ensure.d.ts +33 -0
  79. package/dist/types/sdk/broker/identity.d.ts +3 -0
  80. package/dist/types/sdk/broker/index.d.ts +8 -0
  81. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
  82. package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
  83. package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
  84. package/dist/types/sdk/broker/runtime.d.ts +33 -0
  85. package/dist/types/sdk/broker/session-index.d.ts +57 -0
  86. package/dist/types/sdk/broker/state-version.d.ts +8 -0
  87. package/dist/types/sdk/broker/transport.d.ts +9 -0
  88. package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
  89. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
  90. package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
  91. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
  92. package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
  93. package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
  94. package/dist/types/sdk/bus/config.d.ts +274 -0
  95. package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
  96. package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
  97. package/dist/types/sdk/bus/discord-daemon.d.ts +45 -0
  98. package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
  99. package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
  100. package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
  101. package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
  102. package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
  103. package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
  104. package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
  105. package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
  106. package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
  107. package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
  108. package/dist/types/sdk/bus/session-control.d.ts +83 -0
  109. package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
  110. package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
  111. package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
  112. package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
  113. package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
  114. package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
  115. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
  116. package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
  117. package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +55 -17
  118. package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
  119. package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
  120. package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
  121. package/dist/types/sdk/cli/index.d.ts +1 -0
  122. package/dist/types/sdk/cli/session-cli.d.ts +19 -0
  123. package/dist/types/sdk/client/client.d.ts +1 -0
  124. package/dist/types/sdk/client/discovery.d.ts +27 -0
  125. package/dist/types/sdk/client/index.d.ts +2 -0
  126. package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
  127. package/dist/types/sdk/host/control/index.d.ts +2 -0
  128. package/dist/types/sdk/host/control/operations.d.ts +65 -0
  129. package/dist/types/sdk/host/events.d.ts +35 -0
  130. package/dist/types/sdk/host/host.d.ts +35 -0
  131. package/dist/types/sdk/host/index.d.ts +4 -0
  132. package/dist/types/sdk/host/query/cursor.d.ts +62 -0
  133. package/dist/types/sdk/host/query/handlers.d.ts +70 -0
  134. package/dist/types/sdk/host/query/index.d.ts +3 -0
  135. package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
  136. package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
  137. package/dist/types/sdk/host/types.d.ts +32 -0
  138. package/dist/types/sdk/index.d.ts +6 -0
  139. package/dist/types/sdk/lifecycle-session.d.ts +14 -0
  140. package/dist/types/sdk/mcp/index.d.ts +1 -0
  141. package/dist/types/sdk/mcp/server.d.ts +37 -0
  142. package/dist/types/sdk/models.d.ts +44 -0
  143. package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
  144. package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
  145. package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
  146. package/dist/types/sdk/startup-capability.d.ts +50 -0
  147. package/dist/types/session/agent-session.d.ts +114 -21
  148. package/dist/types/session/default-model-selection.d.ts +25 -0
  149. package/dist/types/session/fallback-chain-controller.d.ts +52 -0
  150. package/dist/types/session/session-manager.d.ts +58 -1
  151. package/dist/types/setup/credential-auto-import.d.ts +36 -6
  152. package/dist/types/tools/ask-answer-registry.d.ts +1 -1
  153. package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
  154. package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
  155. package/dist/types/tools/browser/launch.d.ts +22 -0
  156. package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
  157. package/dist/types/tools/browser/profile-posture.d.ts +37 -0
  158. package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
  159. package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
  160. package/dist/types/tools/browser/registry.d.ts +19 -0
  161. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  162. package/dist/types/tools/index.d.ts +8 -8
  163. package/package.json +34 -13
  164. package/scripts/build-sdk-package-smoke.ts +94 -0
  165. package/scripts/capture-notifications-settings-showcase.ts +435 -0
  166. package/scripts/compile-args.ts +4 -2
  167. package/scripts/g005-daemon-smoke.ts +4 -5
  168. package/scripts/g011-daemon-path-smoke.ts +5 -5
  169. package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
  170. package/scripts/generate-sdk-operation-inventory.ts +867 -0
  171. package/scripts/generate-telegram-baseline-manifest.ts +128 -0
  172. package/scripts/manifest-schema.ts +124 -0
  173. package/scripts/run-test-manifest.ts +153 -0
  174. package/scripts/run-threat-model-check.ts +157 -0
  175. package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
  176. package/scripts/verify-gjc-sdk-rename.ts +90 -0
  177. package/src/append-only-mode.ts +1 -1
  178. package/src/cli/args.ts +43 -12
  179. package/src/cli/auth-gateway-cli.ts +2 -2
  180. package/src/cli/config-cli.ts +2 -1
  181. package/src/cli/daemon-cli.ts +63 -14
  182. package/src/cli/list-models.ts +1 -1
  183. package/src/cli/notify-cli.ts +288 -286
  184. package/src/cli.ts +21 -2
  185. package/src/commands/daemon.ts +50 -6
  186. package/src/commands/harness.ts +93 -36
  187. package/src/commands/launch.ts +5 -2
  188. package/src/commands/mcp-serve.ts +19 -12
  189. package/src/commands/notify.ts +36 -3
  190. package/src/commands/sdk.ts +426 -0
  191. package/src/commit/agentic/agent.ts +1 -0
  192. package/src/config/atomic-yaml-patch.ts +423 -0
  193. package/src/config/model-profile-activation.ts +240 -89
  194. package/src/config/model-profiles.ts +18 -15
  195. package/src/config/model-registry.ts +51 -26
  196. package/src/config/model-resolver.ts +138 -51
  197. package/src/config/model-selector-value.ts +50 -0
  198. package/src/config/models-config-schema.ts +14 -21
  199. package/src/config/settings-schema.ts +128 -10
  200. package/src/config/settings.ts +1120 -158
  201. package/src/coordinator-mcp/model-preset.ts +74 -20
  202. package/src/coordinator-mcp/server.ts +1597 -3052
  203. package/src/daemon/builtin.ts +8 -5
  204. package/src/daemon/control-types.ts +1 -1
  205. package/src/defaults/gjc/skills/team/SKILL.md +4 -4
  206. package/src/discovery/mcp-json.ts +327 -20
  207. package/src/extensibility/custom-tools/types.ts +1 -1
  208. package/src/extensibility/extensions/compact-handler.ts +3 -3
  209. package/src/extensibility/extensions/get-commands-handler.ts +2 -2
  210. package/src/extensibility/extensions/loader.ts +69 -5
  211. package/src/extensibility/extensions/runner.ts +112 -4
  212. package/src/extensibility/extensions/types.ts +176 -17
  213. package/src/extensibility/hooks/types.ts +1 -1
  214. package/src/gjc-runtime/launch-tmux.ts +170 -86
  215. package/src/gjc-runtime/psmux-detect.ts +82 -4
  216. package/src/gjc-runtime/session-state-sidecar.ts +41 -31
  217. package/src/gjc-runtime/team-runtime.ts +54 -35
  218. package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
  219. package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
  220. package/src/gjc-runtime/tmux-sessions.ts +19 -13
  221. package/src/gjc-runtime/ultragoal-guard.ts +53 -6
  222. package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
  223. package/src/gjc-runtime/windows-powershell-command.ts +58 -0
  224. package/src/harness-control-plane/operate.ts +9 -9
  225. package/src/harness-control-plane/owner.ts +283 -39
  226. package/src/harness-control-plane/receipts.ts +0 -1
  227. package/src/harness-control-plane/sdk-transport.ts +395 -0
  228. package/src/harness-control-plane/session-transport.ts +94 -0
  229. package/src/harness-control-plane/types.ts +1 -1
  230. package/src/internal-urls/docs-index.generated.ts +34 -35
  231. package/src/lsp/client.ts +42 -6
  232. package/src/lsp/index.ts +2 -2
  233. package/src/main.ts +299 -204
  234. package/src/modes/DESIGN.md +258 -0
  235. package/src/modes/acp/acp-agent.ts +1228 -2612
  236. package/src/modes/acp/acp-client-bridge.ts +2 -20
  237. package/src/modes/acp/acp-event-mapper.ts +18 -2
  238. package/src/modes/acp/acp-mode.ts +8 -36
  239. package/src/modes/acp/permission-mode.ts +25 -0
  240. package/src/modes/acp/startup-options.ts +6 -0
  241. package/src/modes/components/agent-dashboard.ts +37 -17
  242. package/src/modes/components/model-selector.ts +93 -35
  243. package/src/modes/components/notifications-settings-editor.ts +1379 -0
  244. package/src/modes/components/settings-selector.ts +60 -11
  245. package/src/modes/components/welcome.ts +4 -4
  246. package/src/modes/controllers/command-controller.ts +1 -2
  247. package/src/modes/controllers/event-controller.ts +11 -16
  248. package/src/modes/controllers/extension-ui-controller.ts +348 -4
  249. package/src/modes/controllers/selector-controller.ts +674 -36
  250. package/src/modes/index.ts +0 -22
  251. package/src/modes/interactive-mode.ts +59 -20
  252. package/src/modes/runtime-init.ts +298 -6
  253. package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
  254. package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
  255. package/src/modes/shared/agent-wire/event-contract.ts +7 -8
  256. package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
  257. package/src/modes/shared/agent-wire/event-observation.ts +16 -7
  258. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1254 -87
  259. package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
  260. package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
  261. package/src/modes/types.ts +5 -0
  262. package/src/runtime-mcp/client.ts +94 -19
  263. package/src/runtime-mcp/config.ts +27 -15
  264. package/src/runtime-mcp/manager.ts +343 -105
  265. package/src/runtime-mcp/oauth-flow.ts +42 -12
  266. package/src/runtime-mcp/transports/http.ts +49 -19
  267. package/src/runtime-mcp/transports/stdio.ts +47 -28
  268. package/src/runtime-mcp/types.ts +10 -0
  269. package/src/sdk/acp/adapter.ts +418 -0
  270. package/src/sdk/acp/index.ts +2 -0
  271. package/src/sdk/broker/broker.ts +642 -0
  272. package/src/sdk/broker/discovery.ts +94 -0
  273. package/src/sdk/broker/ensure.ts +249 -0
  274. package/src/sdk/broker/identity.ts +42 -0
  275. package/src/sdk/broker/index.ts +8 -0
  276. package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
  277. package/src/sdk/broker/internal-source.bunfig.toml +1 -0
  278. package/src/sdk/broker/lifecycle-ledger.ts +286 -0
  279. package/src/sdk/broker/lifecycle.ts +2157 -0
  280. package/src/sdk/broker/process-incarnation.ts +159 -0
  281. package/src/sdk/broker/runtime.ts +164 -0
  282. package/src/sdk/broker/session-index.ts +318 -0
  283. package/src/sdk/broker/state-version.ts +23 -0
  284. package/src/sdk/broker/transport.ts +125 -0
  285. package/src/sdk/bus/chat-command-policy.ts +107 -0
  286. package/src/sdk/bus/chat-daemon-cli.ts +214 -0
  287. package/src/sdk/bus/chat-daemon-control.ts +713 -0
  288. package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
  289. package/src/sdk/bus/chat-effect-journal.ts +353 -0
  290. package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
  291. package/src/sdk/bus/config.ts +433 -0
  292. package/src/sdk/bus/conversation-store.ts +391 -0
  293. package/src/sdk/bus/discord-conversation.ts +85 -0
  294. package/src/sdk/bus/discord-daemon.ts +2275 -0
  295. package/src/sdk/bus/discord-live-provider.ts +521 -0
  296. package/src/sdk/bus/discord-provider.ts +56 -0
  297. package/src/{notifications → sdk/bus}/html-format.ts +2 -2
  298. package/src/sdk/bus/index.ts +3943 -0
  299. package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
  300. package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
  301. package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
  302. package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
  303. package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
  304. package/src/sdk/bus/natives-augment.d.ts +38 -0
  305. package/src/sdk/bus/notification-orchestration.ts +535 -0
  306. package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
  307. package/src/sdk/bus/session-control.ts +333 -0
  308. package/src/sdk/bus/slack-conversation.ts +74 -0
  309. package/src/sdk/bus/slack-daemon.ts +2144 -0
  310. package/src/sdk/bus/slack-live-provider.ts +380 -0
  311. package/src/sdk/bus/slack-provider.ts +76 -0
  312. package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
  313. package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
  314. package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
  315. package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
  316. package/src/{notifications → sdk/bus}/telegram-daemon.ts +502 -54
  317. package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
  318. package/src/sdk/bus/telegram-setup.ts +663 -0
  319. package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
  320. package/src/sdk/cli/index.ts +1 -0
  321. package/src/sdk/cli/session-cli.ts +275 -0
  322. package/src/sdk/client/client.ts +1 -0
  323. package/src/sdk/client/discovery.ts +108 -0
  324. package/src/sdk/client/index.ts +2 -0
  325. package/src/sdk/host/control/dispatch.ts +367 -0
  326. package/src/sdk/host/control/index.ts +10 -0
  327. package/src/sdk/host/control/operations.ts +63 -0
  328. package/src/sdk/host/events.ts +91 -0
  329. package/src/sdk/host/host.ts +296 -0
  330. package/src/sdk/host/index.ts +4 -0
  331. package/src/sdk/host/query/cursor.ts +189 -0
  332. package/src/sdk/host/query/handlers.ts +533 -0
  333. package/src/sdk/host/query/index.ts +3 -0
  334. package/src/sdk/host/query/revision-store.ts +1126 -0
  335. package/src/sdk/host/reverse-leases.ts +325 -0
  336. package/src/sdk/host/types.ts +29 -0
  337. package/src/sdk/index.ts +13 -0
  338. package/src/sdk/lifecycle-session.ts +39 -0
  339. package/src/sdk/mcp/index.ts +1 -0
  340. package/src/sdk/mcp/server.ts +332 -0
  341. package/src/sdk/models.ts +168 -0
  342. package/src/sdk/protocol/adapter-validation.ts +48 -0
  343. package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
  344. package/src/sdk/protocol/operation-registry.ts +312 -0
  345. package/src/{sdk.ts → sdk/session.ts} +339 -212
  346. package/src/sdk/startup-capability.ts +189 -0
  347. package/src/session/agent-session.ts +2453 -894
  348. package/src/session/default-model-selection.ts +93 -0
  349. package/src/session/fallback-chain-controller.ts +136 -0
  350. package/src/session/session-manager.ts +438 -11
  351. package/src/session/session-storage.ts +8 -1
  352. package/src/setup/credential-auto-import.ts +290 -35
  353. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
  354. package/src/slash-commands/builtin-registry.ts +2 -1
  355. package/src/task/executor.ts +62 -13
  356. package/src/tools/ask-answer-registry.ts +1 -1
  357. package/src/tools/ask.ts +13 -8
  358. package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
  359. package/src/tools/browser/benchmark/detector-report.ts +157 -0
  360. package/src/tools/browser/benchmark/run-suite.ts +39 -0
  361. package/src/tools/browser/launch.ts +124 -19
  362. package/src/tools/browser/profile-discovery.ts +63 -0
  363. package/src/tools/browser/profile-posture.ts +59 -0
  364. package/src/tools/browser/profile-reuse.ts +102 -0
  365. package/src/tools/browser/profile-warmup.ts +109 -0
  366. package/src/tools/browser/registry.ts +87 -11
  367. package/src/tools/browser/tab-protocol.ts +1 -0
  368. package/src/tools/browser/tab-supervisor.ts +1 -0
  369. package/src/tools/browser/tab-worker.ts +1 -1
  370. package/src/tools/browser.ts +6 -0
  371. package/src/tools/index.ts +8 -8
  372. package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
  373. package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
  374. package/src/tools/telegram-send.ts +2 -2
  375. package/vendor/insane-search/MANIFEST.json +2 -1
  376. package/vendor/insane-search/engine/bias_check.py +4 -1
  377. package/vendor/insane-search/engine/phase0.py +26 -1
  378. package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
  379. package/dist/types/modes/bridge/auth.d.ts +0 -12
  380. package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
  381. package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
  382. package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
  383. package/dist/types/modes/bridge/event-stream.d.ts +0 -8
  384. package/dist/types/modes/rpc/host-tools.d.ts +0 -1
  385. package/dist/types/modes/rpc/host-uris.d.ts +0 -1
  386. package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
  387. package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
  388. package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
  389. package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
  390. package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
  391. package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
  392. package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
  393. package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
  394. package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
  395. package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
  396. package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
  397. package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
  398. package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
  399. package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
  400. package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
  401. package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
  402. package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
  403. package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
  404. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
  405. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
  406. package/dist/types/notifications/config.d.ts +0 -132
  407. package/src/harness-control-plane/rpc-adapter.ts +0 -290
  408. package/src/modes/bridge/auth.ts +0 -41
  409. package/src/modes/bridge/bridge-client-bridge.ts +0 -47
  410. package/src/modes/bridge/bridge-mode.ts +0 -675
  411. package/src/modes/bridge/bridge-ui-context.ts +0 -200
  412. package/src/modes/bridge/event-stream.ts +0 -70
  413. package/src/modes/rpc/host-tools.ts +0 -1
  414. package/src/modes/rpc/host-uris.ts +0 -1
  415. package/src/modes/rpc/rpc-client.ts +0 -1166
  416. package/src/modes/rpc/rpc-mode.ts +0 -1029
  417. package/src/modes/rpc/rpc-socket-security.ts +0 -115
  418. package/src/modes/rpc/rpc-types.ts +0 -637
  419. package/src/modes/shared/agent-wire/command-contract.ts +0 -18
  420. package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
  421. package/src/modes/shared/agent-wire/command-validation.ts +0 -181
  422. package/src/modes/shared/agent-wire/handshake.ts +0 -157
  423. package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
  424. package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
  425. package/src/modes/shared/agent-wire/protocol.ts +0 -46
  426. package/src/modes/shared/agent-wire/responses.ts +0 -17
  427. package/src/modes/shared/agent-wire/scopes.ts +0 -94
  428. package/src/modes/shared/agent-wire/session-registry.ts +0 -109
  429. package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
  430. package/src/modes/shared/agent-wire/ui-result.ts +0 -48
  431. package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
  432. package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
  433. package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
  434. package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
  435. package/src/notifications/config.ts +0 -219
  436. package/src/notifications/index.ts +0 -1835
  437. package/src/notifications/telegram-daemon-cli.ts +0 -243
  438. package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
  439. /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
  440. /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
  441. /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
  442. /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
  443. /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
  444. /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
  445. /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
  446. /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
  447. /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
  448. /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
  449. /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
  450. /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
  451. /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
  452. /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
  453. /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
  454. /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
  455. /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
  456. /package/src/{notifications → sdk/bus}/engine.ts +0 -0
  457. /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
  458. /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
  459. /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
  460. /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
  461. /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
  462. /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
  463. /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
  464. /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
  465. /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * AgentSession - Core abstraction for agent lifecycle and session management.
3
3
  *
4
- * This class is shared between all run modes (interactive, print, rpc).
4
+ * This class is shared by interactive, print, ACP, and SDK-hosted session callers.
5
5
  * It encapsulates:
6
6
  * - Agent state access
7
7
  * - Event subscription with automatic session persistence
@@ -13,6 +13,7 @@
13
13
  * Modes use this class and add their own I/O layer on top.
14
14
  */
15
15
 
16
+ import { AsyncLocalStorage } from "node:async_hooks";
16
17
  import * as crypto from "node:crypto";
17
18
  import * as fs from "node:fs";
18
19
  import * as path from "node:path";
@@ -22,13 +23,18 @@ import {
22
23
  type AfterToolCallResult,
23
24
  type Agent,
24
25
  AgentBusyError,
26
+ type AgentContext,
25
27
  type AgentEvent,
28
+ type AgentLoopConfig,
26
29
  type AgentMessage,
27
30
  type AgentState,
28
31
  type AgentTool,
29
32
  assertImagePlaceholdersHavePayload,
30
33
  canContinuePersistedHistory,
31
- type ResolvedThinkingLevel,
34
+ type ManagedAttemptContinuationOwnership,
35
+ type ManagedAttemptDecision,
36
+ type ManagedAttemptOutcome,
37
+ type MidRunMaintenanceOutcome,
32
38
  resolveTelemetry,
33
39
  type StablePrefixSnapshot,
34
40
  ThinkingLevel,
@@ -75,20 +81,20 @@ import type {
75
81
  TextContent,
76
82
  ToolCall,
77
83
  ToolChoice,
84
+ TransportFailureFacts,
78
85
  Usage,
79
86
  UsageReport,
80
87
  } from "@gajae-code/ai";
81
88
  import {
82
- calculateRateLimitBackoffMs,
83
89
  clearAnthropicFastModeFallback,
84
90
  getSupportedEfforts,
85
91
  isContextOverflow,
86
92
  isUsageLimitError,
87
93
  modelsAreEqual,
88
- parseRateLimitReason,
89
94
  resolveServiceTier,
90
95
  streamSimple,
91
96
  } from "@gajae-code/ai";
97
+ import { beginAttempt, classifyFallbackTrigger } from "@gajae-code/ai/utils/fallback-transport";
92
98
 
93
99
  export interface ForkContextSeedMetadata {
94
100
  sourceSessionId: string;
@@ -138,19 +144,28 @@ import { createAppendOnlyContextManager, resolveAppendOnlyMode } from "../append
138
144
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
139
145
  import { reset as resetCapabilities } from "../capability";
140
146
  import type { Rule } from "../capability/rule";
141
- import { GJC_MODEL_ASSIGNMENT_TARGETS, MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
147
+ import {
148
+ GJC_MODEL_ASSIGNMENT_TARGETS,
149
+ isAuthenticated,
150
+ MODEL_ROLE_IDS,
151
+ type ModelRegistry,
152
+ } from "../config/model-registry";
142
153
  import {
143
154
  extractExplicitThinkingSelector,
144
155
  formatModelSelectorValue,
145
156
  formatModelString,
157
+ managedCursorFallbackUnavailableReason,
146
158
  parseModelString,
147
159
  type ResolvedModelRoleValue,
160
+ resolveModelChainWithAuth,
148
161
  resolveModelRoleValue,
149
162
  type ScopedModelSelection,
150
163
  } from "../config/model-resolver";
164
+ import { normalizeModelSelectorValue } from "../config/model-selector-value";
151
165
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
152
- import type { Settings, SkillsSettings } from "../config/settings";
166
+ import type { GlobalDefaultModelRoleCommit, Settings, SkillsSettings } from "../config/settings";
153
167
  import { onAppendOnlyModeChanged } from "../config/settings";
168
+ import { getDefault } from "../config/settings-schema";
154
169
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
155
170
  import { loadCapability } from "../discovery";
156
171
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
@@ -187,12 +202,15 @@ import type {
187
202
  } from "../extensibility/extensions";
188
203
  import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
189
204
  import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
190
- import type { LoadedSubskillActivation } from "../extensibility/gjc-plugins";
205
+ import {
206
+ type LoadedSubskillActivation,
207
+ resolveSubskillActivationForSkillInvocation,
208
+ } from "../extensibility/gjc-plugins";
191
209
  import { resolveCurrentPhaseForParent } from "../extensibility/gjc-plugins/injection";
192
210
  import { readActiveSubskillsForParent, toActiveSubskillEntry } from "../extensibility/gjc-plugins/state";
193
211
  import { loadActiveSubskillTools } from "../extensibility/gjc-plugins/tools";
194
212
  import type { HookCommandContext } from "../extensibility/hooks/types";
195
- import type { Skill, SkillWarning } from "../extensibility/skills";
213
+ import { buildSkillPromptMessage, type Skill, type SkillWarning } from "../extensibility/skills";
196
214
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
197
215
  import { buildGjcRuntimeSessionEnv, consumePendingGoalModeRequest } from "../gjc-runtime/goal-mode-request";
198
216
  import {
@@ -213,7 +231,12 @@ import { ensureWorkflowSkillActivationState } from "../hooks/skill-state";
213
231
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
214
232
  import { shutdownAll as shutdownAllLspClients } from "../lsp/client";
215
233
  import { resolveMemoryBackend } from "../memory-backend";
216
- import type { WorkflowGateEmitter } from "../modes/shared/agent-wire/unattended-session";
234
+ import {
235
+ BrokerWorkflowGateEmitter,
236
+ FileGateStore,
237
+ MemoryGateStore,
238
+ type WorkflowGateEmitter,
239
+ } from "../modes/shared/agent-wire/workflow-gate-broker";
217
240
  import { getCurrentThemeName, theme } from "../modes/theme/theme";
218
241
  import type { PlanModeState } from "../plan-mode/state";
219
242
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
@@ -238,6 +261,7 @@ import {
238
261
  selectDiscoverableMCPToolNamesByServer,
239
262
  } from "../runtime-mcp/discoverable-tool-metadata";
240
263
  import { MCPManager } from "../runtime-mcp/manager";
264
+ import type { NotificationSessionController } from "../sdk/bus/session-control";
241
265
  import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
242
266
  import { formatNoCredentialOnboardingError, formatNoModelOnboardingError } from "../setup/model-onboarding-guidance";
243
267
  import {
@@ -279,7 +303,26 @@ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-
279
303
  import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../workflow/workflow-intent-diff";
280
304
  import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
281
305
  import type { AuthStorage } from "./auth-storage";
282
- import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
306
+ import {
307
+ DefaultModelSelectionRecoveryError,
308
+ type DefaultModelSelectionResult,
309
+ type DefaultModelSelectionRollbackStage,
310
+ } from "./default-model-selection";
311
+ import {
312
+ type ConfiguredFallbackChain,
313
+ effectiveFallbackDelay,
314
+ FallbackChainController,
315
+ type FallbackTriggerClass,
316
+ } from "./fallback-chain-controller";
317
+
318
+ export { DefaultModelSelectionRecoveryError } from "./default-model-selection";
319
+
320
+ import type {
321
+ ClientBridge,
322
+ ClientBridgePermissionOption,
323
+ ClientBridgePermissionOutcome,
324
+ ClientBridgePermissionToolCall,
325
+ } from "./client-bridge";
283
326
  import { computeNonMessageTokens } from "./context-estimation";
284
327
  import {
285
328
  type ContributionPrepOptions,
@@ -303,6 +346,7 @@ import { formatSessionDumpText } from "./session-dump-format";
303
346
  import type {
304
347
  BranchSummaryEntry,
305
348
  CompactionEntry,
349
+ DefaultModelSelectionStage,
306
350
  NewSessionOptions,
307
351
  SessionContext,
308
352
  SessionManager,
@@ -342,8 +386,18 @@ export type AgentSessionEvent =
342
386
  unbounded?: boolean;
343
387
  }
344
388
  | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
345
- | { type: "retry_fallback_applied"; from: string; to: string; role: string }
346
- | { type: "retry_fallback_succeeded"; model: string; role: string }
389
+ | {
390
+ type: "model_fallback_switched";
391
+ eventId: string;
392
+ from: string;
393
+ to: string;
394
+ reason: string;
395
+ role: string;
396
+ scope: string;
397
+ activeIndex: number;
398
+ chainLength: number;
399
+ attemptsUsed: number;
400
+ }
347
401
  | { type: "ttsr_triggered"; rules: Rule[] }
348
402
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
349
403
  | { type: "todo_auto_clear" }
@@ -384,6 +438,8 @@ export interface AgentSessionConfig {
384
438
  agent: Agent;
385
439
  sessionManager: SessionManager;
386
440
  settings: Settings;
441
+ /** Shared Gate-A-eligible notification session controller, when this host supports it. */
442
+ notificationSessionController?: NotificationSessionController;
387
443
  /** Models to cycle through with Alt+N (from --models flag) */
388
444
  scopedModels?: ScopedModelSelection[];
389
445
  /** Initial session thinking selector. */
@@ -394,6 +450,7 @@ export interface AgentSessionConfig {
394
450
  slashCommands?: FileSlashCommand[];
395
451
  /** Extension runner (created in main.ts with wrapped tools) */
396
452
  extensionRunner?: ExtensionRunner;
453
+
397
454
  /** Loaded skills (already discovered by SDK) */
398
455
  skills?: Skill[];
399
456
  /** Skill loading warnings (already captured by SDK) */
@@ -422,7 +479,11 @@ export interface AgentSessionConfig {
422
479
  /** Current session message-to-LLM conversion pipeline */
423
480
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
424
481
  /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
425
- rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
482
+ rebuildSystemPrompt?: (
483
+ toolNames: string[],
484
+ tools: Map<string, AgentTool>,
485
+ candidateModel?: Model,
486
+ ) => Promise<{ systemPrompt: string[] }>;
426
487
  /** Initial workspace tree snapshot used for the first volatile per-turn context message. */
427
488
  workspaceTree?: WorkspaceTree;
428
489
  /** Rebuild the SSH tool from current capability discovery results. */
@@ -487,6 +548,14 @@ export interface AgentSessionConfig {
487
548
  providerCacheSessionId?: string;
488
549
  }
489
550
 
551
+ type MidRunMaintenanceLifecycle = Parameters<NonNullable<AgentLoopConfig["maintainContext"]>>[1];
552
+
553
+ type AutoCompactionTerminalStatus =
554
+ | { kind: "compacted" }
555
+ | { kind: "aborted"; source: "signal" | "hook" }
556
+ | { kind: "skipped" }
557
+ | { kind: "failed" };
558
+
490
559
  /** Options for AgentSession.prompt() */
491
560
  export interface PromptOptions {
492
561
  /** Whether to expand file-based prompt templates (default: true) */
@@ -505,6 +574,21 @@ export interface PromptOptions {
505
574
  attribution?: MessageAttribution;
506
575
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
507
576
  skipCompactionCheck?: boolean;
577
+ /**
578
+ * Invoked after all prompt preflight checks pass and immediately before agent execution begins.
579
+ * Cancellation before this callback rejects the prompt.
580
+ */
581
+ onPreflightAccepted?: () => void;
582
+ }
583
+
584
+ function promptPreflightCancelledError(): Error {
585
+ const error = Object.assign(new Error("Prompt preflight was cancelled before execution."), { code: "busy" });
586
+ error.name = "PromptPreflightCancelledError";
587
+ return error;
588
+ }
589
+
590
+ function isPromptPreflightCancelledError(error: unknown): boolean {
591
+ return error instanceof Error && error.name === "PromptPreflightCancelledError";
508
592
  }
509
593
 
510
594
  /** Result from a handoff operation. */
@@ -526,6 +610,37 @@ export interface ModelCycleResult {
526
610
  isScoped: boolean;
527
611
  }
528
612
 
613
+ export type ModelChangeCause =
614
+ | "user-selection"
615
+ | "profile-activation"
616
+ | "fallback-switch"
617
+ | "restore"
618
+ | "rollback"
619
+ | "startup-override"
620
+ | "temporary-operation";
621
+
622
+ export type TemporaryModelReason =
623
+ | "plan-mode"
624
+ | "context-promotion"
625
+ | "temporary-cycle"
626
+ | "profile-preview"
627
+ | "extension-temporary"
628
+ | "other";
629
+
630
+ /** Opaque handle for a non-destructive temporary provider-session scope. */
631
+ export interface TemporaryProviderSessionScope {
632
+ readonly reason: TemporaryModelReason;
633
+ }
634
+
635
+ interface TemporaryProviderSessionScopeRecord {
636
+ token: TemporaryProviderSessionScope;
637
+ autoOwned: boolean;
638
+ model: Model | undefined;
639
+ thinkingLevel: ThinkingLevel | undefined;
640
+ fallbackController: FallbackChainController | undefined;
641
+ providerSessionState: Map<string, ProviderSessionState>;
642
+ }
643
+
529
644
  /** Result from cycleRoleModels() */
530
645
  export interface RoleModelCycleResult {
531
646
  model: Model;
@@ -533,12 +648,6 @@ export interface RoleModelCycleResult {
533
648
  role: string;
534
649
  }
535
650
 
536
- export interface DefaultModelSelectionResult {
537
- readonly provider: string;
538
- readonly modelId: string;
539
- readonly thinkingLevel: ResolvedThinkingLevel;
540
- }
541
-
542
651
  /** Session statistics for /session command */
543
652
  export interface SessionStats {
544
653
  sessionFile: string | undefined;
@@ -567,9 +676,6 @@ export interface SessionStats {
567
676
 
568
677
  /** Standard thinking levels */
569
678
 
570
- type RetryFallbackChains = Record<string, string[]>;
571
-
572
- type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
573
679
  type RetryErrorClassification =
574
680
  | "none"
575
681
  | "overflow"
@@ -580,42 +686,6 @@ type RetryErrorClassification =
580
686
  | "local_unavailable"
581
687
  | "unknown";
582
688
 
583
- interface RetryFallbackSelector {
584
- raw: string;
585
- provider: string;
586
- id: string;
587
- thinkingLevel: ThinkingLevel | undefined;
588
- }
589
-
590
- interface ActiveRetryFallbackState {
591
- role: string;
592
- originalSelector: string;
593
- originalThinkingLevel: ThinkingLevel | undefined;
594
- lastAppliedFallbackThinkingLevel: ThinkingLevel | undefined;
595
- }
596
-
597
- function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
598
- const trimmed = selector.trim();
599
- if (!trimmed) return undefined;
600
- const parsed = parseModelString(trimmed);
601
- if (!parsed) return undefined;
602
- return {
603
- raw: trimmed,
604
- provider: parsed.provider,
605
- id: parsed.id,
606
- thinkingLevel: parsed.thinkingLevel,
607
- };
608
- }
609
-
610
- function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
611
- const selector = formatModelString(model);
612
- return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
613
- }
614
-
615
- function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
616
- return `${selector.provider}/${selector.id}`;
617
- }
618
-
619
689
  function isLocalModelEndpoint(model: Model | undefined): boolean {
620
690
  if (!model) return false;
621
691
  if (model.provider === "ollama" || model.provider === "lm-studio" || model.provider === "llama.cpp") {
@@ -1175,10 +1245,24 @@ export class StreamingEditFileCache {
1175
1245
  return this.#totalBytes;
1176
1246
  }
1177
1247
  }
1248
+ type SessionAdmissionKind = "prompt" | "selection";
1249
+
1250
+ type SessionAdmissionEntry = {
1251
+ kind: SessionAdmissionKind;
1252
+ ready: PromiseWithResolvers<void>;
1253
+ settled: PromiseWithResolvers<void>;
1254
+ released: boolean;
1255
+ };
1256
+
1257
+ type SessionAdmissionLease = {
1258
+ release(): void;
1259
+ };
1260
+
1178
1261
  export class AgentSession {
1179
1262
  readonly agent: Agent;
1180
1263
  readonly sessionManager: SessionManager;
1181
1264
  readonly settings: Settings;
1265
+ readonly notificationSessionController: NotificationSessionController | undefined;
1182
1266
  readonly taskDepth: number;
1183
1267
  readonly yieldQueue: YieldQueue;
1184
1268
 
@@ -1189,7 +1273,11 @@ export class AgentSession {
1189
1273
  #scopedModels: ScopedModelSelection[];
1190
1274
  #thinkingLevel: ThinkingLevel | undefined;
1191
1275
  #activeModelProfile: string | undefined;
1192
- #defaultModelSelectionTail: Promise<void> = Promise.resolve();
1276
+ #sessionAdmissionQueue: SessionAdmissionEntry[] = [];
1277
+ #activeSessionAdmission: SessionAdmissionEntry | undefined;
1278
+ #sessionAdmissionClosed = false;
1279
+ #sessionAdmissionContext = new AsyncLocalStorage<SessionAdmissionEntry>();
1280
+ #defaultModelSelectionMutationRevision = 0;
1193
1281
  #promptTemplates: PromptTemplate[];
1194
1282
  #slashCommands: FileSlashCommand[];
1195
1283
 
@@ -1200,6 +1288,8 @@ export class AgentSession {
1200
1288
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
1201
1289
  #eventListeners: AgentSessionEventListener[] = [];
1202
1290
  #eventListenerSnapshot: readonly AgentSessionEventListener[] = Object.freeze([]);
1291
+ /** Resolution-time switches that occur before a consumer can subscribe. */
1292
+ #pendingFallbackSwitches: Extract<AgentSessionEvent, { type: "model_fallback_switched" }>[] = [];
1203
1293
 
1204
1294
  #rebuildEventListenerSnapshot(): void {
1205
1295
  this.#eventListenerSnapshot = Object.freeze([...this.#eventListeners]);
@@ -1233,12 +1323,36 @@ export class AgentSession {
1233
1323
  #allowAcpAgentInitiatedTurns = false;
1234
1324
  /** Per-session memory of allow_always / reject_always decisions for gated tools. */
1235
1325
  #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
1326
+ /** SDK-controlled permission policy applied before ACP client prompting. Defaults to `allow` so callers
1327
+ * without a reverse permission provider (TUI, print/headless) run guarded tools; ACP/SDK set this explicitly. */
1328
+ #sdkPermissionMode: "prompt" | "allow" | "deny" = "allow";
1329
+ /** Permission provider registered by a live SDK reverse lease. */
1330
+ #sdkPermissionProvider:
1331
+ | ((
1332
+ toolCall: ClientBridgePermissionToolCall,
1333
+ options: ClientBridgePermissionOption[],
1334
+ signal?: AbortSignal,
1335
+ ) => Promise<ClientBridgePermissionOutcome>)
1336
+ | undefined;
1337
+
1236
1338
  #guardedToolWrapperCache = new WeakMap<AgentTool, Map<string, AgentTool>>();
1237
1339
  #acpPermissionWrapperVersion = 0;
1238
1340
 
1239
1341
  // Compaction state
1240
1342
  #compactionAbortController: AbortController | undefined = undefined;
1241
1343
  #autoCompactionAbortController: AbortController | undefined = undefined;
1344
+
1345
+ /** Invocation-scoped EventStream drain barriers owned by active maintenance calls. */
1346
+ #activeMidRunBarrierControllers = new Set<AbortController>();
1347
+ /** Maintenance invocations that must settle before resources are torn down. */
1348
+ #activeMidRunMaintenancePromises = new Set<Promise<MidRunMaintenanceOutcome>>();
1349
+ // Anti-loop guard (#1662): signature of the assistant response that last
1350
+ // anchored a mid-run maintenance attempt. A given provider response drives at
1351
+ // most one attempt, so a compaction that cannot shrink further can't wedge the
1352
+ // loop into interrupt → resume → interrupt; a NEW response (fresh signature) is
1353
+ // required to re-trigger. A content signature (not object identity) is used so
1354
+ // it survives the message-array rebuild that compaction/prune perform.
1355
+ #lastMidRunMaintenanceAnchorSignature: string | undefined = undefined;
1242
1356
  #resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
1243
1357
  #retainedMemorySampler: (() => RetainedMemorySample) | undefined;
1244
1358
  #prePromptContextCheckPromise: Promise<void> | undefined = undefined;
@@ -1261,7 +1375,9 @@ export class AgentSession {
1261
1375
  #retryAttempt = 0;
1262
1376
  #retryPromise: Promise<void> | undefined = undefined;
1263
1377
  #retryResolve: (() => void) | undefined = undefined;
1264
- #activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
1378
+ #defaultFallbackController: FallbackChainController | undefined;
1379
+ #defaultFallbackExhaustedLastTurn = false;
1380
+ #fallbackInvocationId = 0;
1265
1381
  // Todo completion reminder state
1266
1382
  #todoReminderCount = 0;
1267
1383
  #lastGoalReminderAssistantTimestamp: number | undefined = undefined;
@@ -1302,8 +1418,10 @@ export class AgentSession {
1302
1418
  #providerSessionId: string | undefined;
1303
1419
  #providerCacheSessionId: string | undefined;
1304
1420
  #isDisposed = false;
1421
+ #disposePromise: Promise<void> | undefined;
1305
1422
  // Extension system
1306
1423
  #extensionRunner: ExtensionRunner | undefined = undefined;
1424
+
1307
1425
  #turnIndex = 0;
1308
1426
  #workerIntegrationScheduler = new WorkerIntegrationRequestScheduler(async () => {
1309
1427
  await requestGjcWorkerIntegrationAttempt(this.sessionManager.getCwd(), process.env).catch(error => {
@@ -1336,7 +1454,11 @@ export class AgentSession {
1336
1454
  #onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
1337
1455
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
1338
1456
  #rebuildSystemPrompt:
1339
- | ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
1457
+ | ((
1458
+ toolNames: string[],
1459
+ tools: Map<string, AgentTool>,
1460
+ candidateModel?: Model,
1461
+ ) => Promise<{ systemPrompt: string[] }>)
1340
1462
  | undefined;
1341
1463
  #getMcpServerInstructions: (() => Map<string, string> | undefined) | undefined;
1342
1464
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
@@ -1353,6 +1475,9 @@ export class AgentSession {
1353
1475
  * the dominant cause of prompt-cache invalidation in long sessions.
1354
1476
  */
1355
1477
  #lastAppliedToolSignature: string | undefined;
1478
+ #pendingAppliedToolSignature: string | undefined;
1479
+ #baseSystemPromptGeneration = 0;
1480
+ #pendingBaseSystemPromptRebuilds = new Set<Promise<void>>();
1356
1481
  #mcpDiscoveryEnabled = false;
1357
1482
  #discoverableMCPTools = new Map<string, DiscoverableMCPTool>();
1358
1483
  #discoverableMCPSearchIndex: DiscoverableMCPSearchIndex | null = null;
@@ -1361,7 +1486,6 @@ export class AgentSession {
1361
1486
  #discoverableToolSearchIndex: DiscoverableToolSearchIndex | null = null;
1362
1487
  #selectedDiscoveredToolNames = new Set<string>();
1363
1488
  #discoverableToolAllowedNames: ReadonlySet<string> | undefined;
1364
- #rpcHostToolNames = new Set<string>();
1365
1489
  #gjcSubskillToolNames = new Set<string>();
1366
1490
  #gjcSubskillToolSignature: string | undefined;
1367
1491
  #defaultSelectedMCPServerNames = new Set<string>();
@@ -1431,15 +1555,17 @@ export class AgentSession {
1431
1555
  nonEditDeterminations: 0,
1432
1556
  };
1433
1557
  #promptInFlightCount = 0;
1434
- // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
1435
- // Local subscribers and extension hooks both receive the deferred event from
1436
- // #flushPendingAgentEnd(), preserving the local-before-extension ordering used
1437
- // by #emitSessionEvent while still preventing external clients from resuming
1438
- // before #promptWithMessage's finally decrements #promptInFlightCount. Without
1439
- // this, a client that resumes on `agent_end` can fire its next `prompt` while
1440
- // the session still reports busy and hit AgentBusyError. Flushed from both
1441
- // #endInFlight (normal) and #resetInFlight (abort).
1558
+ #agentEventHandlersInFlight = 0;
1559
+ #queuedExtensionEventCount = 0;
1560
+ // Wire-level agent_end emission is deferred until both the prompt finalizer and
1561
+ // async event handlers settle. Subscribers treat agent_end as readiness, so
1562
+ // publishing it earlier lets a successor corrupt the prior prompt's lifecycle.
1442
1563
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1564
+ // A scheduled continuation owns this terminal boundary until it either starts
1565
+ // the successor or proves it cannot. Holds prevent a false idle event while
1566
+ // preserving the predecessor for cancellation and preflight failures.
1567
+ #pendingAgentEndContinuationHolds = new Map<symbol, AgentSessionEvent>();
1568
+
1443
1569
  #obfuscator: SecretObfuscator | undefined;
1444
1570
  #checkpointState: CheckpointState | undefined = undefined;
1445
1571
  #providerReplaySourceCache = new WeakMap<AgentMessage, ProviderReplaySourceCacheEntry>();
@@ -1448,7 +1574,11 @@ export class AgentSession {
1448
1574
  #pendingRewindReport: string | undefined = undefined;
1449
1575
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1450
1576
  #promptGeneration = 0;
1577
+ #promptPreflightAbortController = new AbortController();
1578
+
1451
1579
  #providerSessionState = new Map<string, ProviderSessionState>();
1580
+ #temporaryProviderSessionScopes: TemporaryProviderSessionScopeRecord[] = [];
1581
+
1452
1582
  /**
1453
1583
  * Provider keys for which the Anthropic fast-mode auto-fallback fired this
1454
1584
  * session (the provider rejected `speed:"fast"` and we retried without it).
@@ -1495,6 +1625,75 @@ export class AgentSession {
1495
1625
  }
1496
1626
  }
1497
1627
 
1628
+ #sessionAdmissionBusyError(): AgentBusyError {
1629
+ return Object.assign(new AgentBusyError("Agent session admission is busy due to same-session reentrancy."), {
1630
+ code: "busy",
1631
+ });
1632
+ }
1633
+
1634
+ #activateNextSessionAdmission(): void {
1635
+ if (this.#activeSessionAdmission || this.#sessionAdmissionClosed) return;
1636
+ const next = this.#sessionAdmissionQueue.shift();
1637
+ if (!next) return;
1638
+ this.#activeSessionAdmission = next;
1639
+ next.ready.resolve();
1640
+ }
1641
+
1642
+ async #withSessionAdmission<T>(
1643
+ kind: SessionAdmissionKind,
1644
+ body: (lease: SessionAdmissionLease) => Promise<T>,
1645
+ ): Promise<T> {
1646
+ const owner = this.#sessionAdmissionContext.getStore();
1647
+ if (owner && !owner.released) throw this.#sessionAdmissionBusyError();
1648
+ if (this.#sessionAdmissionClosed || this.#isDisposed) throw this.#sessionAdmissionBusyError();
1649
+
1650
+ const entry: SessionAdmissionEntry = {
1651
+ kind,
1652
+ ready: Promise.withResolvers<void>(),
1653
+ settled: Promise.withResolvers<void>(),
1654
+ released: false,
1655
+ };
1656
+ this.#sessionAdmissionQueue.push(entry);
1657
+ this.#activateNextSessionAdmission();
1658
+ await entry.ready.promise;
1659
+ if (this.#sessionAdmissionClosed || this.#isDisposed) {
1660
+ entry.released = true;
1661
+ entry.settled.resolve();
1662
+ if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
1663
+ this.#activateNextSessionAdmission();
1664
+ throw this.#sessionAdmissionBusyError();
1665
+ }
1666
+
1667
+ const release = () => {
1668
+ if (entry.released) return;
1669
+ entry.released = true;
1670
+ entry.settled.resolve();
1671
+ if (this.#activeSessionAdmission === entry) this.#activeSessionAdmission = undefined;
1672
+ this.#activateNextSessionAdmission();
1673
+ };
1674
+ try {
1675
+ return await this.#sessionAdmissionContext.run(entry, () => body({ release }));
1676
+ } finally {
1677
+ release();
1678
+ }
1679
+ }
1680
+
1681
+ async #closeSessionAdmission(): Promise<void> {
1682
+ this.#sessionAdmissionClosed = true;
1683
+ const queued = this.#sessionAdmissionQueue.splice(0);
1684
+ for (const entry of queued) {
1685
+ entry.released = true;
1686
+ entry.ready.reject(this.#sessionAdmissionBusyError());
1687
+ entry.settled.resolve();
1688
+ }
1689
+ const active = this.#activeSessionAdmission;
1690
+ if (active?.kind === "prompt") {
1691
+ this.#promptGeneration++;
1692
+ this.#promptPreflightAbortController.abort();
1693
+ }
1694
+ if (active) await active.settled.promise;
1695
+ }
1696
+
1498
1697
  #beginInFlight(): void {
1499
1698
  this.#promptInFlightCount++;
1500
1699
  if (this.#promptInFlightCount === 1) {
@@ -1502,6 +1701,75 @@ export class AgentSession {
1502
1701
  }
1503
1702
  }
1504
1703
 
1704
+ #isPromptPreflightCancelled(generation: number, signal: AbortSignal): boolean {
1705
+ return signal.aborted || this.#promptGeneration !== generation;
1706
+ }
1707
+
1708
+ #throwIfPromptPreflightCancelled(generation: number, signal: AbortSignal): void {
1709
+ if (this.#isPromptPreflightCancelled(generation, signal)) {
1710
+ throw promptPreflightCancelledError();
1711
+ }
1712
+ }
1713
+
1714
+ async #awaitPromptPreflight<T>(generation: number, signal: AbortSignal, pending: Promise<T>): Promise<T> {
1715
+ this.#throwIfPromptPreflightCancelled(generation, signal);
1716
+ const cancellation = Promise.withResolvers<never>();
1717
+ const cancel = () => cancellation.reject(promptPreflightCancelledError());
1718
+ signal.addEventListener("abort", cancel, { once: true });
1719
+ try {
1720
+ const result = await Promise.race([pending, cancellation.promise]);
1721
+ this.#throwIfPromptPreflightCancelled(generation, signal);
1722
+ return result;
1723
+ } finally {
1724
+ signal.removeEventListener("abort", cancel);
1725
+ }
1726
+ }
1727
+
1728
+ #reserveDeferredAgentEndForContinuation(): symbol | undefined {
1729
+ const pending = this.#pendingAgentEndEmit;
1730
+ if (!pending) return undefined;
1731
+ const hold = Symbol("deferred-agent-end-continuation");
1732
+ this.#pendingAgentEndContinuationHolds.set(hold, pending);
1733
+ return hold;
1734
+ }
1735
+
1736
+ #claimDeferredAgentEndForContinuation(hold: symbol | undefined): AgentSessionEvent | undefined {
1737
+ if (!hold) return undefined;
1738
+ const pending = this.#pendingAgentEndContinuationHolds.get(hold);
1739
+ this.#pendingAgentEndContinuationHolds.delete(hold);
1740
+ if (pending && this.#pendingAgentEndEmit === pending) {
1741
+ this.#pendingAgentEndEmit = undefined;
1742
+ for (const [candidate, candidatePending] of this.#pendingAgentEndContinuationHolds) {
1743
+ if (candidatePending === pending) this.#pendingAgentEndContinuationHolds.delete(candidate);
1744
+ }
1745
+ }
1746
+ return pending;
1747
+ }
1748
+
1749
+ #restoreDeferredAgentEndAfterContinuationFailure(pending: AgentSessionEvent | undefined): void {
1750
+ if (pending && !this.#pendingAgentEndEmit) {
1751
+ this.#pendingAgentEndEmit = pending;
1752
+ }
1753
+ this.#flushPendingAgentEnd();
1754
+ }
1755
+
1756
+ #releaseDeferredAgentEndContinuation(hold: symbol | undefined): void {
1757
+ if (!hold) return;
1758
+ const pending = this.#pendingAgentEndContinuationHolds.get(hold);
1759
+ this.#pendingAgentEndContinuationHolds.delete(hold);
1760
+ this.#restoreDeferredAgentEndAfterContinuationFailure(pending);
1761
+ }
1762
+
1763
+ #releaseDeferredAgentEndContinuations(): void {
1764
+ let pending: AgentSessionEvent | undefined;
1765
+ for (const candidate of this.#pendingAgentEndContinuationHolds.values()) {
1766
+ pending = candidate;
1767
+ break;
1768
+ }
1769
+ this.#pendingAgentEndContinuationHolds.clear();
1770
+ this.#restoreDeferredAgentEndAfterContinuationFailure(pending);
1771
+ }
1772
+
1505
1773
  #endInFlight(): void {
1506
1774
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
1507
1775
  if (this.#promptInFlightCount === 0) {
@@ -1511,17 +1779,21 @@ export class AgentSession {
1511
1779
  }
1512
1780
  }
1513
1781
 
1514
- #resetInFlight(): void {
1515
- this.#promptInFlightCount = 0;
1516
- this.#releasePowerAssertion();
1517
- this.#flushPendingBackgroundExchanges();
1518
- this.#flushPendingAgentEnd();
1519
- }
1520
-
1521
1782
  #flushPendingAgentEnd(): void {
1783
+ if (
1784
+ this.#promptInFlightCount > 0 ||
1785
+ this.#agentEventHandlersInFlight > 0 ||
1786
+ this.#queuedExtensionEventCount > 0 ||
1787
+ this.#pendingAgentEndContinuationHolds.size > 0
1788
+ )
1789
+ return;
1522
1790
  const pending = this.#pendingAgentEndEmit;
1523
1791
  if (!pending) return;
1524
1792
  this.#pendingAgentEndEmit = undefined;
1793
+ // Persist before notifying synchronous subscribers: a subscriber may start a
1794
+ // successor prompt from agent_end, whose running state must serialize after
1795
+ // this terminal boundary rather than be overwritten by it.
1796
+ this.#persistRuntimeStateInBackground(pending);
1525
1797
  this.#emit(pending);
1526
1798
  void this.#queueExtensionEvent(pending);
1527
1799
  }
@@ -1530,6 +1802,7 @@ export class AgentSession {
1530
1802
  this.agent = config.agent;
1531
1803
  this.sessionManager = config.sessionManager;
1532
1804
  this.settings = config.settings;
1805
+ this.notificationSessionController = config.notificationSessionController;
1533
1806
  this.taskDepth = config.taskDepth ?? 0;
1534
1807
  // Register this session with the process-wide resource GC (idle/RSS browser-tab eviction
1535
1808
  // + stale screenshot cleanup). Session-keyed so concurrent sessions share one timer safely.
@@ -1563,7 +1836,6 @@ export class AgentSession {
1563
1836
  if (config.providerSessionState) {
1564
1837
  this.#providerSessionState = config.providerSessionState;
1565
1838
  }
1566
- this.#validateRetryFallbackChains();
1567
1839
  this.#toolRegistry = config.toolRegistry ?? new Map();
1568
1840
  this.#workflowGateToolSession = config.workflowGateToolSession;
1569
1841
  this.#requestedToolNames = config.requestedToolNames;
@@ -1595,13 +1867,18 @@ export class AgentSession {
1595
1867
  this.agent.setProviderResponseInterceptor(this.#onResponse);
1596
1868
  this.agent.setRawSseEventInterceptor(this.#onSseEvent);
1597
1869
  this.#setGuardedAgentTools(this.agent.state.tools);
1870
+ this.#bindWorkflowGateEmitter();
1598
1871
  this.yieldQueue = new YieldQueue({
1599
1872
  isStreaming: () => this.isStreaming,
1600
1873
  injectStreaming: message => this.agent.followUp(message),
1601
1874
  injectIdle: async messages => {
1602
1875
  const first = messages[0];
1603
1876
  if (!first) return;
1604
- await this.agent.prompt(messages.length === 1 ? first : messages);
1877
+ if (messages.length === 1) {
1878
+ await this.agent.prompt(first, this.#managedFallbackPromptOptions());
1879
+ } else {
1880
+ await this.agent.prompt(messages, this.#managedFallbackPromptOptions());
1881
+ }
1605
1882
  },
1606
1883
  scheduleIdleFlush: run => {
1607
1884
  this.#schedulePostPromptTask(
@@ -1613,6 +1890,9 @@ export class AgentSession {
1613
1890
  },
1614
1891
  });
1615
1892
  this.agent.setOnBeforeYield(() => this.yieldQueue.flush("streaming"));
1893
+ this.agent.setMaintainContext((context, lifecycle) =>
1894
+ this.#trackMidRunMaintenance(this.#runMidRunMaintenance(context, lifecycle)),
1895
+ );
1616
1896
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
1617
1897
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
1618
1898
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -1695,9 +1975,16 @@ export class AgentSession {
1695
1975
 
1696
1976
  // Always subscribe to agent events for internal handling
1697
1977
  // (session persistence, hooks, auto-compaction, retry logic)
1698
- this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
1978
+ this.#unsubscribeAgent = this.agent.subscribe(this.#trackAgentEvent);
1979
+
1699
1980
  // Re-evaluate append-only context mode when the setting changes at runtime.
1700
1981
  this.#unsubscribeAppendOnly = onAppendOnlyModeChanged(_value => this.#syncAppendOnlyContext(this.model));
1982
+ // SDK ToolSession callbacks capture the just-constructed session. Defer the
1983
+ // initial ask-tool attachment until that capture has been assigned by the
1984
+ // session factory, while retaining the durable emitter created above.
1985
+ queueMicrotask(() => {
1986
+ if (!this.#isDisposed) this.#ensureWorkflowGateAskTool();
1987
+ });
1701
1988
  }
1702
1989
 
1703
1990
  /** Model registry for API key resolution and model discovery */
@@ -1795,6 +2082,90 @@ export class AgentSession {
1795
2082
  return this.#providerSessionState;
1796
2083
  }
1797
2084
 
2085
+ /** Suspend provider state without closing it while a temporary model is active. */
2086
+ beginTemporaryProviderSessionScope(reason: TemporaryModelReason): TemporaryProviderSessionScope {
2087
+ return this.#beginTemporaryProviderSessionScope(reason, false);
2088
+ }
2089
+
2090
+ #beginTemporaryProviderSessionScope(
2091
+ reason: TemporaryModelReason,
2092
+ autoOwned: boolean,
2093
+ ): TemporaryProviderSessionScope {
2094
+ const token: TemporaryProviderSessionScope = Object.freeze({ reason });
2095
+ this.#temporaryProviderSessionScopes.push({
2096
+ token,
2097
+ autoOwned,
2098
+ model: this.model,
2099
+ thinkingLevel: this.#thinkingLevel,
2100
+ fallbackController: this.#defaultFallbackController,
2101
+ providerSessionState: this.#providerSessionState,
2102
+ });
2103
+ this.#rebindProviderSessionState(new Map());
2104
+ const temporaryModel = this.model;
2105
+ this.#defaultFallbackController = new FallbackChainController(
2106
+ {
2107
+ role: "default",
2108
+ entries: temporaryModel ? [formatModelString(temporaryModel)] : [],
2109
+ origin: "temporary-provider-scope",
2110
+ explicitHead: true,
2111
+ },
2112
+ this.settings.get("fallback.maxAttempts"),
2113
+ );
2114
+ return token;
2115
+ }
2116
+
2117
+ /** Returns the topmost auto-owned scope without retaining a separate ownership handle. */
2118
+ #currentAutoTemporaryProviderSessionScope(): TemporaryProviderSessionScopeRecord | undefined {
2119
+ return this.#temporaryProviderSessionScopes.findLast(scope => scope.autoOwned);
2120
+ }
2121
+
2122
+ /** Restore a temporary scope, unwinding any auto-owned scopes above it. */
2123
+ restoreTemporaryProviderSessionScope(token: TemporaryProviderSessionScope): boolean {
2124
+ const scopeIndex = this.#temporaryProviderSessionScopes.findLastIndex(scope => scope.token === token);
2125
+ if (
2126
+ scopeIndex < 0 ||
2127
+ this.#temporaryProviderSessionScopes.slice(scopeIndex + 1).some(scope => !scope.autoOwned)
2128
+ ) {
2129
+ return false;
2130
+ }
2131
+ while (this.#temporaryProviderSessionScopes.length > scopeIndex) {
2132
+ this.#restoreTopTemporaryProviderSessionScope();
2133
+ }
2134
+ return true;
2135
+ }
2136
+
2137
+ #restoreTopTemporaryProviderSessionScope(): void {
2138
+ const scope = this.#temporaryProviderSessionScopes.pop();
2139
+ if (!scope) return;
2140
+ this.#closeProviderSessionMap(this.#providerSessionState, "temporary scope restore");
2141
+ this.#rebindProviderSessionState(scope.providerSessionState);
2142
+ this.#defaultFallbackController = scope.fallbackController;
2143
+ const previousEditMode = this.#resolveActiveEditMode();
2144
+ if (scope.model) {
2145
+ this.agent.setModel(scope.model);
2146
+ this.#syncAppendOnlyContext(scope.model);
2147
+ }
2148
+ this.#thinkingLevel = scope.thinkingLevel;
2149
+ this.agent.setThinkingLevel(toReasoningEffort(scope.thinkingLevel));
2150
+ void this.#syncEditToolModeAfterModelChange(previousEditMode);
2151
+ }
2152
+
2153
+ /** Promote a temporary scope. The suspended provider state is permanently closed. */
2154
+ commitTemporaryProviderSessionScope(token: TemporaryProviderSessionScope): boolean {
2155
+ const scope = this.#temporaryProviderSessionScopes.at(-1);
2156
+ if (!scope || scope.token !== token) return false;
2157
+ this.#temporaryProviderSessionScopes.pop();
2158
+ this.#closeProviderSessionMap(scope.providerSessionState, "temporary scope commit");
2159
+ return true;
2160
+ }
2161
+
2162
+ /** Permanently discard every suspended map while retaining the active map. */
2163
+ #commitAllTemporaryProviderSessionScopes(): void {
2164
+ const scopes = this.#temporaryProviderSessionScopes;
2165
+ this.#temporaryProviderSessionScopes = [];
2166
+ for (const scope of scopes) this.#closeProviderSessionMap(scope.providerSessionState, "permanent model change");
2167
+ }
2168
+
1798
2169
  async buildForkContextSeed(options: ForkContextSeedOptions): Promise<ForkContextSeed> {
1799
2170
  const maxMessages = Math.min(500, Math.max(0, Math.trunc(options.maxMessages)));
1800
2171
  const maxTokens = Math.max(0, Math.trunc(options.maxTokens));
@@ -2107,14 +2478,32 @@ export class AgentSession {
2107
2478
  #queuedExtensionEvents: Promise<void> = Promise.resolve();
2108
2479
 
2109
2480
  #queueExtensionEvent(event: AgentSessionEvent): Promise<void> {
2481
+ this.#queuedExtensionEventCount++;
2110
2482
  const emit = async () => {
2111
2483
  await this.#emitExtensionEvent(event);
2112
2484
  };
2113
2485
  const queued = this.#queuedExtensionEvents.then(emit, emit);
2114
2486
  this.#queuedExtensionEvents = queued.catch(() => {});
2487
+ const settled = () => {
2488
+ this.#queuedExtensionEventCount = Math.max(0, this.#queuedExtensionEventCount - 1);
2489
+ this.#flushPendingAgentEnd();
2490
+ };
2491
+ void queued.then(settled, settled);
2115
2492
  return queued;
2116
2493
  }
2117
2494
 
2495
+ #trackAgentEvent = async (event: AgentEvent): Promise<void> => {
2496
+ this.#agentEventHandlersInFlight++;
2497
+ try {
2498
+ await this.#handleAgentEvent(event);
2499
+ } catch (error) {
2500
+ logger.warn("Agent event handler failed", { event: event.type, error: String(error) });
2501
+ } finally {
2502
+ this.#agentEventHandlersInFlight = Math.max(0, this.#agentEventHandlersInFlight - 1);
2503
+ this.#flushPendingAgentEnd();
2504
+ }
2505
+ };
2506
+
2118
2507
  #persistRuntimeStateInBackground(event: AgentSessionEvent): void {
2119
2508
  void persistCoordinatorRuntimeStateFromEvent(event, {
2120
2509
  sessionId: this.sessionId,
@@ -2136,21 +2525,34 @@ export class AgentSession {
2136
2525
  }
2137
2526
  return;
2138
2527
  }
2528
+ // A maintenance agent_end is an internal checkpoint only while another
2529
+ // continuation will follow. An aborted maintenance run is its terminal
2530
+ // settlement and must reach public subscribers.
2531
+ if (event.type === "agent_end" && event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted")
2532
+ return;
2533
+
2139
2534
  const persistRuntimeState = () => this.#persistRuntimeStateInBackground(event);
2140
- // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
2141
- // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
2142
- // emitting while #promptInFlightCount > 0 lets a client fire its next
2143
- // `prompt` into a session that still reports isStreaming === true. Flush
2144
- // happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
2145
- // an auto-compaction turn that starts before the original prompt unwinds)
2146
- // supersedes the pending one, which is what subscribers want — they only
2147
- // care about the final settle.
2148
- if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
2149
- void persistRuntimeState();
2535
+ // Hold agent_end until the prompt's finally and all earlier async event work
2536
+ // have unwound. Subscribers treat this event as the ready signal; flushing it
2537
+ // from abort while either barrier is active permits a successor to race the
2538
+ // prior prompt's cleanup.
2539
+ if (
2540
+ event.type === "agent_end" &&
2541
+ (this.#promptInFlightCount > 0 || this.#agentEventHandlersInFlight > 0 || this.#queuedExtensionEventCount > 0)
2542
+ ) {
2150
2543
  this.#pendingAgentEndEmit = event;
2151
2544
  return;
2152
2545
  }
2153
2546
 
2547
+ if (event.type === "agent_end") {
2548
+ // Start the durable terminal write before synchronous subscribers can
2549
+ // re-enter prompt(), so a successor's running transition serializes after it.
2550
+ void persistRuntimeState();
2551
+ this.#emit(event);
2552
+ await this.#emitExtensionEvent(event);
2553
+ return;
2554
+ }
2555
+
2154
2556
  // Local subscribers are part of the AgentSession control path: retryNow(),
2155
2557
  // auto-continuation gates, goal reminders, and tests all observe these events
2156
2558
  // synchronously. Coordinator sidecar writes and extension hooks are secondary
@@ -2165,6 +2567,18 @@ export class AgentSession {
2165
2567
 
2166
2568
  /** Internal handler for agent events - shared by subscribe and reconnect */
2167
2569
  #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
2570
+ // Record a successful final yield before any asynchronous extension work so a
2571
+ // concurrently delivered agent_end cannot start post-turn maintenance first.
2572
+ if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
2573
+ this.#lastSuccessfulYieldToolCallId = event.toolCallId;
2574
+ }
2575
+
2576
+ // Agent listeners run synchronously, but this handler yields while emitting
2577
+ // session events. Capture the maintenance run identity before that yield so
2578
+ // a later raw listener cannot revive a cancelled/replaced continuation.
2579
+ const maintenanceGeneration =
2580
+ event.type === "agent_end" && event.stopReason === "maintenance" ? this.#promptGeneration : undefined;
2581
+ const maintenanceWasDisposed = this.#isDisposed;
2168
2582
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
2169
2583
  // This ensures the UI sees the updated queue state
2170
2584
  if (event.type === "message_start" && event.message.role === "user") {
@@ -2229,9 +2643,36 @@ export class AgentSession {
2229
2643
  this.#silentAbortPending = false;
2230
2644
  }
2231
2645
 
2646
+ // Canonical persistence must happen synchronously before listener work can
2647
+ // await: the EventStream FIFO drain then guarantees tool results and every
2648
+ // steering message are in the branch before a maintenance rewrite starts.
2649
+ if (event.type === "message_end") {
2650
+ if (event.message.role === "hookMessage" || event.message.role === "custom") {
2651
+ const isRosterReminder = event.message.role === "custom" && event.message.customType === "irc-peer-roster";
2652
+ if (!isRosterReminder) {
2653
+ this.sessionManager.appendCustomMessageEntry(
2654
+ event.message.customType,
2655
+ event.message.content,
2656
+ event.message.display,
2657
+ event.message.details,
2658
+ event.message.attribution ?? "agent",
2659
+ getSessionMessageObservationId(event.message),
2660
+ );
2661
+ }
2662
+ } else if (
2663
+ event.message.role === "user" ||
2664
+ event.message.role === "developer" ||
2665
+ event.message.role === "assistant" ||
2666
+ event.message.role === "toolResult" ||
2667
+ event.message.role === "fileMention"
2668
+ ) {
2669
+ this.sessionManager.appendMessage(event.message);
2670
+ }
2671
+ }
2672
+
2232
2673
  // Deobfuscate assistant message content for display emission — the LLM echoes back
2233
2674
  // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
2234
- // values. The original event.message stays obfuscated so the persistence path below
2675
+ // values. The original event.message stays obfuscated so the canonical persistence path above
2235
2676
  // writes `#HASH#` tokens to the session file; convertToLlm re-obfuscates outbound
2236
2677
  // traffic on the next turn. Walks text, thinking, and toolCall arguments/intent.
2237
2678
  let displayEvent: AgentEvent = event;
@@ -2298,9 +2739,6 @@ export class AgentSession {
2298
2739
  await this.#activatePendingGjcGoalModeRequest();
2299
2740
  }
2300
2741
  }
2301
- if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
2302
- this.#lastSuccessfulYieldToolCallId = event.toolCallId;
2303
- }
2304
2742
  if (event.type === "turn_end" && this.#pendingRewindReport) {
2305
2743
  const report = this.#pendingRewindReport;
2306
2744
  this.#pendingRewindReport = undefined;
@@ -2431,38 +2869,11 @@ export class AgentSession {
2431
2869
  this.#maybeAbortStreamingEdit(event, this.#promptGeneration);
2432
2870
  }
2433
2871
 
2434
- // Handle session persistence
2872
+ // Handle post-persistence message side effects.
2435
2873
  if (event.type === "message_end") {
2436
- // Check if this is a hook/custom message
2437
- if (event.message.role === "hookMessage" || event.message.role === "custom") {
2438
- // Roster reminders are request-context-only: never persist them, or a
2439
- // reload would resurrect stale rosters into agent history.
2440
- const isRosterReminder = event.message.role === "custom" && event.message.customType === "irc-peer-roster";
2441
- if (!isRosterReminder) {
2442
- // Persist as CustomMessageEntry
2443
- this.sessionManager.appendCustomMessageEntry(
2444
- event.message.customType,
2445
- event.message.content,
2446
- event.message.display,
2447
- event.message.details,
2448
- event.message.attribution ?? "agent",
2449
- getSessionMessageObservationId(event.message),
2450
- );
2451
- }
2452
- if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
2453
- this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
2454
- }
2455
- } else if (
2456
- event.message.role === "user" ||
2457
- event.message.role === "developer" ||
2458
- event.message.role === "assistant" ||
2459
- event.message.role === "toolResult" ||
2460
- event.message.role === "fileMention"
2461
- ) {
2462
- // Regular LLM message - persist as SessionMessageEntry
2463
- this.sessionManager.appendMessage(event.message);
2874
+ if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
2875
+ this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
2464
2876
  }
2465
- // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
2466
2877
 
2467
2878
  // Track assistant message for auto-compaction (checked on agent_end)
2468
2879
  if (event.message.role === "assistant") {
@@ -2500,13 +2911,6 @@ export class AgentSession {
2500
2911
  assistantMsg.stopReason !== "aborted" &&
2501
2912
  this.#retryAttempt > 0
2502
2913
  ) {
2503
- if (this.#activeRetryFallback && this.model) {
2504
- await this.#emitSessionEvent({
2505
- type: "retry_fallback_succeeded",
2506
- model: formatRetryFallbackSelector(this.model, this.thinkingLevel),
2507
- role: this.#activeRetryFallback.role,
2508
- });
2509
- }
2510
2914
  await this.#emitSessionEvent({
2511
2915
  type: "auto_retry_end",
2512
2916
  success: true,
@@ -2580,6 +2984,29 @@ export class AgentSession {
2580
2984
 
2581
2985
  // Check auto-retry and auto-compaction after agent completes
2582
2986
  if (event.type === "agent_end") {
2987
+ // Cooperative mid-run maintenance interruption (issue #2035). The loop
2988
+ // ended the run losslessly after #runMidRunMaintenance did prune/
2989
+ // compact/promote; this handler is the SINGLE continuation owner. Resume
2990
+ // the same run on the rewritten context (no synthetic prompt), skipping
2991
+ // the goal-runtime / skill-state / retry / compaction finalization a
2992
+ // normal agent_end runs — none of that applies to an in-progress run.
2993
+ // "aborted" settles without resuming; the generation guard drops the
2994
+ // continuation if a newer prompt/abort has moved the run on.
2995
+ if (event.stopReason === "maintenance") {
2996
+ this.#lastAssistantMessage = undefined;
2997
+ const outcome = event.maintenanceOutcome;
2998
+ if (
2999
+ outcome &&
3000
+ outcome !== "aborted" &&
3001
+ !maintenanceWasDisposed &&
3002
+ !this.#isDisposed &&
3003
+ maintenanceGeneration !== undefined &&
3004
+ this.#promptGeneration === maintenanceGeneration
3005
+ ) {
3006
+ this.#scheduleAgentContinue({ generation: maintenanceGeneration, skipCompactionCheck: true });
3007
+ }
3008
+ return;
3009
+ }
2583
3010
  const usage = this.getSessionStats().tokens;
2584
3011
  await this.#goalRuntime.onAgentEnd({
2585
3012
  currentUsage: {
@@ -2606,6 +3033,7 @@ export class AgentSession {
2606
3033
  this.#lastAssistantMessage = undefined;
2607
3034
  if (!msg) {
2608
3035
  this.#lastSuccessfulYieldToolCallId = undefined;
3036
+ this.#resolveRetry();
2609
3037
  return;
2610
3038
  }
2611
3039
 
@@ -2736,6 +3164,7 @@ export class AgentSession {
2736
3164
  try {
2737
3165
  await scheduler.wait(delayMs, { signal });
2738
3166
  } catch {
3167
+ options?.onSkip?.();
2739
3168
  return;
2740
3169
  }
2741
3170
  }
@@ -2757,40 +3186,82 @@ export class AgentSession {
2757
3186
  delayMs?: number;
2758
3187
  generation?: number;
2759
3188
  skipCompactionCheck?: boolean;
3189
+ suppressPredecessorAgentEnd?: boolean;
2760
3190
  shouldContinue?: () => boolean;
2761
3191
  onSkip?: (reason: "generation_changed" | "aborted_signal" | "queue_drained") => void;
2762
3192
  onError?: (error: unknown) => void;
2763
3193
  }): Promise<void> {
3194
+ const predecessorAgentEndHold = options?.suppressPredecessorAgentEnd
3195
+ ? this.#reserveDeferredAgentEndForContinuation()
3196
+ : undefined;
3197
+ let terminalized = false;
3198
+ const skip = (reason: "generation_changed" | "aborted_signal" | "queue_drained") => {
3199
+ if (terminalized) return;
3200
+ terminalized = true;
3201
+ this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
3202
+ options?.onSkip?.(reason);
3203
+ };
3204
+ const fail = (error: unknown) => {
3205
+ if (terminalized) return;
3206
+ terminalized = true;
3207
+ this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
3208
+ logger.warn("agent.continue failed after scheduling", {
3209
+ error: error instanceof Error ? error.message : String(error),
3210
+ });
3211
+ options?.onError?.(error);
3212
+ };
2764
3213
  const scheduledGeneration = options?.generation;
2765
3214
  const signal = this.#postPromptTasksAbortController.signal;
2766
3215
  return this.#schedulePostPromptTask(
2767
3216
  async () => {
2768
- if (signal.aborted) {
2769
- options?.onSkip?.("aborted_signal");
2770
- return;
2771
- }
2772
- if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
2773
- options?.onSkip?.("generation_changed");
2774
- return;
2775
- }
2776
- if (options?.shouldContinue && !options.shouldContinue()) {
2777
- options.onSkip?.("queue_drained");
2778
- return;
2779
- }
3217
+ const canContinue = (): boolean => {
3218
+ if (signal.aborted || this.#isDisposed) {
3219
+ skip("aborted_signal");
3220
+ return false;
3221
+ }
3222
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
3223
+ skip("generation_changed");
3224
+ return false;
3225
+ }
3226
+ if (options?.shouldContinue && !options.shouldContinue()) {
3227
+ skip("queue_drained");
3228
+ return false;
3229
+ }
3230
+ return true;
3231
+ };
3232
+ if (!canContinue()) return;
2780
3233
  try {
2781
- await this.#maybeRestoreRetryFallbackPrimary();
2782
3234
  if (!options?.skipCompactionCheck) {
2783
3235
  await this.#checkEstimatedContextBeforePrompt();
3236
+ if (!canContinue()) return;
3237
+ }
3238
+ if (signal.aborted) {
3239
+ skip("aborted_signal");
3240
+ return;
3241
+ }
3242
+ if (scheduledGeneration !== undefined && this.#promptGeneration !== scheduledGeneration) {
3243
+ skip("generation_changed");
3244
+ return;
3245
+ }
3246
+ if (options?.shouldContinue && !options.shouldContinue()) {
3247
+ skip("queue_drained");
3248
+ return;
3249
+ }
3250
+ const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(predecessorAgentEndHold);
3251
+ try {
3252
+ await this.agent.continue(this.#managedFallbackPromptOptions());
3253
+ } catch (error) {
3254
+ this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
3255
+ throw error;
2784
3256
  }
2785
- await this.agent.continue();
2786
3257
  } catch (error) {
2787
- logger.warn("agent.continue failed after scheduling", {
2788
- error: error instanceof Error ? error.message : String(error),
2789
- });
2790
- options?.onError?.(error);
3258
+ fail(error);
2791
3259
  }
2792
3260
  },
2793
- { delayMs: options?.delayMs },
3261
+ {
3262
+ delayMs: options?.delayMs,
3263
+ onSkip: () => skip("aborted_signal"),
3264
+ },
2794
3265
  );
2795
3266
  }
2796
3267
 
@@ -2838,6 +3309,8 @@ export class AgentSession {
2838
3309
  this.#scheduleAgentContinue({
2839
3310
  delayMs: 100,
2840
3311
  generation,
3312
+ suppressPredecessorAgentEnd: true,
3313
+
2841
3314
  onSkip: reason => this.#logCompactionContinuationSkipped("overflow_retry", reason),
2842
3315
  onError: error => this.#logCompactionContinuationError("overflow_retry", error),
2843
3316
  });
@@ -2854,6 +3327,7 @@ export class AgentSession {
2854
3327
  }
2855
3328
 
2856
3329
  #scheduleAutoContinuePrompt(generation: number): void {
3330
+ const predecessorAgentEndHold = this.#reserveDeferredAgentEndForContinuation();
2857
3331
  const continuePrompt = async () => {
2858
3332
  await this.#promptWithMessage(
2859
3333
  {
@@ -2863,26 +3337,32 @@ export class AgentSession {
2863
3337
  timestamp: Date.now(),
2864
3338
  },
2865
3339
  autoContinuePrompt,
2866
- { skipPostPromptRecoveryWait: true, skipCompactionCheck: true },
3340
+ {
3341
+ skipPostPromptRecoveryWait: true,
3342
+ skipCompactionCheck: true,
3343
+ predecessorAgentEndHold,
3344
+ },
2867
3345
  );
2868
3346
  };
2869
3347
  const scheduledGeneration = generation;
2870
3348
  const signal = this.#postPromptTasksAbortController.signal;
2871
3349
  this.#trackPostPromptTask(
2872
3350
  (async () => {
2873
- await Promise.resolve();
2874
- if (signal.aborted) {
2875
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
2876
- return;
2877
- }
2878
- if (this.#promptGeneration !== scheduledGeneration) {
2879
- this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
2880
- return;
2881
- }
2882
3351
  try {
3352
+ await Promise.resolve();
3353
+ if (signal.aborted) {
3354
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "aborted_signal");
3355
+ return;
3356
+ }
3357
+ if (this.#promptGeneration !== scheduledGeneration) {
3358
+ this.#logCompactionContinuationSkipped("auto_continue_prompt", "generation_changed");
3359
+ return;
3360
+ }
2883
3361
  await continuePrompt();
2884
3362
  } catch (error) {
2885
3363
  this.#logCompactionContinuationError("auto_continue_prompt", error);
3364
+ } finally {
3365
+ this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
2886
3366
  }
2887
3367
  })(),
2888
3368
  );
@@ -2895,11 +3375,13 @@ export class AgentSession {
2895
3375
 
2896
3376
  const pendingTasks = Array.from(this.#postPromptTasks);
2897
3377
  if (pendingTasks.length === 0) {
3378
+ this.#releaseDeferredAgentEndContinuations();
2898
3379
  this.#resolvePostPromptTasks();
2899
3380
  return;
2900
3381
  }
2901
3382
 
2902
3383
  await Promise.allSettled(pendingTasks);
3384
+ this.#releaseDeferredAgentEndContinuations();
2903
3385
  if (this.#postPromptTasks.size === 0) {
2904
3386
  this.#resolvePostPromptTasks();
2905
3387
  }
@@ -2909,6 +3391,7 @@ export class AgentSession {
2909
3391
  this.#postPromptTasksAbortController.abort();
2910
3392
  this.#postPromptTasksAbortController = new AbortController();
2911
3393
  this.#postPromptTasks.clear();
3394
+ this.#releaseDeferredAgentEndContinuations();
2912
3395
  this.#resolveTtsrResume();
2913
3396
  this.#resolvePostPromptTasks();
2914
3397
  }
@@ -3733,6 +4216,7 @@ export class AgentSession {
3733
4216
  subscribe(listener: AgentSessionEventListener): () => void {
3734
4217
  this.#eventListeners.push(listener);
3735
4218
  this.#rebuildEventListenerSnapshot();
4219
+ for (const event of this.#pendingFallbackSwitches.splice(0)) listener(event);
3736
4220
 
3737
4221
  // Return unsubscribe function for this specific listener
3738
4222
  return () => {
@@ -3750,6 +4234,7 @@ export class AgentSession {
3750
4234
  * Used internally during operations that need to pause event processing.
3751
4235
  */
3752
4236
  #disconnectFromAgent(): void {
4237
+ this.#abortActiveMidRunBarriers();
3753
4238
  if (this.#unsubscribeAgent) {
3754
4239
  this.#unsubscribeAgent();
3755
4240
  this.#unsubscribeAgent = undefined;
@@ -3762,7 +4247,7 @@ export class AgentSession {
3762
4247
  */
3763
4248
  #reconnectToAgent(): void {
3764
4249
  if (this.#unsubscribeAgent) return; // Already connected
3765
- this.#unsubscribeAgent = this.agent.subscribe(this.#handleAgentEvent);
4250
+ this.#unsubscribeAgent = this.agent.subscribe(this.#trackAgentEvent);
3766
4251
  }
3767
4252
 
3768
4253
  /**
@@ -3803,12 +4288,51 @@ export class AgentSession {
3803
4288
  * Remove all listeners, flush pending writes, and disconnect from agent.
3804
4289
  * Call this when completely done with the session.
3805
4290
  */
3806
- async dispose(): Promise<void> {
4291
+ dispose(): Promise<void> {
4292
+ this.#evalExecutionDisposing = true;
4293
+ if (this.#disposePromise) return this.#disposePromise;
4294
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
4295
+ this.#disposePromise = promise;
4296
+ void this.#dispose().then(resolve, reject);
4297
+ return promise;
4298
+ }
4299
+
4300
+ async #dispose(): Promise<void> {
4301
+ const admissionClosed = this.#closeSessionAdmission();
3807
4302
  this.#isDisposed = true;
4303
+ // Reject new direct Python starts as soon as disposal begins (synchronously,
4304
+ // before any await) so callers cannot race a start against teardown.
4305
+ this.#evalExecutionDisposing = true;
4306
+ this.#abortActiveMidRunBarriers();
4307
+ this.abortCompaction();
4308
+ this.agent.abort();
4309
+ // Disconnect the Agent event bridge NOW — before the maintenance join and the
4310
+ // bounded idle / forceAbort below — so no agent_end emitted during teardown
4311
+ // (including the one forceAbort emits) can re-enter #handleAgentEvent and start
4312
+ // fresh post-turn maintenance or mutate the closing session. Maintenance promises
4313
+ // are joined directly (not via events), so this does not affect the join.
4314
+ this.#disconnectFromAgent();
4315
+ // R2-5: join any in-flight mid-run maintenance invocation before teardown so the
4316
+ // abort-aware maintenance promise (already aborted above) settles and cannot touch
4317
+ // torn-down state afterward.
4318
+ await this.#waitForActiveMidRunMaintenance();
4319
+ // R2-5: give the aborted Agent run a bounded chance to settle, then force-
4320
+ // invalidate its run id so an abort-ignoring provider/tool cannot emit a late
4321
+ // message_end after teardown. Mirrors AgentSession.abort({ timeoutMs }).
4322
+ const disposeIdleSettled = await Promise.race([
4323
+ this.agent.waitForIdle().then(
4324
+ () => true,
4325
+ () => true,
4326
+ ),
4327
+ Bun.sleep(2_000).then(() => false),
4328
+ ]);
4329
+ if (!disposeIdleSettled) this.agent.forceAbort("Session disposed");
4330
+ await admissionClosed;
4331
+ this.#workflowGateEmitter?.fence?.();
3808
4332
  this.#pendingBackgroundExchanges = [];
3809
4333
  this.yieldQueue.clear();
4334
+
3810
4335
  this.agent.setOnBeforeYield(undefined);
3811
- this.#evalExecutionDisposing = true;
3812
4336
  try {
3813
4337
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
3814
4338
  await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -3868,12 +4392,14 @@ export class AgentSession {
3868
4392
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
3869
4393
  await disposeVmContextsByOwner(this.#evalKernelOwnerId);
3870
4394
  this.#releasePowerAssertion();
4395
+ // Disconnect the agent event listener BEFORE closing session resources so a late
4396
+ // provider/tool message_end cannot append to the closing SessionManager.
4397
+ this.#disconnectFromAgent();
3871
4398
  await this.sessionManager.close();
3872
4399
  this.#closeAllProviderSessions("dispose");
3873
4400
  const hindsightState = this.setHindsightSessionState(undefined);
3874
4401
  await hindsightState?.flushRetainQueue();
3875
4402
  hindsightState?.dispose();
3876
- this.#disconnectFromAgent();
3877
4403
  if (this.#unsubscribeAppendOnly) {
3878
4404
  this.#unsubscribeAppendOnly();
3879
4405
  this.#unsubscribeAppendOnly = undefined;
@@ -3926,20 +4452,27 @@ export class AgentSession {
3926
4452
  await Promise.race([work, Bun.sleep(timeoutMs)]);
3927
4453
  }
3928
4454
 
3929
- #closeAllProviderSessions(reason: string): void {
3930
- for (const [providerKey, state] of this.#providerSessionState) {
4455
+ #rebindProviderSessionState(providerSessionState: Map<string, ProviderSessionState>): void {
4456
+ this.#providerSessionState = providerSessionState;
4457
+ this.agent.providerSessionState = providerSessionState;
4458
+ }
4459
+
4460
+ #closeProviderSessionMap(providerSessionState: Map<string, ProviderSessionState>, reason: string): void {
4461
+ for (const [providerKey, state] of providerSessionState) {
3931
4462
  try {
3932
4463
  state.close();
3933
4464
  } catch (error) {
3934
- logger.warn("Failed to close provider session state", {
3935
- providerKey,
3936
- reason,
3937
- error: String(error),
3938
- });
4465
+ logger.warn("Failed to close provider session state", { providerKey, reason, error: String(error) });
3939
4466
  }
3940
4467
  }
4468
+ providerSessionState.clear();
4469
+ }
3941
4470
 
3942
- this.#providerSessionState.clear();
4471
+ #closeAllProviderSessions(reason: string): void {
4472
+ const maps = new Set<Map<string, ProviderSessionState>>([this.#providerSessionState]);
4473
+ for (const scope of this.#temporaryProviderSessionScopes) maps.add(scope.providerSessionState);
4474
+ this.#temporaryProviderSessionScopes = [];
4475
+ for (const providerSessionState of maps) this.#closeProviderSessionMap(providerSessionState, reason);
3943
4476
  }
3944
4477
 
3945
4478
  // =========================================================================
@@ -4031,8 +4564,8 @@ export class AgentSession {
4031
4564
  }
4032
4565
 
4033
4566
  /** Single point for invalidating cached discovery indices. Call after any change that can
4034
- * affect which tools should be discoverable: registry mutations (refreshMCPTools,
4035
- * refreshRpcHostTools) or active-tool mutations (#applyActiveToolsByName). */
4567
+ * affect which tools should be discoverable: registry mutations (refreshMCPTools)
4568
+ * or active-tool mutations (#applyActiveToolsByName). */
4036
4569
  #invalidateDiscoveryCaches(): void {
4037
4570
  this.#discoverableMCPSearchIndex = null;
4038
4571
  this.#discoverableToolSearchIndex = null;
@@ -4136,6 +4669,35 @@ export class AgentSession {
4136
4669
  return this.#foregroundBashBackgroundRequestHandler !== undefined;
4137
4670
  }
4138
4671
 
4672
+ /** Set the SDK permission policy used by guarded ACP tool execution. */
4673
+ setSdkPermissionMode(mode: "prompt" | "allow" | "deny"): void {
4674
+ this.#sdkPermissionMode = mode;
4675
+ }
4676
+
4677
+ /** Current SDK permission policy for guarded ACP tool execution. */
4678
+ get sdkPermissionMode(): "prompt" | "allow" | "deny" {
4679
+ return this.#sdkPermissionMode;
4680
+ }
4681
+
4682
+ /** Register or clear the SDK reverse permission provider for this session. */
4683
+ setSdkPermissionProvider(
4684
+ provider:
4685
+ | ((
4686
+ toolCall: ClientBridgePermissionToolCall,
4687
+ options: ClientBridgePermissionOption[],
4688
+ signal?: AbortSignal,
4689
+ ) => Promise<ClientBridgePermissionOutcome>)
4690
+ | undefined,
4691
+ ): void {
4692
+ this.#sdkPermissionProvider = provider;
4693
+ this.#acpPermissionDecisions.clear();
4694
+ this.#acpPermissionWrapperVersion++;
4695
+ const activeTools = this.getActiveToolNames()
4696
+ .map(name => this.#toolRegistry.get(name))
4697
+ .filter((tool): tool is AgentTool => tool !== undefined);
4698
+ this.#setGuardedAgentTools(activeTools);
4699
+ }
4700
+
4139
4701
  /**
4140
4702
  * Ask the active managed foreground bash call to return as a background job.
4141
4703
  * Returns false when no supported foreground tool is currently backgroundable.
@@ -4165,6 +4727,55 @@ export class AgentSession {
4165
4727
  return resolveEditMode(this.#getEditModeSession());
4166
4728
  }
4167
4729
 
4730
+ #resolveEditModeForModel(model: Model): EditMode {
4731
+ return resolveEditMode({
4732
+ settings: this.settings,
4733
+ getActiveModelString: () => formatModelString(model),
4734
+ });
4735
+ }
4736
+
4737
+ async #prepareDefaultModelSelectionPrompt(model: Model): Promise<string[] | undefined> {
4738
+ if (!this.#rebuildSystemPrompt) return undefined;
4739
+ if (!this.getActiveToolNames().includes("edit")) return undefined;
4740
+ if (this.#resolveActiveEditMode() === this.#resolveEditModeForModel(model)) return undefined;
4741
+ const built = await this.#rebuildSystemPrompt(this.getActiveToolNames(), this.#toolRegistry, model);
4742
+ return built.systemPrompt;
4743
+ }
4744
+
4745
+ #reserveBaseSystemPromptGeneration(): number {
4746
+ this.#baseSystemPromptGeneration++;
4747
+ return this.#baseSystemPromptGeneration;
4748
+ }
4749
+
4750
+ async #runAdmittedBaseSystemPromptRebuild<T>(build: () => Promise<T>): Promise<T> {
4751
+ const completion = Promise.withResolvers<void>();
4752
+ this.#pendingBaseSystemPromptRebuilds.add(completion.promise);
4753
+ try {
4754
+ return await build();
4755
+ } finally {
4756
+ completion.resolve();
4757
+ this.#pendingBaseSystemPromptRebuilds.delete(completion.promise);
4758
+ }
4759
+ }
4760
+
4761
+ async #waitForAdmittedBaseSystemPromptRebuilds(): Promise<void> {
4762
+ while (this.#pendingBaseSystemPromptRebuilds.size > 0) {
4763
+ await Promise.all(this.#pendingBaseSystemPromptRebuilds);
4764
+ }
4765
+ }
4766
+
4767
+ #applyPreparedDefaultModelSelectionPrompt(systemPrompt: string[] | undefined): void {
4768
+ if (!systemPrompt) return;
4769
+ this.#reserveBaseSystemPromptGeneration();
4770
+ this.#baseSystemPrompt = systemPrompt;
4771
+ this.agent.setSystemPrompt(this.#baseSystemPrompt);
4772
+ const activeToolNames = this.getActiveToolNames();
4773
+ const activeTools = activeToolNames
4774
+ .map(name => this.#toolRegistry.get(name))
4775
+ .filter((tool): tool is AgentTool => tool != null);
4776
+ this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
4777
+ }
4778
+
4168
4779
  async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
4169
4780
  const currentEditMode = this.#resolveActiveEditMode();
4170
4781
  if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
@@ -4335,16 +4946,20 @@ export class AgentSession {
4335
4946
  return [...new Set(activated)];
4336
4947
  }
4337
4948
 
4338
- /**
4339
- * Wrap a tool with a permission-gate proxy when an ACP client is connected.
4340
- * Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
4341
- * the bridge exposes `requestPermission`. No-ops for all other cases.
4342
- */
4949
+ /** Wrap guarded tools so SDK permission modes remain fail-closed without a reverse provider. */
4343
4950
  #wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
4344
4951
  const bridge = this.#clientBridge;
4345
- // Match the capability+method gating pattern used by read/write/bash.
4346
- if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
4952
+ const requestPermission =
4953
+ this.#sdkPermissionProvider ??
4954
+ (bridge?.capabilities.requestPermission && bridge.requestPermission
4955
+ ? (
4956
+ toolCall: ClientBridgePermissionToolCall,
4957
+ options: ClientBridgePermissionOption[],
4958
+ signal?: AbortSignal,
4959
+ ) => bridge.requestPermission!(toolCall, options, signal)
4960
+ : undefined);
4347
4961
  if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
4962
+
4348
4963
  return new Proxy(tool, {
4349
4964
  get: (target, prop) => {
4350
4965
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -4367,6 +4982,17 @@ export class AgentSession {
4367
4982
  const commandContent = command
4368
4983
  ? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
4369
4984
  : undefined;
4985
+ if (this.#sdkPermissionMode === "allow") {
4986
+ return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
4987
+ }
4988
+ if (this.#sdkPermissionMode === "deny") {
4989
+ throw new ToolError(`Tool call rejected by session permission policy (${target.name})`);
4990
+ }
4991
+ if (!requestPermission) {
4992
+ throw new ToolError(
4993
+ `Tool call rejected because no permission provider is connected (${target.name})`,
4994
+ );
4995
+ }
4370
4996
  // Short-circuit on persisted decisions.
4371
4997
  const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
4372
4998
  if (persisted === "allow_always") {
@@ -4386,7 +5012,7 @@ export class AgentSession {
4386
5012
  signal?.addEventListener("abort", onAbort, { once: true });
4387
5013
  let raced: PermissionRaceResult;
4388
5014
  try {
4389
- const permissionPromise = bridge.requestPermission!(
5015
+ const permissionPromise = requestPermission(
4390
5016
  {
4391
5017
  toolCallId,
4392
5018
  toolName: target.name,
@@ -4465,13 +5091,14 @@ export class AgentSession {
4465
5091
  #guardedToolWrapperCacheKey(): string {
4466
5092
  const bridge = this.#clientBridge;
4467
5093
  const acpEnabled = Boolean(bridge?.capabilities.requestPermission && bridge.requestPermission);
5094
+ const sdkEnabled = this.#sdkPermissionProvider !== undefined;
4468
5095
  const activeSkill = this.#activeSkillState?.skill ?? "";
4469
5096
  const activeSkillSession = this.#activeSkillState?.sessionId ?? "";
4470
5097
  return [
4471
5098
  "deep-interview-mutation-v1",
4472
5099
  "ultragoal-ask-v1",
4473
5100
  `active=${activeSkill}:${activeSkillSession}`,
4474
- `acp=${acpEnabled ? "on" : "off"}:${this.#acpPermissionWrapperVersion}`,
5101
+ `acp=${acpEnabled ? "on" : "off"}:sdk=${sdkEnabled ? "on" : "off"}:${this.#acpPermissionWrapperVersion}`,
4475
5102
  ].join("|");
4476
5103
  }
4477
5104
 
@@ -4532,6 +5159,13 @@ export class AgentSession {
4532
5159
  this.#selectedDiscoveredToolNames.delete(name);
4533
5160
  }
4534
5161
  }
5162
+ const signature = this.#computeAppliedToolSignature(validToolNames, tools);
5163
+ const promptRelevantToolsChanged =
5164
+ signature !== (this.#pendingAppliedToolSignature ?? this.#lastAppliedToolSignature);
5165
+ if (promptRelevantToolsChanged) {
5166
+ this.#defaultModelSelectionMutationRevision++;
5167
+ this.#pendingAppliedToolSignature = signature;
5168
+ }
4535
5169
  this.#setGuardedAgentTools(tools);
4536
5170
 
4537
5171
  // Active tool set changed → discoverable tool list (which excludes already-active tools)
@@ -4543,14 +5177,27 @@ export class AgentSession {
4543
5177
  // `refreshMCPTools` -> `#applyActiveToolsByName` even though the resulting
4544
5178
  // tool list is byte-identical. Skipping the rebuild keeps the system prompt
4545
5179
  // stable, which is required for Anthropic prompt caching to keep hitting.
4546
- if (this.#rebuildSystemPrompt) {
4547
- const signature = this.#computeAppliedToolSignature(validToolNames, tools);
4548
- if (signature !== this.#lastAppliedToolSignature) {
4549
- const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry);
4550
- this.#baseSystemPrompt = built.systemPrompt;
4551
- this.agent.setSystemPrompt(this.#baseSystemPrompt);
4552
- this.#lastAppliedToolSignature = signature;
5180
+ if (promptRelevantToolsChanged && this.#rebuildSystemPrompt) {
5181
+ const generation = this.#reserveBaseSystemPromptGeneration();
5182
+ try {
5183
+ const built = await this.#runAdmittedBaseSystemPromptRebuild(() =>
5184
+ this.#rebuildSystemPrompt!(validToolNames, this.#toolRegistry),
5185
+ );
5186
+ if (generation === this.#baseSystemPromptGeneration) {
5187
+ this.#baseSystemPrompt = built.systemPrompt;
5188
+ this.agent.setSystemPrompt(this.#baseSystemPrompt);
5189
+ this.#lastAppliedToolSignature = signature;
5190
+ this.#pendingAppliedToolSignature = undefined;
5191
+ }
5192
+ } catch (error) {
5193
+ if (generation === this.#baseSystemPromptGeneration) {
5194
+ this.#pendingAppliedToolSignature = undefined;
5195
+ }
5196
+ throw error;
4553
5197
  }
5198
+ } else if (promptRelevantToolsChanged) {
5199
+ this.#lastAppliedToolSignature = signature;
5200
+ this.#pendingAppliedToolSignature = undefined;
4554
5201
  }
4555
5202
  if (options?.persistMCPSelection !== false) {
4556
5203
  this.#persistSelectedMCPToolNamesIfChanged(previousSelectedMCPToolNames);
@@ -4629,7 +5276,20 @@ export class AgentSession {
4629
5276
  async refreshBaseSystemPrompt(): Promise<void> {
4630
5277
  if (!this.#rebuildSystemPrompt) return;
4631
5278
  const activeToolNames = this.getActiveToolNames();
4632
- const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry);
5279
+ const generation = this.#reserveBaseSystemPromptGeneration();
5280
+ this.#defaultModelSelectionMutationRevision++;
5281
+ let built: { systemPrompt: string[] };
5282
+ try {
5283
+ built = await this.#runAdmittedBaseSystemPromptRebuild(() =>
5284
+ this.#rebuildSystemPrompt!(activeToolNames, this.#toolRegistry),
5285
+ );
5286
+ } catch (error) {
5287
+ if (generation === this.#baseSystemPromptGeneration) {
5288
+ this.#pendingAppliedToolSignature = undefined;
5289
+ }
5290
+ throw error;
5291
+ }
5292
+ if (generation !== this.#baseSystemPromptGeneration) return;
4633
5293
  this.#baseSystemPrompt = built.systemPrompt;
4634
5294
  this.agent.setSystemPrompt(this.#baseSystemPrompt);
4635
5295
  // Refresh the cached signature so a subsequent `#applyActiveToolsByName` with
@@ -4639,6 +5299,7 @@ export class AgentSession {
4639
5299
  .map(name => this.#toolRegistry.get(name))
4640
5300
  .filter((tool): tool is AgentTool => tool != null);
4641
5301
  this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
5302
+ this.#pendingAppliedToolSignature = undefined;
4642
5303
  }
4643
5304
 
4644
5305
  async #buildSystemPromptForAgentStart(promptText: string): Promise<string[]> {
@@ -4686,7 +5347,7 @@ export class AgentSession {
4686
5347
  * cache per-tool strings without preserving this property.
4687
5348
  *
4688
5349
  * Inputs NOT covered: tool input schemas; memory instructions read from disk;
4689
- * and SDK-init-time closure constants in `sdk.ts` (`repeatToolDescriptions`,
5350
+ * and SDK-init-time closure constants in `sdk/session.ts` (`repeatToolDescriptions`,
4690
5351
  * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The
4691
5352
  * closure-captured ones cannot change at runtime regardless of skip behavior.
4692
5353
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
@@ -4784,57 +5445,6 @@ export class AgentSession {
4784
5445
  await this.#applyActiveToolsByName(nextActive, { previousSelectedMCPToolNames });
4785
5446
  }
4786
5447
 
4787
- /**
4788
- * Replace RPC host-owned tools and refresh the active tool set before the next model call.
4789
- */
4790
- async refreshRpcHostTools(rpcTools: AgentTool[]): Promise<void> {
4791
- const nextToolNames = rpcTools.map(tool => tool.name);
4792
- const uniqueToolNames = new Set(nextToolNames);
4793
- if (uniqueToolNames.size !== nextToolNames.length) {
4794
- throw new Error("RPC host tool names must be unique");
4795
- }
4796
- if (uniqueToolNames.has("ask")) {
4797
- throw new Error('RPC host tool "ask" is reserved and cannot be supplied by the host');
4798
- }
4799
-
4800
- for (const name of uniqueToolNames) {
4801
- if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) {
4802
- throw new Error(`RPC host tool "${name}" conflicts with an existing tool`);
4803
- }
4804
- }
4805
-
4806
- const previousRpcHostToolNames = new Set(this.#rpcHostToolNames);
4807
- const previousActiveToolNames = this.getActiveToolNames();
4808
- for (const name of previousRpcHostToolNames) {
4809
- this.#toolRegistry.delete(name);
4810
- }
4811
- this.#rpcHostToolNames.clear();
4812
-
4813
- for (const tool of rpcTools) {
4814
- const finalTool = (
4815
- this.#extensionRunner ? new ExtensionToolWrapper(tool, this.#extensionRunner) : tool
4816
- ) as AgentTool;
4817
- this.#toolRegistry.set(finalTool.name, finalTool);
4818
- this.#rpcHostToolNames.add(finalTool.name);
4819
- }
4820
-
4821
- // Registry contents changed — invalidate discovery caches so the next BM25 lookup sees
4822
- // the new RPC-host tool set. (#applyActiveToolsByName below also invalidates, but doing
4823
- // it here too keeps the contract local to "registry mutated".)
4824
- this.#invalidateDiscoveryCaches();
4825
-
4826
- const activeNonRpcToolNames = previousActiveToolNames.filter(name => !previousRpcHostToolNames.has(name));
4827
- const preservedRpcToolNames = previousActiveToolNames.filter(
4828
- name => previousRpcHostToolNames.has(name) && this.#rpcHostToolNames.has(name),
4829
- );
4830
- const autoActivatedRpcToolNames = rpcTools
4831
- .filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name))
4832
- .map(tool => tool.name);
4833
- await this.#applyActiveToolsByName(
4834
- Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])),
4835
- );
4836
- }
4837
-
4838
5448
  async #hasActiveGjcSubskillTools(parent: string, sessionId: string | undefined): Promise<boolean> {
4839
5449
  if (!parent.trim()) return false;
4840
5450
  const cwd = this.sessionManager.getCwd();
@@ -4979,7 +5589,13 @@ export class AgentSession {
4979
5589
  if (!canContinuePersistedHistory(this.agent.state.messages)) {
4980
5590
  throw new Error("Cannot continue from persisted message history");
4981
5591
  }
4982
- await this.agent.continue();
5592
+ this.#beginInFlight();
5593
+ try {
5594
+ await this.agent.continue(this.#managedFallbackPromptOptions());
5595
+ await this.#waitForPostPromptRecovery();
5596
+ } finally {
5597
+ this.#endInFlight();
5598
+ }
4983
5599
  }
4984
5600
 
4985
5601
  buildDisplaySessionContext(): SessionContext {
@@ -5090,6 +5706,19 @@ export class AgentSession {
5090
5706
  return this.#planModeState;
5091
5707
  }
5092
5708
 
5709
+ /** Live SDK configuration values exposed through the session query surface. */
5710
+ getSdkConfigItems(): Record<string, string> {
5711
+ const model = this.model;
5712
+ return {
5713
+ mode: this.#planModeState?.enabled ? "plan" : "default",
5714
+ ...(model ? { model: `${model.provider}/${model.id}` } : {}),
5715
+ thinking: this.#thinkingLevel ?? "off",
5716
+ steeringMode: this.steeringMode,
5717
+ followUpMode: this.followUpMode,
5718
+ interruptMode: this.interruptMode,
5719
+ };
5720
+ }
5721
+
5093
5722
  setPlanModeState(state: PlanModeState | undefined): void {
5094
5723
  this.#planModeState = state;
5095
5724
  if (state?.enabled) {
@@ -5098,6 +5727,114 @@ export class AgentSession {
5098
5727
  }
5099
5728
  }
5100
5729
 
5730
+ async invokeSkill(
5731
+ name: string,
5732
+ args = "",
5733
+ ): Promise<{ name: string; path: string; args?: string; lineCount?: number }> {
5734
+ const skillName = name.trim();
5735
+ if (!skillName) throw Object.assign(new Error("skill.invoke requires a skill name."), { code: "invalid_input" });
5736
+ if (typeof args !== "string")
5737
+ throw Object.assign(new Error("skill.invoke args must be a string."), { code: "invalid_input" });
5738
+ const skill = this.skills.find(candidate => candidate.name === skillName);
5739
+ if (!skill) throw Object.assign(new Error(`Skill ${skillName} was not found.`), { code: "invalid_input" });
5740
+ const activation = await resolveSubskillActivationForSkillInvocation({
5741
+ cwd: this.sessionManager.getCwd(),
5742
+ sessionId: this.sessionId,
5743
+ skillName: skill.name,
5744
+ args,
5745
+ });
5746
+ const built = await buildSkillPromptMessage(skill, activation.cleanedArgs, {
5747
+ subskillActivation: activation.activation,
5748
+ subskillActivationSet: activation.activeSubskillsToPersist,
5749
+ cwd: this.sessionManager.getCwd(),
5750
+ sessionId: this.sessionId,
5751
+ });
5752
+ await this.promptCustomMessage({
5753
+ customType: SKILL_PROMPT_MESSAGE_TYPE,
5754
+ content: built.message,
5755
+ display: true,
5756
+ details: built.details,
5757
+ attribution: "user",
5758
+ });
5759
+ return {
5760
+ name: skill.name,
5761
+ path: skill.filePath,
5762
+ args: activation.cleanedArgs || undefined,
5763
+ lineCount: built.details.lineCount,
5764
+ };
5765
+ }
5766
+
5767
+ setSdkPlanMode(on: boolean): PlanModeState | undefined {
5768
+ if (typeof on !== "boolean")
5769
+ throw Object.assign(new Error("mode.plan.set requires a boolean on value."), { code: "invalid_input" });
5770
+ if (!on) {
5771
+ this.setPlanModeState(undefined);
5772
+ return undefined;
5773
+ }
5774
+ const state: PlanModeState = { enabled: true, planFilePath: "local://PLAN.md", workflow: "parallel" };
5775
+ this.setPlanModeState(state);
5776
+ return state;
5777
+ }
5778
+
5779
+ async operateGoal(
5780
+ op: "create" | "get" | "resume" | "pause" | "complete" | "drop",
5781
+ objective?: string,
5782
+ ): Promise<unknown> {
5783
+ try {
5784
+ switch (op) {
5785
+ case "create":
5786
+ return await this.#goalRuntime.createGoal({ objective: objective ?? "" });
5787
+ case "get":
5788
+ return this.getGoalModeState();
5789
+ case "resume":
5790
+ return await this.#goalRuntime.resumeGoal();
5791
+ case "pause":
5792
+ return await this.#goalRuntime.pauseGoal();
5793
+ case "complete":
5794
+ return await this.#goalRuntime.completeGoalFromTool();
5795
+ case "drop":
5796
+ return await this.#goalRuntime.dropGoal();
5797
+ }
5798
+ } catch (error) {
5799
+ throw Object.assign(new Error(error instanceof Error ? error.message : "Goal operation failed."), {
5800
+ code: "invalid_input",
5801
+ });
5802
+ }
5803
+ }
5804
+
5805
+ getTranscript(): Array<{ id: string; role: string; textSummary: string; ts: string; body: string }> {
5806
+ return this.sessionManager.getEntries().flatMap(entry => {
5807
+ if (entry.type !== "message") return [];
5808
+ const message = entry.message as unknown as { role?: unknown; content?: unknown };
5809
+ const body =
5810
+ typeof message.content === "string"
5811
+ ? message.content
5812
+ : Array.isArray(message.content)
5813
+ ? message.content
5814
+ .map(part =>
5815
+ typeof part === "object" && part !== null && "text" in part && typeof part.text === "string"
5816
+ ? part.text
5817
+ : "",
5818
+ )
5819
+ .filter(Boolean)
5820
+ .join("\n")
5821
+ : "";
5822
+ return [
5823
+ {
5824
+ id: entry.id,
5825
+ role: typeof message.role === "string" ? message.role : "unknown",
5826
+ textSummary: body.slice(0, 500),
5827
+ ts: entry.timestamp,
5828
+ body,
5829
+ },
5830
+ ];
5831
+ });
5832
+ }
5833
+
5834
+ getTranscriptBody(entryId: string): string | undefined {
5835
+ return this.getTranscript().find(entry => entry.id === entryId)?.body;
5836
+ }
5837
+
5101
5838
  getGoalModeState(): GoalModeState | undefined {
5102
5839
  return this.#goalModeState;
5103
5840
  }
@@ -5114,6 +5851,36 @@ export class AgentSession {
5114
5851
  return getAskAnswerSourceFromRegistry(this.sessionId);
5115
5852
  }
5116
5853
 
5854
+ #bindWorkflowGateEmitter(previousSessionId?: string, previousEmitter = this.#workflowGateEmitter): void {
5855
+ const sessionId = this.sessionManager.getSessionId();
5856
+ assertNonEmptyGjcSessionId(sessionId, "AgentSession workflow-gate session");
5857
+ const gateStore = this.sessionManager.isPersisted()
5858
+ ? new FileGateStore(path.join(sessionStateDir(this.sessionManager.getCwd(), sessionId), "workflow-gates.json"))
5859
+ : new MemoryGateStore();
5860
+ const successorEmitter = new BrokerWorkflowGateEmitter(sessionId, gateStore);
5861
+ previousEmitter?.fence?.();
5862
+ if (previousEmitter && !previousEmitter.fence) {
5863
+ for (const gate of previousEmitter.listPendingGates?.() ?? []) previousEmitter.quarantineGate?.(gate.gate_id);
5864
+ }
5865
+ if (previousSessionId) notifyWorkflowGateEmitterChanged(previousSessionId, undefined);
5866
+ this.setWorkflowGateEmitter(successorEmitter);
5867
+ }
5868
+
5869
+ #suspendWorkflowGateEmitter(sessionId: string): WorkflowGateEmitter | undefined {
5870
+ const emitter = this.#workflowGateEmitter;
5871
+ if (!emitter) return undefined;
5872
+ emitter.suspend?.();
5873
+ this.#workflowGateEmitter = undefined;
5874
+ notifyWorkflowGateEmitterChanged(sessionId, undefined);
5875
+ return emitter;
5876
+ }
5877
+
5878
+ #restoreWorkflowGateEmitter(emitter: WorkflowGateEmitter | undefined): void {
5879
+ if (!emitter) return;
5880
+ emitter.resume?.();
5881
+ this.setWorkflowGateEmitter(emitter);
5882
+ }
5883
+
5117
5884
  setWorkflowGateEmitter(emitter: WorkflowGateEmitter | undefined): void {
5118
5885
  this.#workflowGateEmitter = emitter;
5119
5886
  notifyWorkflowGateEmitterChanged(this.sessionId, emitter);
@@ -5504,48 +6271,60 @@ export class AgentSession {
5504
6271
  if (workflowIntentDiff) {
5505
6272
  this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
5506
6273
  }
6274
+ options?.onPreflightAccepted?.();
5507
6275
  return;
5508
6276
  }
5509
6277
 
5510
- if (workflowIntentDiff) {
5511
- this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
5512
- }
6278
+ const admissionGeneration = this.#promptGeneration;
6279
+ const admissionSignal = this.#promptPreflightAbortController.signal;
6280
+ await this.#withSessionAdmission("prompt", async admission => {
6281
+ this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
6282
+ if (workflowIntentDiff) {
6283
+ this.sessionManager.appendCustomEntry(WORKFLOW_INTENT_DIFF_CUSTOM_TYPE, workflowIntentDiff);
6284
+ }
5513
6285
 
5514
- // Skip eager todo prelude when the user has already queued a directive
5515
- const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
5516
- const eagerTodoPrelude =
5517
- !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
6286
+ // Skip eager todo prelude when the user has already queued a directive
6287
+ const hasPendingUserDirective = this.#toolChoiceQueue.inspect().includes("user-force");
6288
+ const eagerTodoPrelude =
6289
+ !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
5518
6290
 
5519
- const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
5520
- if (options?.images) {
5521
- userContent.push(...options.images);
5522
- }
6291
+ const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
6292
+ if (options?.images) {
6293
+ userContent.push(...options.images);
6294
+ }
5523
6295
 
5524
- const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
5525
- const message = options?.synthetic
5526
- ? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }
5527
- : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
5528
- await this.refreshGjcSubskillTools();
6296
+ const promptAttribution = options?.attribution ?? (options?.synthetic ? "agent" : "user");
6297
+ const message = options?.synthetic
6298
+ ? {
6299
+ role: "developer" as const,
6300
+ content: userContent,
6301
+ attribution: promptAttribution,
6302
+ timestamp: Date.now(),
6303
+ }
6304
+ : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
6305
+ await this.refreshGjcSubskillTools();
5529
6306
 
5530
- if (eagerTodoPrelude?.toolChoice) {
5531
- this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
5532
- label: "eager-todo",
5533
- });
5534
- }
6307
+ if (eagerTodoPrelude?.toolChoice) {
6308
+ this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
6309
+ label: "eager-todo",
6310
+ });
6311
+ }
5535
6312
 
5536
- try {
5537
- await this.#promptWithMessage(message, expandedText, {
5538
- ...options,
5539
- prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
5540
- });
5541
- } finally {
5542
- // Clean up residual eager-todo directive if the prompt never consumed it
5543
- // (e.g., compaction aborted, validation failed).
5544
- this.#toolChoiceQueue.removeByLabel("eager-todo");
5545
- }
5546
- if (!options?.synthetic) {
5547
- await this.#enforcePlanModeToolDecision();
5548
- }
6313
+ try {
6314
+ await this.#promptWithMessage(message, expandedText, {
6315
+ ...options,
6316
+ prependMessages: eagerTodoPrelude ? [eagerTodoPrelude.message] : undefined,
6317
+ admissionLease: admission,
6318
+ });
6319
+ } finally {
6320
+ // Clean up residual eager-todo directive if the prompt never consumed it
6321
+ // (e.g., compaction aborted, validation failed).
6322
+ this.#toolChoiceQueue.removeByLabel("eager-todo");
6323
+ }
6324
+ if (!options?.synthetic) {
6325
+ await this.#enforcePlanModeToolDecision();
6326
+ }
6327
+ });
5549
6328
  }
5550
6329
 
5551
6330
  async #syncSkillPromptActiveState(
@@ -5636,36 +6415,53 @@ export class AgentSession {
5636
6415
  return;
5637
6416
  }
5638
6417
 
5639
- const customMessage: CustomMessage<T> = {
5640
- role: "custom",
5641
- customType: message.customType,
5642
- content: message.content,
5643
- display: message.display,
5644
- details: message.details,
5645
- attribution: message.attribution ?? "agent",
5646
- timestamp: Date.now(),
5647
- };
6418
+ const admissionGeneration = this.#promptGeneration;
6419
+ const admissionSignal = this.#promptPreflightAbortController.signal;
6420
+ await this.#withSessionAdmission("prompt", async admission => {
6421
+ this.#throwIfPromptPreflightCancelled(admissionGeneration, admissionSignal);
6422
+ const customMessage: CustomMessage<T> = {
6423
+ role: "custom",
6424
+ customType: message.customType,
6425
+ content: message.content,
6426
+ display: message.display,
6427
+ details: message.details,
6428
+ attribution: message.attribution ?? "agent",
6429
+ timestamp: Date.now(),
6430
+ };
5648
6431
 
5649
- await this.#syncSkillPromptActiveStateSafely(customMessage, true);
5650
- try {
5651
- await this.#promptWithMessage(customMessage, textContent, options);
5652
- } finally {
5653
- await this.#syncSkillPromptActiveStateSafely(customMessage, false);
5654
- }
6432
+ await this.#syncSkillPromptActiveStateSafely(customMessage, true);
6433
+ try {
6434
+ await this.#promptWithMessage(customMessage, textContent, { ...options, admissionLease: admission });
6435
+ } finally {
6436
+ await this.#syncSkillPromptActiveStateSafely(customMessage, false);
6437
+ }
6438
+ });
5655
6439
  }
5656
6440
 
5657
6441
  async #promptWithMessage(
5658
6442
  message: AgentMessage,
5659
6443
  expandedText: string,
5660
- options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
6444
+ options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck" | "onPreflightAccepted"> & {
5661
6445
  prependMessages?: AgentMessage[];
5662
6446
  skipPostPromptRecoveryWait?: boolean;
6447
+ predecessorAgentEndHold?: symbol;
6448
+ admissionLease?: SessionAdmissionLease;
5663
6449
  },
5664
6450
  ): Promise<void> {
5665
6451
  this.#beginInFlight();
6452
+ const predecessorAgentEndHold =
6453
+ options?.predecessorAgentEndHold ?? this.#reserveDeferredAgentEndForContinuation();
5666
6454
  const generation = this.#promptGeneration;
6455
+ const preflightSignal = this.#promptPreflightAbortController.signal;
5667
6456
  const rosterClaim = this.#claimIrcRosterCandidate();
5668
6457
  try {
6458
+ this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
6459
+ if (message.role === "user") {
6460
+ await this.#resetDefaultFallbackForNewTurn();
6461
+ await this.#ensureDefaultFallbackResolution();
6462
+ this.#defaultFallbackChain().resetAttemptBudget();
6463
+ this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
6464
+ }
5669
6465
  // Flush any pending bash messages before the new prompt
5670
6466
  this.#flushPendingBashMessages();
5671
6467
  this.#flushPendingPythonMessages();
@@ -5674,8 +6470,6 @@ export class AgentSession {
5674
6470
  // Reset todo reminder count on new user prompt
5675
6471
  this.#todoReminderCount = 0;
5676
6472
 
5677
- await this.#maybeRestoreRetryFallbackPrimary();
5678
-
5679
6473
  // Validate model
5680
6474
  if (!this.model) {
5681
6475
  throw new Error(formatNoModelOnboardingError());
@@ -5727,10 +6521,14 @@ export class AgentSession {
5727
6521
 
5728
6522
  messages.push(message);
5729
6523
 
5730
- // Early bail-out: if a newer abort/prompt cycle started during setup,
5731
- // return before mutating shared state (nextTurn messages, system prompt).
5732
- if (this.#promptGeneration !== generation) {
6524
+ // Early bail-out: a generation change or cancellation during setup must
6525
+ // terminate preflight rather than retaining SDK prompt authority.
6526
+ if (this.#isPromptPreflightCancelled(generation, preflightSignal)) {
5733
6527
  this.#resetInjectedContextSignatures();
6528
+ // A newer abort/prompt cycle superseded this preflight. Callers awaiting
6529
+ // acceptance (onPreflightAccepted) must be told it never ran; direct
6530
+ // callers (e.g. prompt() aborted during a TTSR wait) resolve gracefully.
6531
+ if (options?.onPreflightAccepted) throw promptPreflightCancelledError();
5734
6532
  return;
5735
6533
  }
5736
6534
 
@@ -5774,12 +6572,13 @@ export class AgentSession {
5774
6572
  const promptAttribution: "user" | "agent" | undefined =
5775
6573
  "attribution" in message ? message.attribution : undefined;
5776
6574
 
5777
- // Emit before_agent_start extension event
6575
+ // Emit before_agent_start extension event. Race hook completion with prompt
6576
+ // cancellation so a wedged hook cannot retain SDK prompt authority.
5778
6577
  if (this.#extensionRunner) {
5779
- const result = await this.#extensionRunner.emitBeforeAgentStart(
5780
- expandedText,
5781
- options?.images,
5782
- beforeAgentStartSystemPrompt,
6578
+ const result = await this.#awaitPromptPreflight(
6579
+ generation,
6580
+ preflightSignal,
6581
+ this.#extensionRunner.emitBeforeAgentStart(expandedText, options?.images, beforeAgentStartSystemPrompt),
5783
6582
  );
5784
6583
  if (result?.messages) {
5785
6584
  this.#appendBeforeAgentStartCustomMessages(messages, result.messages, promptAttribution, message.role);
@@ -5801,30 +6600,43 @@ export class AgentSession {
5801
6600
  const contributed: BeforeAgentStartInternalMessage[] = [];
5802
6601
  for (const contributor of this.#beforeAgentStartContributors) {
5803
6602
  try {
5804
- const msg = await contributor({
5805
- prompt: expandedText,
5806
- images: options?.images,
5807
- sessionId: this.sessionId,
5808
- });
6603
+ const msg = await this.#awaitPromptPreflight(
6604
+ generation,
6605
+ preflightSignal,
6606
+ contributor({
6607
+ prompt: expandedText,
6608
+ images: options?.images,
6609
+ sessionId: this.sessionId,
6610
+ }),
6611
+ );
5809
6612
  if (msg) contributed.push(msg);
5810
6613
  } catch (err) {
6614
+ if (this.#isPromptPreflightCancelled(generation, preflightSignal))
6615
+ throw promptPreflightCancelledError();
5811
6616
  logger.debug("before_agent_start contributor failed", { error: String(err) });
5812
6617
  }
5813
6618
  }
5814
6619
  this.#appendBeforeAgentStartCustomMessages(messages, contributed, promptAttribution, message.role);
5815
6620
  }
5816
6621
 
5817
- // Bail out if a newer abort/prompt cycle has started since we began setup.
5818
- // The injection signatures were consumed while building the automatic
5819
- // goal/plan context above, but the message is not delivered on this
5820
- // aborted turn, so reset them here so the next real turn re-injects once.
5821
- if (this.#promptGeneration !== generation) {
6622
+ // Abort can race asynchronous preflight work. The injection signatures were
6623
+ // consumed while building context, but no prompt was accepted, so reset them.
6624
+ if (this.#isPromptPreflightCancelled(generation, preflightSignal)) {
5822
6625
  this.#resetInjectedContextSignatures();
6626
+ // Ack-waiting callers are told the preflight never ran; direct callers
6627
+ // (aborted after setup) resolve gracefully as before f24f46ff5.
6628
+ if (options?.onPreflightAccepted) throw promptPreflightCancelledError();
5823
6629
  return;
5824
6630
  }
5825
6631
 
5826
- const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
5827
- await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
6632
+ const agentPromptOptions = {
6633
+ ...(options?.toolChoice ? { toolChoice: options.toolChoice } : undefined),
6634
+ ...this.#managedFallbackPromptOptions(),
6635
+ ...(options?.admissionLease ? { onRunAccepted: options.admissionLease.release } : undefined),
6636
+ };
6637
+ options?.onPreflightAccepted?.();
6638
+ this.#throwIfPromptPreflightCancelled(generation, preflightSignal);
6639
+ await this.#promptAgentWithIdleRetry(messages, agentPromptOptions, predecessorAgentEndHold);
5828
6640
  const terminalAssistant = this.#findLastAssistantMessage();
5829
6641
  if (
5830
6642
  rosterClaim &&
@@ -5837,6 +6649,12 @@ export class AgentSession {
5837
6649
  if (!options?.skipPostPromptRecoveryWait) {
5838
6650
  await this.#waitForPostPromptRecovery();
5839
6651
  }
6652
+ } catch (error) {
6653
+ // Session identity changes historically cancel local setup silently. Only SDK
6654
+ // submissions provide an acceptance callback and require an explicit terminal
6655
+ // preflight failure for their remote request authority.
6656
+ if (isPromptPreflightCancelledError(error) && !options?.onPreflightAccepted) return;
6657
+ throw error;
5840
6658
  } finally {
5841
6659
  if (rosterClaim) {
5842
6660
  this.agent.replaceMessages(
@@ -5846,6 +6664,7 @@ export class AgentSession {
5846
6664
  );
5847
6665
  this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
5848
6666
  }
6667
+ this.#releaseDeferredAgentEndContinuation(predecessorAgentEndHold);
5849
6668
  this.#endInFlight();
5850
6669
  }
5851
6670
  }
@@ -5898,6 +6717,50 @@ export class AgentSession {
5898
6717
  void this.abort();
5899
6718
  },
5900
6719
  hasPendingMessages: () => this.queuedMessageCount > 0,
6720
+ getPendingMessageCounts: () => this.pendingMessageCounts,
6721
+ getTranscript: () => this.getTranscript(),
6722
+ getTranscriptBody: entryId => this.getTranscriptBody(entryId),
6723
+ getGoalState: () => this.getGoalModeState(),
6724
+ getTodoState: () => this.getTodoPhases(),
6725
+ getQueuedMessages: () => this.getQueuedMessageEntries(),
6726
+ getActiveTools: () => this.getActiveToolNames(),
6727
+ getAllTools: () => this.getAllToolNames(),
6728
+ cycleModel: () => this.cycleModel(),
6729
+ cycleThinkingLevel: () => this.cycleThinkingLevel(),
6730
+ setQueueMode: (kind, mode) => {
6731
+ if (kind === "steering" && (mode === "all" || mode === "one-at-a-time")) {
6732
+ this.setSteeringMode(mode);
6733
+ return true;
6734
+ }
6735
+ if (kind === "follow_up" && (mode === "all" || mode === "one-at-a-time")) {
6736
+ this.setFollowUpMode(mode);
6737
+ return true;
6738
+ }
6739
+ if (kind === "interrupt" && (mode === "immediate" || mode === "wait")) {
6740
+ this.setInterruptMode(mode);
6741
+ return true;
6742
+ }
6743
+ return false;
6744
+ },
6745
+ invokeSkill: (name, args) => this.invokeSkill(name, args),
6746
+ setPlanMode: on => this.setSdkPlanMode(on),
6747
+ operateGoal: (op, objective) => this.operateGoal(op, objective),
6748
+ getSkillState: () => this.skills.map(skill => ({ name: skill.name, description: skill.description })),
6749
+ getConfigItems: () => this.getSdkConfigItems(),
6750
+ getBranchCandidates: () => this.sessionManager.getTree(),
6751
+ getExtensions: () => this.#extensionRunner?.getExtensionPaths() ?? [],
6752
+ getArtifact: () => undefined,
6753
+ getJobs: () => undefined,
6754
+ sdkBindings: () => [
6755
+ "cycleModel",
6756
+ "cycleThinkingLevel",
6757
+ "setQueueMode",
6758
+ "getSkillState",
6759
+ "getConfigItems",
6760
+ "getBranchCandidates",
6761
+ "getExtensions",
6762
+ ],
6763
+ clearContext: () => this.clearContext(),
5901
6764
  shutdown: () => {
5902
6765
  void this.dispose();
5903
6766
  process.exit(0);
@@ -6354,7 +7217,7 @@ export class AgentSession {
6354
7217
  */
6355
7218
  async sendUserMessage(
6356
7219
  content: string | (TextContent | ImageContent)[],
6357
- options?: { deliverAs?: "steer" | "followUp" },
7220
+ options?: { deliverAs?: "steer" | "followUp"; onPreflightAccepted?: () => void },
6358
7221
  ): Promise<void> {
6359
7222
  // Normalize content to text string + optional images
6360
7223
  let text: string;
@@ -6378,10 +7241,12 @@ export class AgentSession {
6378
7241
 
6379
7242
  if (options?.deliverAs === "followUp") {
6380
7243
  await this.#queueFollowUp(text, images);
7244
+ options.onPreflightAccepted?.();
6381
7245
  return;
6382
7246
  }
6383
7247
  if (options?.deliverAs === "steer") {
6384
7248
  await this.#queueSteer(text, images);
7249
+ options.onPreflightAccepted?.();
6385
7250
  return;
6386
7251
  }
6387
7252
 
@@ -6392,6 +7257,7 @@ export class AgentSession {
6392
7257
  // the message in the steering queue with no turn to consume it.
6393
7258
  if (this.isStreaming) {
6394
7259
  await this.#queueSteer(text, images);
7260
+ options?.onPreflightAccepted?.();
6395
7261
  return;
6396
7262
  }
6397
7263
 
@@ -6399,6 +7265,7 @@ export class AgentSession {
6399
7265
  await this.prompt(text, {
6400
7266
  expandPromptTemplates: false,
6401
7267
  images,
7268
+ onPreflightAccepted: options?.onPreflightAccepted,
6402
7269
  });
6403
7270
  }
6404
7271
 
@@ -6419,6 +7286,14 @@ export class AgentSession {
6419
7286
  get queuedMessageCount(): number {
6420
7287
  return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
6421
7288
  }
7289
+ /** Typed pending-message counts per queue (steering, follow-up, next-turn). */
7290
+ get pendingMessageCounts(): { steering: number; followUp: number; nextTurn: number } {
7291
+ return {
7292
+ steering: this.#steeringMessages.length,
7293
+ followUp: this.#followUpMessages.length,
7294
+ nextTurn: this.#pendingNextTurnMessages.length,
7295
+ };
7296
+ }
6422
7297
 
6423
7298
  /** Whether the agent has queued steering messages that a `user_interrupt`
6424
7299
  * abort would resume into (steer-on-interrupt). Drives the Esc-on-steer UX:
@@ -6596,6 +7471,32 @@ export class AgentSession {
6596
7471
  return this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
6597
7472
  }
6598
7473
 
7474
+ /** Read-only test seam for active mid-run EventStream drain barriers. */
7475
+ get activeMidRunBarrierCountForTests(): number {
7476
+ return this.#activeMidRunBarrierControllers.size;
7477
+ }
7478
+
7479
+ /** Read-only test seam for active mid-run maintenance invocations. */
7480
+ get activeMidRunMaintenanceCountForTests(): number {
7481
+ return this.#activeMidRunMaintenancePromises.size;
7482
+ }
7483
+
7484
+ /** Test seam: drive the cooperative mid-run maintenance checkpoint directly. */
7485
+ runMidRunMaintenanceForTests(
7486
+ context: AgentContext,
7487
+ lifecycle: MidRunMaintenanceLifecycle = {
7488
+ signal: new AbortController().signal,
7489
+ awaitEventDrain: async () => {},
7490
+ },
7491
+ ): Promise<MidRunMaintenanceOutcome> {
7492
+ return this.#trackMidRunMaintenance(this.#runMidRunMaintenance(context, lifecycle));
7493
+ }
7494
+
7495
+ /** Test seam: estimate mid-run context tokens for a given context view. */
7496
+ estimateMidRunContextTokensForTests(messages: readonly AgentMessage[]): number {
7497
+ return this.#estimateMidRunContextTokens(messages);
7498
+ }
7499
+
6599
7500
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
6600
7501
  return phases.map(phase => ({
6601
7502
  name: phase.name,
@@ -6633,6 +7534,7 @@ export class AgentSession {
6633
7534
  * hand-off rather than a failure. */
6634
7535
  silent?: boolean;
6635
7536
  }): Promise<void> {
7537
+ this.#abortActiveMidRunBarriers();
6636
7538
  if (options?.silent) {
6637
7539
  this.#silentAbortPending = true;
6638
7540
  } else {
@@ -6640,12 +7542,16 @@ export class AgentSession {
6640
7542
  }
6641
7543
  this.abortRetry();
6642
7544
  this.#promptGeneration++;
7545
+ this.#promptPreflightAbortController.abort();
7546
+ this.#promptPreflightAbortController = new AbortController();
6643
7547
  this.#scheduledHiddenNextTurnGeneration = undefined;
6644
7548
  this.abortCompaction();
6645
7549
  this.abortHandoff();
6646
7550
  this.abortBash();
6647
7551
  this.abortEval();
6648
7552
  const postPromptDrain = this.#cancelPostPromptTasks();
7553
+ const managedLogicalRunId =
7554
+ this.#defaultFallbackChain().chain.entries.length > 1 ? this.agent.currentManagedLogicalRunId : undefined;
6649
7555
  this.agent.abort();
6650
7556
  const cleanup = Promise.all([postPromptDrain, this.agent.waitForIdle()]).then(
6651
7557
  () => ({ type: "settled" as const }),
@@ -6673,10 +7579,16 @@ export class AgentSession {
6673
7579
  }
6674
7580
  }
6675
7581
  await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
6676
- // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
6677
- // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
6678
- // a subsequent prompt() can incorrectly observe the session as busy after an abort.
6679
- this.#resetInFlight();
7582
+ if (managedLogicalRunId !== undefined) {
7583
+ // Agent-core owns terminalization for the logical managed run, which
7584
+ // remains stable across fallback attempt retries.
7585
+ this.agent.requestRunTerminal(managedLogicalRunId, { stopReason: "cancelled" });
7586
+ }
7587
+ // waitForIdle resolves before #promptWithMessage's finally and asynchronous
7588
+ // event handlers necessarily unwind. Keep their counters intact: #endInFlight
7589
+ // and the event barrier publish deferred readiness only when they actually settle.
7590
+ this.#flushPendingBackgroundExchanges();
7591
+ this.#flushPendingAgentEnd();
6680
7592
  // Safety net: clear the silent-abort flag if it was never consumed (the
6681
7593
  // abort produced no aborted assistant message_end to stamp). Prevents the
6682
7594
  // marker from leaking onto a later, unrelated abort.
@@ -6711,6 +7623,7 @@ export class AgentSession {
6711
7623
  */
6712
7624
  async newSession(options?: NewSessionOptions): Promise<boolean> {
6713
7625
  const previousSessionFile = this.sessionFile;
7626
+ const previousWorkflowGateSessionId = this.sessionId;
6714
7627
  const nextDiscoverySessionToolNames = this.#mcpDiscoveryEnabled
6715
7628
  ? [
6716
7629
  ...this.#getActiveNonMCPToolNames(),
@@ -6734,6 +7647,7 @@ export class AgentSession {
6734
7647
  await this.abort();
6735
7648
  this.#cancelOwnAsyncJobs();
6736
7649
  this.#closeAllProviderSessions("new session");
7650
+ this.#rebindProviderSessionState(new Map());
6737
7651
  this.agent.reset();
6738
7652
  if (options?.drop && previousSessionFile) {
6739
7653
  try {
@@ -6747,6 +7661,7 @@ export class AgentSession {
6747
7661
  await this.sessionManager.newSession(options);
6748
7662
  this.setTodoPhases([]);
6749
7663
  this.#syncAgentSessionId();
7664
+ this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
6750
7665
  this.#rekeyHindsightMemoryForCurrentSessionId();
6751
7666
  this.#resetHindsightConversationTrackingIfHindsight();
6752
7667
  this.#steeringMessages = [];
@@ -6754,7 +7669,10 @@ export class AgentSession {
6754
7669
  this.#pendingNextTurnMessages = [];
6755
7670
  this.#scheduledHiddenNextTurnGeneration = undefined;
6756
7671
 
6757
- this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
7672
+ const inheritedThinkingLevel = resolveThinkingLevelForModel(this.model, this.#getInheritedThinkingLevel());
7673
+ this.#thinkingLevel = inheritedThinkingLevel;
7674
+ this.agent.setThinkingLevel(toReasoningEffort(inheritedThinkingLevel));
7675
+ this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit);
6758
7676
  if (this.model) {
6759
7677
  this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
6760
7678
  }
@@ -6839,6 +7757,7 @@ export class AgentSession {
6839
7757
  */
6840
7758
  async fork(): Promise<boolean> {
6841
7759
  const previousSessionFile = this.sessionFile;
7760
+ const previousWorkflowGateSessionId = this.sessionId;
6842
7761
 
6843
7762
  // Emit session_before_switch event with reason "fork" (can be cancelled)
6844
7763
  if (this.#extensionRunner?.hasHandlers("session_before_switch")) {
@@ -6882,6 +7801,7 @@ export class AgentSession {
6882
7801
 
6883
7802
  // Update agent session ID
6884
7803
  this.#syncAgentSessionId();
7804
+ this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
6885
7805
  this.#rekeyHindsightMemoryForCurrentSessionId();
6886
7806
 
6887
7807
  this.#resetIrcRosterDeliveryState();
@@ -6910,7 +7830,7 @@ export class AgentSession {
6910
7830
  async setModel(
6911
7831
  model: Model,
6912
7832
  role: string = "default",
6913
- options?: { selector?: string; thinkingLevel?: ThinkingLevel },
7833
+ options?: { selector?: string; thinkingLevel?: ThinkingLevel; cause?: ModelChangeCause },
6914
7834
  ): Promise<void> {
6915
7835
  const previousEditMode = this.#resolveActiveEditMode();
6916
7836
  const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
@@ -6918,15 +7838,39 @@ export class AgentSession {
6918
7838
  throw new Error(`No API key for ${model.provider}/${model.id}`);
6919
7839
  }
6920
7840
 
6921
- this.#clearActiveRetryFallback();
6922
- this.#setModelWithProviderSessionReset(model);
7841
+ this.#setModelAuthoritatively(model, options?.cause ?? "user-selection");
6923
7842
  this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, role);
6924
7843
  this.settings.setModelRole(
6925
7844
  role,
6926
7845
  this.#formatRoleModelValue(role, model, options?.selector, options?.thinkingLevel),
6927
7846
  );
7847
+ if (role === "default") {
7848
+ this.#defaultFallbackController = undefined;
7849
+ this.#defaultFallbackExhaustedLastTurn = false;
7850
+ }
6928
7851
  this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
6929
7852
 
7853
+ // Persist configured intent rather than a transient controller index. A pick
7854
+ // inside an existing chain keeps its deterministic suffix; a different
7855
+ // thinking choice for the same concrete model becomes the new head followed
7856
+ // by that concrete entry's tail. Picks outside the chain are one-entry intent.
7857
+ const configuredChain = this.getConfiguredModelChain(role);
7858
+ if (configuredChain) {
7859
+ const selectedSelector = this.#canonicalSelector(model, options?.selector, options?.thinkingLevel);
7860
+ const exactIndex = configuredChain.indexOf(selectedSelector);
7861
+ const concreteIndex = configuredChain.findIndex(entry => {
7862
+ const parsed = parseModelString(entry);
7863
+ return parsed?.provider === model.provider && parsed.id === model.id;
7864
+ });
7865
+ const entries =
7866
+ exactIndex !== -1
7867
+ ? configuredChain.slice(exactIndex)
7868
+ : concreteIndex !== -1
7869
+ ? [selectedSelector, ...configuredChain.slice(concreteIndex + 1)]
7870
+ : [selectedSelector];
7871
+ this.setConfiguredModelChain(role, entries, "model_selection");
7872
+ }
7873
+
6930
7874
  // Apply the explicitly selected thinking level when the selector supplies one;
6931
7875
  // otherwise prefer the model's configured defaultLevel, then preserve the current level.
6932
7876
  this.setThinkingLevel(options?.thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
@@ -6941,21 +7885,67 @@ export class AgentSession {
6941
7885
  return this.#activeModelProfile;
6942
7886
  }
6943
7887
 
6944
- /**
6945
- * The model selector ("provider/id") that resume restores as the session
6946
- * default — the latest session-log `model_change` with role="default".
6947
- * Model-profile activation snapshots this before mutating the session so a
6948
- * failed-activation rollback can restore the pre-activation resume default
6949
- * instead of promoting a transient runtime model to the resume default.
6950
- */
6951
- getSessionDefaultModelSelector(): string | undefined {
6952
- return this.sessionManager.buildSessionContext().models.default;
7888
+ /** Return the persisted configured fallback selectors for a model role. */
7889
+ getConfiguredModelChain(role: string): readonly string[] | undefined {
7890
+ return this.sessionManager.buildSessionContext().configuredModelChains[role]?.entries;
6953
7891
  }
6954
7892
 
6955
- /**
6956
- * Re-assert the session resume default ("provider/id") in the session log
6957
- * WITHOUT touching the live runtime model. Appends a `model_change` with
6958
- * role="default"; never writes to global settings (apply-for-this-session
7893
+ /** Persist the configured fallback selectors for a model role. */
7894
+ setConfiguredModelChain(
7895
+ role: string,
7896
+ entries: readonly string[],
7897
+ origin: string,
7898
+ identity?: string,
7899
+ explicitHead = true,
7900
+ ): void {
7901
+ this.sessionManager.appendConfiguredModelChain({
7902
+ role,
7903
+ entries: [...entries],
7904
+ origin,
7905
+ identity,
7906
+ explicitHead,
7907
+ cleared: entries.length === 0,
7908
+ });
7909
+ }
7910
+
7911
+ /**
7912
+ * Replace only the in-memory default fallback controller. Used when startup
7913
+ * falls through an unavailable persisted chain to the global default, which
7914
+ * must not mutate the persisted configured intent.
7915
+ */
7916
+ setDefaultFallbackRuntimeModel(selector: string): void {
7917
+ this.#defaultFallbackController = new FallbackChainController(
7918
+ { role: "default", entries: [selector], origin: "runtime", explicitHead: true },
7919
+ this.settings.get("fallback.maxAttempts"),
7920
+ );
7921
+ this.#defaultFallbackExhaustedLastTurn = false;
7922
+ }
7923
+
7924
+ /**
7925
+ * Seed default fallback state after guarded auth-aware model resolution skips chain entries.
7926
+ * The configured chain's role, origin, and identity are retained by the controller.
7927
+ */
7928
+ seedDefaultFallbackResolution(activeIndex: number, skips: Array<{ selector: string; reason: string }>): void {
7929
+ const controller = this.#defaultFallbackChain();
7930
+ controller.seedResolution(activeIndex, skips);
7931
+ this.#emitResolutionFallbackSwitch(controller);
7932
+ }
7933
+
7934
+ /**
7935
+ * The model selector ("provider/id") that resume restores as the session
7936
+ * default — the latest session-log `model_change` with role="default".
7937
+ * Model-profile activation snapshots this before mutating the session so a
7938
+ * failed-activation rollback can restore the pre-activation resume default
7939
+ * instead of promoting a transient runtime model to the resume default.
7940
+ */
7941
+ getSessionDefaultModelSelector(): string | undefined {
7942
+ return this.sessionManager.buildSessionContext().models.default;
7943
+ }
7944
+
7945
+ /**
7946
+ * Re-assert the session resume default ("provider/id") in the session log
7947
+ * WITHOUT touching the live runtime model. Appends a `model_change` with
7948
+ * role="default"; never writes to global settings (apply-for-this-session
6959
7949
  * semantics). Used by model-profile activation rollback to neutralize the
6960
7950
  * profile main model the failed activation already recorded as the default.
6961
7951
  */
@@ -6979,37 +7969,179 @@ export class AgentSession {
6979
7969
  async setModelTemporary(
6980
7970
  model: Model,
6981
7971
  thinkingLevel?: ThinkingLevel,
6982
- options?: { persistAsSessionDefault?: boolean },
6983
- ): Promise<void> {
7972
+ options?: {
7973
+ persistAsSessionDefault?: boolean;
7974
+ cause?: ModelChangeCause;
7975
+ reason?: TemporaryModelReason;
7976
+ providerSessionScope?: TemporaryProviderSessionScope;
7977
+ signal?: AbortSignal;
7978
+ },
7979
+ // biome-ignore lint/suspicious/noConfusingVoidType: Existing session adapters return Promise<void>; a scope is optional.
7980
+ ): Promise<TemporaryProviderSessionScope | void> {
7981
+ if (options?.signal?.aborted) return;
7982
+ const suppliedScope = options?.providerSessionScope;
7983
+ if (suppliedScope && this.#temporaryProviderSessionScopes.at(-1)?.token !== suppliedScope) return;
6984
7984
  const previousEditMode = this.#resolveActiveEditMode();
6985
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
7985
+ const expectedSessionId = this.sessionId;
7986
+ const apiKey = await this.#modelRegistry.getApiKey(model, expectedSessionId);
7987
+ if (options?.signal?.aborted) return;
7988
+ if (this.sessionId !== expectedSessionId) {
7989
+ throw new Error("Session changed while selecting model");
7990
+ }
6986
7991
  if (!apiKey) {
6987
7992
  throw new Error(`No API key for ${model.provider}/${model.id}`);
6988
7993
  }
7994
+ if (suppliedScope && this.#temporaryProviderSessionScopes.at(-1)?.token !== suppliedScope) return;
7995
+
7996
+ const isTemporaryOperation = options?.cause === undefined || options.cause === "temporary-operation";
7997
+ const autoCreateScope = isTemporaryOperation && !suppliedScope;
7998
+ const currentAutoScope = this.#currentAutoTemporaryProviderSessionScope();
7999
+ const replaceAutoScope =
8000
+ autoCreateScope &&
8001
+ currentAutoScope !== undefined &&
8002
+ this.#temporaryProviderSessionScopes.at(-1) === currentAutoScope;
8003
+ if (replaceAutoScope && currentAutoScope) {
8004
+ this.#restoreTopTemporaryProviderSessionScope();
8005
+ }
8006
+ const scope = isTemporaryOperation
8007
+ ? (suppliedScope ??
8008
+ (replaceAutoScope && this.model && modelsAreEqual(this.model, model)
8009
+ ? undefined
8010
+ : this.#beginTemporaryProviderSessionScope(options?.reason ?? "other", true)))
8011
+ : undefined;
8012
+ const ownsScope = scope !== undefined && !suppliedScope;
8013
+ try {
8014
+ if (isTemporaryOperation) {
8015
+ this.agent.setModel(model);
8016
+ this.#syncAppendOnlyContext(model);
8017
+ } else {
8018
+ this.#setModelAuthoritatively(model, options?.cause ?? "temporary-operation");
8019
+ }
8020
+ this.sessionManager.appendModelChange(
8021
+ `${model.provider}/${model.id}`,
8022
+ options?.persistAsSessionDefault ? "default" : "temporary",
8023
+ );
8024
+ this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
8025
+
8026
+ // Apply explicit thinking level if given; otherwise prefer the model's
8027
+ // configured defaultLevel; otherwise re-clamp the current level.
8028
+ this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
8029
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
8030
+ } catch (error) {
8031
+ if (ownsScope) this.restoreTemporaryProviderSessionScope(scope);
8032
+ throw error;
8033
+ }
8034
+ return scope;
8035
+ }
6989
8036
 
6990
- this.#clearActiveRetryFallback();
6991
- this.#setModelWithProviderSessionReset(model);
6992
- this.sessionManager.appendModelChange(
6993
- `${model.provider}/${model.id}`,
6994
- options?.persistAsSessionDefault ? "default" : "temporary",
8037
+ async #restoreDefaultModelSelectionCommit(
8038
+ commit: GlobalDefaultModelRoleCommit,
8039
+ ): Promise<{ readonly stage: DefaultModelSelectionRollbackStage; readonly message: string } | undefined> {
8040
+ try {
8041
+ if (await this.settings.restoreGlobalDefaultModelRoleIfCurrent(commit)) return undefined;
8042
+ return {
8043
+ stage: "durable",
8044
+ message: "A newer default selection prevented durable recovery.",
8045
+ };
8046
+ } catch {
8047
+ logger.warn("Failed to restore durable default model selection after session promotion failure", {
8048
+ code: "default_model_selection_recovery_failed",
8049
+ rollbackStage: "durable",
8050
+ });
8051
+ return {
8052
+ stage: "durable",
8053
+ message: "Durable default selection recovery could not be completed.",
8054
+ };
8055
+ }
8056
+ }
8057
+
8058
+ async #discardDefaultModelSelectionStage(
8059
+ stage: DefaultModelSelectionStage,
8060
+ ): Promise<{ readonly stage: DefaultModelSelectionRollbackStage; readonly message: string } | undefined> {
8061
+ try {
8062
+ await this.sessionManager.discardDefaultModelSelectionStage(stage);
8063
+ return undefined;
8064
+ } catch {
8065
+ logger.warn("Failed to discard staged default model selection after session promotion failure", {
8066
+ code: "default_model_selection_recovery_failed",
8067
+ rollbackStage: "session",
8068
+ });
8069
+ return {
8070
+ stage: "session",
8071
+ message: "Session replacement recovery could not be completed.",
8072
+ };
8073
+ }
8074
+ }
8075
+
8076
+ async #throwDefaultModelSelectionRecovery(
8077
+ error: Error,
8078
+ stage: DefaultModelSelectionStage,
8079
+ commit: GlobalDefaultModelRoleCommit,
8080
+ ): Promise<never> {
8081
+ const sessionFailure = await this.#discardDefaultModelSelectionStage(stage);
8082
+ const durableFailure = await this.#restoreDefaultModelSelectionCommit(commit);
8083
+ const failures = [sessionFailure, durableFailure].filter(
8084
+ (failure): failure is { readonly stage: DefaultModelSelectionRollbackStage; readonly message: string } =>
8085
+ failure !== undefined,
6995
8086
  );
6996
- this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
8087
+ throw new DefaultModelSelectionRecoveryError(error.message, {
8088
+ message: error.message,
8089
+ rollback: {
8090
+ disposition: failures.length === 0 ? "restored" : "partial",
8091
+ failures,
8092
+ },
8093
+ });
8094
+ }
6997
8095
 
6998
- // Apply explicit thinking level if given; otherwise prefer the model's
6999
- // configured defaultLevel; otherwise re-clamp the current level.
7000
- this.setThinkingLevel(thinkingLevel ?? model.thinking?.defaultLevel ?? this.thinkingLevel);
7001
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
8096
+ #publishDefaultModelSelection(model: Model, thinkingLevel: ThinkingLevel, systemPrompt: string[] | undefined): void {
8097
+ this.#clearActiveRetryFallback();
8098
+ this.#setModelWithProviderSessionReset(model);
8099
+ const thinkingLevelChanged = this.#thinkingLevel !== thinkingLevel;
8100
+ this.#thinkingLevel = thinkingLevel;
8101
+ this.agent.setThinkingLevel(toReasoningEffort(thinkingLevel));
8102
+ if (thinkingLevelChanged) {
8103
+ const event: AgentSessionEvent = { type: "thinking_level_changed", thinkingLevel };
8104
+ for (const listener of this.#eventListenerSnapshot) {
8105
+ try {
8106
+ listener(event);
8107
+ } catch {
8108
+ logger.warn("Default model selection event listener failed", {
8109
+ code: "default_model_selection_listener_failed",
8110
+ disposition: "continue",
8111
+ });
8112
+ }
8113
+ }
8114
+ }
8115
+ this.#applyPreparedDefaultModelSelectionPrompt(systemPrompt);
8116
+ try {
8117
+ this.settings.getStorage()?.recordModelUsage(`${model.provider}/${model.id}`);
8118
+ } catch {
8119
+ logger.warn("Failed to record model usage after default model selection", {
8120
+ code: "default_model_selection_model_usage_record_failed",
8121
+ disposition: "continue",
8122
+ });
8123
+ }
7002
8124
  }
7003
8125
 
8126
+ /** Set a durable per-session model from a control surface without exposing credential errors. */
8127
+ async setModelTemporaryForControl(model: Model, expectedSessionId: string = this.sessionId): Promise<boolean> {
8128
+ if (expectedSessionId !== this.sessionId) return false;
8129
+ try {
8130
+ await this.setModelTemporary(model, undefined, {
8131
+ persistAsSessionDefault: true,
8132
+ cause: "user-selection",
8133
+ });
8134
+ return expectedSessionId === this.sessionId;
8135
+ } catch {
8136
+ logger.warn("session: model control failed");
8137
+ return false;
8138
+ }
8139
+ }
7004
8140
  async setDefaultModelSelection(
7005
8141
  model: Model,
7006
8142
  thinkingLevel: ThinkingLevel | undefined,
7007
8143
  ): Promise<DefaultModelSelectionResult> {
7008
- const predecessor = this.#defaultModelSelectionTail;
7009
- const transaction = Promise.withResolvers<void>();
7010
- this.#defaultModelSelectionTail = transaction.promise;
7011
- try {
7012
- await predecessor;
8144
+ return this.#withSessionAdmission("selection", async () => {
7013
8145
  if (thinkingLevel === ThinkingLevel.Inherit) {
7014
8146
  throw new Error("Default model selection cannot inherit a thinking level");
7015
8147
  }
@@ -7023,72 +8155,64 @@ export class AgentSession {
7023
8155
  resolveThinkingLevelForModel(model, model.thinking?.defaultLevel ?? this.thinkingLevel) ??
7024
8156
  ThinkingLevel.Off;
7025
8157
  await this.waitForIdle();
7026
- const previousDefaultModelRole = this.settings.getGlobal("modelRoles")?.default;
7027
8158
  await this.sessionManager.flush();
7028
- const previousSessionState = this.sessionManager.captureState();
7029
- const previousModel = this.model;
7030
- const previousThinkingLevel = this.thinkingLevel;
7031
- const previousActiveRetryFallback = this.#activeRetryFallback ? { ...this.#activeRetryFallback } : undefined;
7032
- await this.settings.setGlobalModelRoleAndFlush(
7033
- "default",
7034
- formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel),
8159
+ await this.#waitForAdmittedBaseSystemPromptRebuilds();
8160
+ const expectedMutationRevision = this.#defaultModelSelectionMutationRevision;
8161
+ const preparedSystemPrompt = await this.#prepareDefaultModelSelectionPrompt(model);
8162
+ const stage = await this.sessionManager.stageDefaultModelSelection(
8163
+ `${model.provider}/${model.id}`,
8164
+ effectiveLevel,
8165
+ { appendThinkingLevel: true },
7035
8166
  );
8167
+ let durableCommit: GlobalDefaultModelRoleCommit;
7036
8168
  try {
7037
- await this.setModelTemporary(model, effectiveLevel);
7038
- if (this.thinkingLevel === previousThinkingLevel) {
7039
- this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
7040
- }
7041
- this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "default");
8169
+ durableCommit = await this.settings.setGlobalModelRoleAndFlush(
8170
+ "default",
8171
+ formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel),
8172
+ );
7042
8173
  } catch (error) {
7043
- await this.settings.setGlobalModelRoleAndFlush("default", previousDefaultModelRole).catch(rollbackError => {
7044
- logger.warn("Failed to restore durable default model selection after live apply failure", {
7045
- error: String(rollbackError),
7046
- });
7047
- });
7048
- let restoredLiveState = false;
7049
- try {
7050
- const failedEditMode = this.#resolveActiveEditMode();
7051
- const currentModel = this.model;
7052
- const hasCompatibleModelTransport =
7053
- currentModel !== undefined &&
7054
- previousModel !== undefined &&
7055
- currentModel.provider === previousModel.provider &&
7056
- currentModel.id === previousModel.id &&
7057
- currentModel.api === previousModel.api;
7058
- if (hasCompatibleModelTransport) {
7059
- this.agent.setModel(previousModel);
7060
- this.#syncAppendOnlyContext(previousModel);
7061
- } else {
7062
- this.#setModelWithProviderSessionReset(previousModel);
7063
- }
7064
- this.#thinkingLevel = previousThinkingLevel;
7065
- this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
7066
- await this.#syncEditToolModeAfterModelChange(failedEditMode);
7067
- restoredLiveState = true;
7068
- } catch (rollbackError) {
7069
- logger.warn("Failed to restore live default model selection after live apply failure", {
7070
- error: String(rollbackError),
7071
- });
8174
+ await this.sessionManager.discardDefaultModelSelectionStage(stage);
8175
+ throw error;
8176
+ }
8177
+ if (this.#defaultModelSelectionMutationRevision !== expectedMutationRevision) {
8178
+ await this.#throwDefaultModelSelectionRecovery(
8179
+ new Error("Default model selection was superseded before session promotion"),
8180
+ stage,
8181
+ durableCommit,
8182
+ );
8183
+ }
8184
+ const promotion = this.sessionManager.promoteDefaultModelSelection(stage);
8185
+ switch (promotion.kind) {
8186
+ case "promoted": {
8187
+ this.#publishDefaultModelSelection(model, effectiveLevel, preparedSystemPrompt);
8188
+ break;
7072
8189
  }
7073
- if (restoredLiveState) {
7074
- this.#activeRetryFallback = previousActiveRetryFallback;
8190
+ case "not_promoted": {
8191
+ return this.#throwDefaultModelSelectionRecovery(
8192
+ promotion.error ?? new Error("Default model selection was superseded before session promotion"),
8193
+ stage,
8194
+ durableCommit,
8195
+ );
7075
8196
  }
7076
- try {
7077
- this.sessionManager.restoreState(previousSessionState);
7078
- await this.sessionManager.rewriteEntries();
7079
- } catch (rollbackError) {
7080
- logger.warn("Failed to restore session state after live default model selection failure", {
7081
- error: String(rollbackError),
8197
+ case "unknown": {
8198
+ const message = "Session replacement outcome could not be determined.";
8199
+ throw new DefaultModelSelectionRecoveryError(message, {
8200
+ message,
8201
+ rollback: {
8202
+ disposition: "unknown",
8203
+ failures: [
8204
+ {
8205
+ stage: "session",
8206
+ message: "Session replacement outcome could not be determined.",
8207
+ },
8208
+ ],
8209
+ },
7082
8210
  });
7083
8211
  }
7084
- throw error;
7085
8212
  }
7086
8213
  return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel };
7087
- } finally {
7088
- transaction.resolve();
7089
- }
8214
+ });
7090
8215
  }
7091
-
7092
8216
  /**
7093
8217
  * Cycle to next/previous model.
7094
8218
  * Uses scoped models (from --models flag) if available, otherwise all available models.
@@ -7160,9 +8284,12 @@ export class AgentSession {
7160
8284
  const next = roleModels[nextIndex];
7161
8285
 
7162
8286
  if (options?.temporary) {
7163
- await this.setModelTemporary(next.model, next.explicitThinkingLevel ? next.thinkingLevel : undefined);
8287
+ await this.setModelTemporary(next.model, next.explicitThinkingLevel ? next.thinkingLevel : undefined, {
8288
+ cause: "temporary-operation",
8289
+ reason: "temporary-cycle",
8290
+ });
7164
8291
  } else {
7165
- await this.setModel(next.model, next.role);
8292
+ await this.setModel(next.model, next.role, { cause: "user-selection" });
7166
8293
  if (next.explicitThinkingLevel && next.thinkingLevel !== undefined) {
7167
8294
  this.setThinkingLevel(next.thinkingLevel);
7168
8295
  }
@@ -7194,7 +8321,6 @@ export class AgentSession {
7194
8321
  }
7195
8322
 
7196
8323
  async #cycleScopedModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
7197
- const previousEditMode = this.#resolveActiveEditMode();
7198
8324
  const scopedModels = await this.#getScopedModelsWithApiKey();
7199
8325
  if (scopedModels.length <= 1) return undefined;
7200
8326
 
@@ -7206,22 +8332,15 @@ export class AgentSession {
7206
8332
  const nextIndex = direction === "forward" ? (currentIndex + 1) % len : (currentIndex - 1 + len) % len;
7207
8333
  const next = scopedModels[nextIndex];
7208
8334
 
7209
- // Apply model
7210
- this.#clearActiveRetryFallback();
7211
- this.#setModelWithProviderSessionReset(next.model);
7212
- this.sessionManager.appendModelChange(`${next.model.provider}/${next.model.id}`);
7213
- this.settings.setModelRole("default", this.#formatRoleModelValue("default", next.model));
7214
- this.settings.getStorage()?.recordModelUsage(`${next.model.provider}/${next.model.id}`);
8335
+ await this.setModel(next.model, "default", { cause: "user-selection" });
7215
8336
 
7216
8337
  // Apply the scoped model's configured thinking level
7217
8338
  this.setThinkingLevel(next.thinkingLevel);
7218
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
7219
8339
 
7220
8340
  return { model: next.model, thinkingLevel: this.thinkingLevel, isScoped: true };
7221
8341
  }
7222
8342
 
7223
8343
  async #cycleAvailableModel(direction: "forward" | "backward"): Promise<ModelCycleResult | undefined> {
7224
- const previousEditMode = this.#resolveActiveEditMode();
7225
8344
  const availableModels = this.#modelRegistry.getAvailable();
7226
8345
  if (availableModels.length <= 1) return undefined;
7227
8346
 
@@ -7238,14 +8357,9 @@ export class AgentSession {
7238
8357
  throw new Error(`No API key for ${nextModel.provider}/${nextModel.id}`);
7239
8358
  }
7240
8359
 
7241
- this.#clearActiveRetryFallback();
7242
- this.#setModelWithProviderSessionReset(nextModel);
7243
- this.sessionManager.appendModelChange(`${nextModel.provider}/${nextModel.id}`);
7244
- this.settings.setModelRole("default", this.#formatRoleModelValue("default", nextModel));
7245
- this.settings.getStorage()?.recordModelUsage(`${nextModel.provider}/${nextModel.id}`);
8360
+ await this.setModel(nextModel, "default", { cause: "user-selection" });
7246
8361
  // Re-apply the current thinking level for the newly selected model
7247
8362
  this.setThinkingLevel(this.thinkingLevel);
7248
- await this.#syncEditToolModeAfterModelChange(previousEditMode);
7249
8363
 
7250
8364
  return { model: nextModel, thinkingLevel: this.thinkingLevel, isScoped: false };
7251
8365
  }
@@ -7261,6 +8375,11 @@ export class AgentSession {
7261
8375
  // Thinking Level Management
7262
8376
  // =========================================================================
7263
8377
 
8378
+ #getInheritedThinkingLevel(): ThinkingLevel | undefined {
8379
+ if (this.settings.has("defaultThinkingLevel")) return this.settings.get("defaultThinkingLevel");
8380
+ return this.model?.thinking?.defaultLevel ?? this.settings.get("defaultThinkingLevel");
8381
+ }
8382
+
7264
8383
  /**
7265
8384
  * Set thinking level.
7266
8385
  * Saves the effective metadata-clamped level to the session, and to settings when requested.
@@ -7271,9 +8390,11 @@ export class AgentSession {
7271
8390
 
7272
8391
  this.#thinkingLevel = effectiveLevel;
7273
8392
  this.agent.setThinkingLevel(toReasoningEffort(effectiveLevel));
8393
+ if (isChanging) this.#defaultModelSelectionMutationRevision++;
7274
8394
 
7275
- if (persist && effectiveLevel !== undefined) {
7276
- this.settings.set("defaultThinkingLevel", effectiveLevel);
8395
+ if (persist) {
8396
+ const persistedLevel = level === ThinkingLevel.Inherit ? getDefault("defaultThinkingLevel") : effectiveLevel;
8397
+ if (persistedLevel !== undefined) this.settings.set("defaultThinkingLevel", persistedLevel);
7277
8398
  }
7278
8399
 
7279
8400
  if (isChanging) {
@@ -7282,6 +8403,83 @@ export class AgentSession {
7282
8403
  }
7283
8404
  }
7284
8405
 
8406
+ /**
8407
+ * Set thinking level from a control surface. Global changes are durable or rolled back.
8408
+ */
8409
+ async setThinkingLevelForControl(level: ThinkingLevel, persist: boolean): Promise<void> {
8410
+ const previousThinkingLevel = this.thinkingLevel;
8411
+ const previousScope = this.getThinkingScopeForControl();
8412
+ if (!persist) {
8413
+ this.setThinkingLevel(level === ThinkingLevel.Inherit ? this.#getInheritedThinkingLevel() : level);
8414
+ if (level === ThinkingLevel.Inherit || this.thinkingLevel === previousThinkingLevel) {
8415
+ this.sessionManager.appendThinkingLevelChange(level);
8416
+ }
8417
+ return;
8418
+ }
8419
+
8420
+ const previousDefaultThinkingLevel = this.settings.getGlobal("defaultThinkingLevel");
8421
+ if (level === ThinkingLevel.Inherit) {
8422
+ const defaultLevel = getDefault("defaultThinkingLevel");
8423
+ this.settings.set("defaultThinkingLevel", defaultLevel);
8424
+ this.setThinkingLevel(this.#getInheritedThinkingLevel());
8425
+ } else {
8426
+ this.setThinkingLevel(level, true);
8427
+ }
8428
+ try {
8429
+ await this.settings.flushOrThrow();
8430
+ this.sessionManager.appendThinkingLevelChange(ThinkingLevel.Inherit);
8431
+ } catch {
8432
+ this.settings.set("defaultThinkingLevel", previousDefaultThinkingLevel ?? getDefault("defaultThinkingLevel"));
8433
+ this.setThinkingLevel(previousThinkingLevel);
8434
+ this.sessionManager.appendThinkingLevelChange(
8435
+ previousScope === "global config" ? ThinkingLevel.Inherit : previousThinkingLevel,
8436
+ );
8437
+ await this.settings.flushOrThrow().catch(() => {});
8438
+ throw new Error("Unable to persist reasoning settings.");
8439
+ }
8440
+ }
8441
+
8442
+ getThinkingScopeForControl(): "session" | "global config" {
8443
+ const latest = this.sessionManager
8444
+ .getBranch()
8445
+ .toReversed()
8446
+ .find(entry => entry.type === "thinking_level_change");
8447
+ return latest && latest.thinkingLevel !== ThinkingLevel.Inherit ? "session" : "global config";
8448
+ }
8449
+
8450
+ getThinkingVisibility(): "visible" | "hidden" {
8451
+ return this.agent.hideThinkingSummary ? "hidden" : "visible";
8452
+ }
8453
+
8454
+ setThinkingVisibility(visibility: "visible" | "hidden", persist: boolean = false): void {
8455
+ this.agent.hideThinkingSummary = visibility === "hidden";
8456
+ if (persist) {
8457
+ this.settings.set("hideThinkingBlock", visibility === "hidden");
8458
+ }
8459
+ }
8460
+
8461
+ /**
8462
+ * Set thinking visibility from a control surface. Global changes are durable or rolled back.
8463
+ */
8464
+ async setThinkingVisibilityForControl(visibility: "visible" | "hidden", persist: boolean): Promise<void> {
8465
+ if (!persist) {
8466
+ this.setThinkingVisibility(visibility);
8467
+ return;
8468
+ }
8469
+
8470
+ const previousVisibility = this.getThinkingVisibility();
8471
+ const previousHideThinkingBlock = this.settings.getGlobal("hideThinkingBlock");
8472
+ this.setThinkingVisibility(visibility, true);
8473
+ try {
8474
+ await this.settings.flushOrThrow();
8475
+ } catch {
8476
+ this.settings.set("hideThinkingBlock", previousHideThinkingBlock ?? getDefault("hideThinkingBlock"));
8477
+ this.setThinkingVisibility(previousVisibility);
8478
+ await this.settings.flushOrThrow().catch(() => {});
8479
+ throw new Error("Unable to persist reasoning settings.");
8480
+ }
8481
+ }
8482
+
7285
8483
  /**
7286
8484
  * Cycle to next thinking level.
7287
8485
  * @returns New level, or undefined if model doesn't support thinking
@@ -7482,7 +8680,7 @@ export class AgentSession {
7482
8680
  // Compaction
7483
8681
  // =========================================================================
7484
8682
 
7485
- async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
8683
+ async #pruneToolOutputs(signal?: AbortSignal): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
7486
8684
  const branchEntries = this.sessionManager.getBranch();
7487
8685
  const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
7488
8686
  const argumentResult = pruneAssistantToolArguments(branchEntries, DEFAULT_PRUNE_CONFIG);
@@ -7492,7 +8690,7 @@ export class AgentSession {
7492
8690
  const tokensSaved =
7493
8691
  result.tokensSaved + argumentResult.argumentTokensSaved + Math.round(fileMentionResult.bytesSaved / 4);
7494
8692
  const prunedCount = result.prunedCount + argumentResult.argumentPrunedCount + fileMentionResult.changed.length;
7495
- if (prunedCount === 0) {
8693
+ if (prunedCount === 0 || signal?.aborted) {
7496
8694
  return undefined;
7497
8695
  }
7498
8696
 
@@ -7757,6 +8955,28 @@ export class AgentSession {
7757
8955
  this.#handoffAbortController?.abort();
7758
8956
  }
7759
8957
 
8958
+ #abortActiveMidRunBarriers(): void {
8959
+ for (const controller of this.#activeMidRunBarrierControllers) {
8960
+ controller.abort();
8961
+ }
8962
+ this.#activeMidRunBarrierControllers.clear();
8963
+ }
8964
+
8965
+ #trackMidRunMaintenance(maintenance: Promise<MidRunMaintenanceOutcome>): Promise<MidRunMaintenanceOutcome> {
8966
+ this.#activeMidRunMaintenancePromises.add(maintenance);
8967
+ maintenance.then(
8968
+ () => this.#activeMidRunMaintenancePromises.delete(maintenance),
8969
+ () => this.#activeMidRunMaintenancePromises.delete(maintenance),
8970
+ );
8971
+ return maintenance;
8972
+ }
8973
+
8974
+ async #waitForActiveMidRunMaintenance(): Promise<void> {
8975
+ while (this.#activeMidRunMaintenancePromises.size > 0) {
8976
+ await Promise.allSettled([...this.#activeMidRunMaintenancePromises]);
8977
+ }
8978
+ }
8979
+
7760
8980
  /** Trigger idle compaction through the auto-compaction flow (with UI events). */
7761
8981
  async runIdleCompaction(): Promise<void> {
7762
8982
  if (this.isStreaming || this.isCompacting) return;
@@ -7914,6 +9134,13 @@ export class AgentSession {
7914
9134
  this.#syncTodoPhasesFromBranch();
7915
9135
 
7916
9136
  this.#resetIrcRosterDeliveryState();
9137
+ if (this.#extensionRunner) {
9138
+ await this.#extensionRunner.emit({
9139
+ type: "session_switch",
9140
+ reason: "new",
9141
+ previousSessionFile,
9142
+ });
9143
+ }
7917
9144
 
7918
9145
  return { document: handoffText, savedPath };
7919
9146
  } catch (error) {
@@ -7990,7 +9217,8 @@ export class AgentSession {
7990
9217
  const promoted = await this.#tryContextPromotion(assistantMessage);
7991
9218
  if (promoted) {
7992
9219
  // Retry on the promoted (larger) model without compacting
7993
- this.#scheduleAgentContinue({ delayMs: 100, generation });
9220
+ this.#scheduleAgentContinue({ delayMs: 100, generation, suppressPredecessorAgentEnd: true });
9221
+
7994
9222
  return;
7995
9223
  }
7996
9224
 
@@ -8031,6 +9259,167 @@ export class AgentSession {
8031
9259
  }
8032
9260
  }
8033
9261
 
9262
+ /**
9263
+ * Cooperative mid-run context maintenance (issue #2035).
9264
+ *
9265
+ * Invoked by the agent loop (via {@link Agent.setMaintainContext}) at the top
9266
+ * of each tool-loop iteration — after pending tool-result / steering messages
9267
+ * are durable and before the model call. Threshold-based auto-compaction was
9268
+ * previously only evaluated at `agent_end` and before a user prompt, so a
9269
+ * single long agentic run grew straight through the compaction margin and died
9270
+ * with provider `context_length_exceeded`. This bounds that run.
9271
+ *
9272
+ * Decision input is the last assistant `usage.totalTokens` plus the estimated
9273
+ * trailing tool/steering deltas (NOT the pre-prompt estimator's prompt-only
9274
+ * anchor, which drops the last assistant's output). Reuses the existing
9275
+ * prune → promote → compact machinery and returns an explicit outcome.
9276
+ *
9277
+ * A non-"not-needed" outcome ends the current run losslessly
9278
+ * (`agent_end.stopReason === "maintenance"`); the `agent_end` handler is the
9279
+ * single continuation owner that resumes the run with no synthetic prompt.
9280
+ * This method never self-continues, runs goal-runtime hooks, clears skill
9281
+ * state, or emits a user-facing pause.
9282
+ */
9283
+ async #runMidRunMaintenance(
9284
+ context: AgentContext,
9285
+ lifecycle: MidRunMaintenanceLifecycle,
9286
+ ): Promise<MidRunMaintenanceOutcome> {
9287
+ if (this.#isDisposed) return "aborted";
9288
+ const invocationController = new AbortController();
9289
+ this.#activeMidRunBarrierControllers.add(invocationController);
9290
+ const maintenanceSignal = AbortSignal.any([lifecycle.signal, invocationController.signal]);
9291
+ const isAborted = () => maintenanceSignal.aborted;
9292
+
9293
+ try {
9294
+ try {
9295
+ await lifecycle.awaitEventDrain(invocationController.signal);
9296
+ } catch {
9297
+ return isAborted() ? "aborted" : "failed";
9298
+ }
9299
+ if (isAborted()) return "aborted";
9300
+
9301
+ // In-place context-full maintenance only. "off" defers entirely; "handoff"
9302
+ // keeps its existing agent_end / pre-prompt boundaries (a mid-tool-loop
9303
+ // session swap would be far more disruptive than the overflow it avoids).
9304
+ const compactionSettings = this.settings.getGroup("compaction");
9305
+ if (!compactionSettings.enabled || compactionSettings.strategy !== "context-full") return "not-needed";
9306
+ const contextWindow = this.model?.contextWindow ?? 0;
9307
+ if (contextWindow <= 0) return "not-needed";
9308
+ // A compaction already in flight (overflow recovery, manual, idle) owns the
9309
+ // context; never double-compact underneath it.
9310
+ if (this.isCompacting) return "not-needed";
9311
+
9312
+ // Model maxTokens is a capability ceiling, not a per-turn reservation;
9313
+ // track actual context fullness (mirrors the agent_end / pre-prompt checks).
9314
+ const autoCompactionOutputReserveTokens = 0;
9315
+ const anchor = this.#findMidRunUsageAnchor(context.messages);
9316
+ let contextTokens = this.#estimateMidRunContextTokens(context.messages);
9317
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) {
9318
+ return "not-needed";
9319
+ }
9320
+ // Anti-loop (#1662): a given provider response anchors at most one
9321
+ // maintenance attempt. Until a NEW response re-anchors usage, repeat checks
9322
+ // on the same anchor are no-ops so a compaction that cannot shrink further
9323
+ // cannot wedge the loop into interrupt → resume → interrupt.
9324
+ const anchorSignature = anchor
9325
+ ? `${anchor.message.provider}/${anchor.message.model}#${anchor.message.timestamp}#${calculateContextTokens(anchor.message.usage as Usage)}`
9326
+ : undefined;
9327
+ if (anchorSignature) {
9328
+ if (anchorSignature === this.#lastMidRunMaintenanceAnchorSignature) return "not-needed";
9329
+ this.#lastMidRunMaintenanceAnchorSignature = anchorSignature;
9330
+ }
9331
+
9332
+ // The FIFO consumer barrier made every prior materialized message canonical.
9333
+ // Flush those synchronous branch appends before any history rewrite.
9334
+ if (isAborted()) return "aborted";
9335
+ await this.sessionManager.flush();
9336
+ if (isAborted()) return "aborted";
9337
+
9338
+ // 1) Prune stale tool outputs first — cheaper than compaction, may avert it,
9339
+ // and (like all history rewrites) resets the codex provider session /
9340
+ // prompt-cache epoch via #closeCodexProviderSessionsForHistoryRewrite.
9341
+ if (isAborted()) return "aborted";
9342
+ const pruneResult = await this.#pruneToolOutputs(maintenanceSignal);
9343
+ if (isAborted()) return "aborted";
9344
+ if (pruneResult) {
9345
+ contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
9346
+ }
9347
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) {
9348
+ return pruneResult && pruneResult.prunedCount > 0 ? "pruned" : "not-needed";
9349
+ }
9350
+
9351
+ // 2) Try context promotion (switch to a larger-window model) before compacting.
9352
+ const lastAssistant = this.#findLastAssistantMessage();
9353
+ if (lastAssistant && lastAssistant.stopReason !== "aborted" && lastAssistant.stopReason !== "error") {
9354
+ if (isAborted()) return "aborted";
9355
+ const promoted = await this.#tryContextPromotion(lastAssistant, maintenanceSignal);
9356
+ if (isAborted()) return "aborted";
9357
+ if (promoted) return "promoted";
9358
+ }
9359
+
9360
+ // 3) Compact via the existing auto-compaction machinery. continueAfterMaintenance
9361
+ // is false so it does NOT schedule its own (synthetic) continuation — the
9362
+ // agent_end("maintenance") handler owns resumption. The oversized-maintenance
9363
+ // signature guard and the previous_response_id / prompt-cache-epoch reset
9364
+ // (#applyCompactionPostAppend) are inherited from #runAutoCompaction.
9365
+ if (isAborted()) return "aborted";
9366
+ const compactionStatus = await this.#runAutoCompaction("threshold", false, false, {
9367
+ continueAfterMaintenance: false,
9368
+ deferHandoffMaintenance: false,
9369
+ signal: maintenanceSignal,
9370
+ });
9371
+ if (isAborted()) return "aborted";
9372
+ if (compactionStatus.kind === "compacted") return "compacted";
9373
+ if (compactionStatus.kind === "aborted") {
9374
+ return compactionStatus.source === "hook" ? "not-needed" : "aborted";
9375
+ }
9376
+ return "failed";
9377
+ } finally {
9378
+ this.#activeMidRunBarrierControllers.delete(invocationController);
9379
+ }
9380
+ }
9381
+
9382
+ /**
9383
+ * Mid-run context-token estimate for {@link #runMidRunMaintenance}.
9384
+ *
9385
+ * Anchors on the last non-error assistant's `usage.totalTokens` — at the top
9386
+ * of a tool-loop iteration that output is already durable context that will be
9387
+ * re-sent — plus the inflated script-aware delta (see {@link #estimateMessageCompactionDeltaTokens}) of every trailing tool-result /
9388
+ * steering message appended since. Operates on the loop's authoritative
9389
+ * context view (not `this.messages`) so it is independent of listener flush
9390
+ * timing.
9391
+ */
9392
+ #estimateMidRunContextTokens(messages: readonly AgentMessage[]): number {
9393
+ const anchor = this.#findMidRunUsageAnchor(messages);
9394
+ if (!anchor) {
9395
+ let estimated = 0;
9396
+ for (const message of messages) estimated += this.#estimateMessageCompactionDeltaTokens(message);
9397
+ return estimated;
9398
+ }
9399
+ let tokens = calculateContextTokens(anchor.message.usage as Usage);
9400
+ for (let i = anchor.index + 1; i < messages.length; i++) {
9401
+ tokens += this.#estimateMessageCompactionDeltaTokens(messages[i]);
9402
+ }
9403
+ return tokens;
9404
+ }
9405
+
9406
+ /**
9407
+ * Locate the anchor for {@link #estimateMidRunContextTokens}: the most recent
9408
+ * non-aborted/non-error assistant message that carries provider usage. Also
9409
+ * keys the anti-loop guard so each provider response drives at most one
9410
+ * maintenance attempt.
9411
+ */
9412
+ #findMidRunUsageAnchor(messages: readonly AgentMessage[]): { index: number; message: AssistantMessage } | undefined {
9413
+ for (let i = messages.length - 1; i >= 0; i--) {
9414
+ const msg = messages[i];
9415
+ if (msg.role !== "assistant") continue;
9416
+ const assistantMsg = msg as AssistantMessage;
9417
+ if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") continue;
9418
+ if (assistantMsg.usage) return { index: i, message: assistantMsg };
9419
+ }
9420
+ return undefined;
9421
+ }
9422
+
8034
9423
  async #checkEstimatedContextBeforePrompt(pendingMessages: readonly AgentMessage[] = []): Promise<void> {
8035
9424
  if (this.#prePromptContextCheckPromise) {
8036
9425
  await this.#prePromptContextCheckPromise;
@@ -8407,7 +9796,8 @@ export class AgentSession {
8407
9796
  * Attempt context promotion to a larger model.
8408
9797
  * Returns true if promotion succeeded (caller should retry without compacting).
8409
9798
  */
8410
- async #tryContextPromotion(assistantMessage: AssistantMessage): Promise<boolean> {
9799
+ async #tryContextPromotion(assistantMessage: AssistantMessage, signal?: AbortSignal): Promise<boolean> {
9800
+ if (signal?.aborted) return false;
8411
9801
  const promotionSettings = this.settings.getGroup("contextPromotion");
8412
9802
  if (!promotionSettings.enabled) return false;
8413
9803
  const currentModel = this.model;
@@ -8416,11 +9806,19 @@ export class AgentSession {
8416
9806
  return false;
8417
9807
  const contextWindow = currentModel.contextWindow ?? 0;
8418
9808
  if (contextWindow <= 0) return false;
8419
- const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow);
8420
- if (!targetModel) return false;
9809
+ const targetModel = await this.#resolveContextPromotionTarget(currentModel, contextWindow, signal);
9810
+ if (!targetModel || signal?.aborted) return false;
8421
9811
 
8422
9812
  try {
8423
- await this.setModelTemporary(targetModel);
9813
+ const scope = await this.setModelTemporary(targetModel, undefined, {
9814
+ cause: "temporary-operation",
9815
+ reason: "context-promotion",
9816
+ signal,
9817
+ });
9818
+ if (signal?.aborted) {
9819
+ if (scope) this.restoreTemporaryProviderSessionScope(scope);
9820
+ return false;
9821
+ }
8424
9822
  logger.debug("Context promotion switched model on overflow", {
8425
9823
  from: `${currentModel.provider}/${currentModel.id}`,
8426
9824
  to: `${targetModel.provider}/${targetModel.id}`,
@@ -8436,7 +9834,12 @@ export class AgentSession {
8436
9834
  }
8437
9835
  }
8438
9836
 
8439
- async #resolveContextPromotionTarget(currentModel: Model, contextWindow: number): Promise<Model | undefined> {
9837
+ async #resolveContextPromotionTarget(
9838
+ currentModel: Model,
9839
+ contextWindow: number,
9840
+ signal?: AbortSignal,
9841
+ ): Promise<Model | undefined> {
9842
+ if (signal?.aborted) return undefined;
8440
9843
  const availableModels = this.#modelRegistry.getAvailable();
8441
9844
  if (availableModels.length === 0) return undefined;
8442
9845
 
@@ -8445,18 +9848,37 @@ export class AgentSession {
8445
9848
  if (modelsAreEqual(candidate, currentModel)) return undefined;
8446
9849
  if (candidate.contextWindow <= contextWindow) return undefined;
8447
9850
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
8448
- if (!apiKey) return undefined;
9851
+ if (!apiKey || signal?.aborted) return undefined;
8449
9852
  return candidate;
8450
9853
  }
8451
9854
 
9855
+ #canonicalSelector(model: Model, selector?: string, thinkingLevel?: ThinkingLevel): string {
9856
+ const parsed = selector ? parseModelString(selector) : undefined;
9857
+ const base = parsed ? `${parsed.provider}/${parsed.id}` : `${model.provider}/${model.id}`;
9858
+ return thinkingLevel === undefined ? (selector ?? base) : `${base}:${String(thinkingLevel).toLowerCase()}`;
9859
+ }
9860
+
9861
+ #clearActiveRetryFallback(): void {
9862
+ this.#defaultFallbackController = undefined;
9863
+ this.#defaultFallbackExhaustedLastTurn = false;
9864
+ }
9865
+
8452
9866
  #setModelWithProviderSessionReset(model: Model | undefined): void {
9867
+ this.#defaultModelSelectionMutationRevision++;
8453
9868
  const currentModel = this.model;
8454
9869
  if (currentModel) {
8455
9870
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
8456
9871
  }
8457
9872
  this.agent.setModel(model);
9873
+ }
8458
9874
 
8459
- // Re-evaluate append-only context mode — provider or setting may have changed
9875
+ #setModelAuthoritatively(model: Model, cause: ModelChangeCause): void {
9876
+ // Only a non-temporary cause makes a model switch authoritative: it commits
9877
+ // any suspended provider-session scopes instead of restoring them later.
9878
+ if (cause !== "temporary-operation") this.#commitAllTemporaryProviderSessionScopes();
9879
+ const currentModel = this.model;
9880
+ if (currentModel) this.#closeProviderSessionsForModelSwitch(currentModel, model);
9881
+ this.agent.setModel(model);
8460
9882
  this.#syncAppendOnlyContext(model);
8461
9883
  }
8462
9884
 
@@ -8974,13 +10396,18 @@ export class AgentSession {
8974
10396
  reason: "overflow" | "threshold" | "idle",
8975
10397
  willRetry: boolean,
8976
10398
  deferred = false,
8977
- options?: { continueAfterMaintenance?: boolean; deferHandoffMaintenance?: boolean; force?: boolean },
8978
- ): Promise<void> {
10399
+ options?: {
10400
+ continueAfterMaintenance?: boolean;
10401
+ deferHandoffMaintenance?: boolean;
10402
+ force?: boolean;
10403
+ signal?: AbortSignal;
10404
+ },
10405
+ ): Promise<AutoCompactionTerminalStatus> {
8979
10406
  const compactionSettings = this.settings.getGroup("compaction");
8980
10407
  // `force` is the non-disableable emergency floor (F6): it bypasses the user's
8981
10408
  // disabled/off settings so a resource-floor breach still compacts before OOM.
8982
- if (!options?.force && compactionSettings.strategy === "off") return;
8983
- if (!options?.force && reason !== "idle" && !compactionSettings.enabled) return;
10409
+ if (!options?.force && compactionSettings.strategy === "off") return { kind: "skipped" };
10410
+ if (!options?.force && reason !== "idle" && !compactionSettings.enabled) return { kind: "skipped" };
8984
10411
  const generation = this.#promptGeneration;
8985
10412
  if (
8986
10413
  options?.deferHandoffMaintenance !== false &&
@@ -8997,27 +10424,44 @@ export class AgentSession {
8997
10424
  },
8998
10425
  { generation },
8999
10426
  );
9000
- return;
10427
+ return { kind: "skipped" };
9001
10428
  }
9002
10429
 
9003
10430
  let action: "context-full" | "handoff" =
9004
10431
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
9005
10432
  const continueAfterMaintenance = options?.continueAfterMaintenance !== false;
9006
- await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
9007
- // Abort any older auto-compaction before installing this run's controller.
10433
+ // Register the controller before the observable start event so an abort from
10434
+ // a local subscriber, extension, dispose, or caller signal owns this run.
9008
10435
  this.#autoCompactionAbortController?.abort();
9009
10436
  const autoCompactionAbortController = new AbortController();
9010
10437
  this.#autoCompactionAbortController = autoCompactionAbortController;
9011
- const autoCompactionSignal = autoCompactionAbortController.signal;
10438
+ const autoCompactionSignal = options?.signal
10439
+ ? AbortSignal.any([autoCompactionAbortController.signal, options.signal])
10440
+ : autoCompactionAbortController.signal;
9012
10441
  let maintenanceAttemptSignature: string | undefined;
10442
+ const emitAborted = async (): Promise<AutoCompactionTerminalStatus> => {
10443
+ await this.#emitSessionEvent({
10444
+ type: "auto_compaction_end",
10445
+ action,
10446
+ result: undefined,
10447
+ aborted: true,
10448
+ willRetry: false,
10449
+ });
10450
+ return { kind: "aborted", source: "signal" };
10451
+ };
9013
10452
 
9014
10453
  try {
10454
+ if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
10455
+ await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
10456
+ if (autoCompactionSignal.aborted) return await emitAborted();
10457
+
9015
10458
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
9016
10459
  const handoffFocus = AUTO_HANDOFF_THRESHOLD_FOCUS;
9017
10460
  const handoffResult = await this.handoff(handoffFocus, {
9018
10461
  autoTriggered: true,
9019
- signal: this.#autoCompactionAbortController.signal,
10462
+ signal: autoCompactionSignal,
9020
10463
  });
10464
+
9021
10465
  if (!handoffResult) {
9022
10466
  const aborted = autoCompactionSignal.aborted;
9023
10467
  if (aborted) {
@@ -9028,13 +10472,15 @@ export class AgentSession {
9028
10472
  aborted: true,
9029
10473
  willRetry: false,
9030
10474
  });
9031
- return;
10475
+ return { kind: "aborted", source: "signal" };
9032
10476
  }
9033
10477
  logger.warn("Auto-handoff returned no document; falling back to context-full maintenance", {
9034
10478
  reason,
9035
10479
  });
9036
10480
  action = "context-full";
9037
10481
  }
10482
+ if (autoCompactionSignal.aborted) return await emitAborted();
10483
+
9038
10484
  if (handoffResult) {
9039
10485
  await this.#emitSessionEvent({
9040
10486
  type: "auto_compaction_end",
@@ -9043,15 +10489,12 @@ export class AgentSession {
9043
10489
  aborted: false,
9044
10490
  willRetry: false,
9045
10491
  });
9046
- if (
9047
- continueAfterMaintenance &&
9048
- !autoCompactionSignal.aborted &&
9049
- reason !== "idle" &&
9050
- compactionSettings.autoContinue !== false
9051
- ) {
10492
+ if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
10493
+ if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
9052
10494
  this.#scheduleAutoContinuePrompt(generation);
9053
10495
  }
9054
- return;
10496
+ if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
10497
+ return { kind: "compacted" };
9055
10498
  }
9056
10499
  }
9057
10500
 
@@ -9064,7 +10507,7 @@ export class AgentSession {
9064
10507
  willRetry: false,
9065
10508
  skipped: true,
9066
10509
  });
9067
- return;
10510
+ return { kind: "skipped" };
9068
10511
  }
9069
10512
 
9070
10513
  const availableModels = this.#modelRegistry.getAvailable();
@@ -9077,9 +10520,11 @@ export class AgentSession {
9077
10520
  willRetry: false,
9078
10521
  skipped: true,
9079
10522
  });
9080
- return;
10523
+ return { kind: "skipped" };
9081
10524
  }
9082
10525
 
10526
+ if (autoCompactionSignal.aborted) return await emitAborted();
10527
+
9083
10528
  const pathEntries = this.sessionManager.getBranch();
9084
10529
 
9085
10530
  // Emergency/overflow-recovery compaction is conservative: apply the token
@@ -9089,6 +10534,8 @@ export class AgentSession {
9089
10534
  const preparation = prepareCompaction(pathEntries, compactionSettings, {
9090
10535
  tokenCorrectionRatio: overflowRatio !== undefined ? Math.max(1, overflowRatio) : undefined,
9091
10536
  });
10537
+ if (autoCompactionSignal.aborted) return await emitAborted();
10538
+
9092
10539
  if (!preparation) {
9093
10540
  const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
9094
10541
  await this.#emitSessionEvent({
@@ -9106,6 +10553,8 @@ export class AgentSession {
9106
10553
  this.#scheduleAgentContinue({
9107
10554
  delayMs: 100,
9108
10555
  generation,
10556
+ suppressPredecessorAgentEnd: true,
10557
+
9109
10558
  shouldContinue: () => this.agent.hasQueuedMessages(),
9110
10559
  onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
9111
10560
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
@@ -9113,7 +10562,7 @@ export class AgentSession {
9113
10562
  } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
9114
10563
  this.#scheduleAutoContinuePrompt(generation);
9115
10564
  }
9116
- return;
10565
+ return { kind: "skipped" };
9117
10566
  }
9118
10567
 
9119
10568
  let hookCompaction: CompactionResult | undefined;
@@ -9128,6 +10577,7 @@ export class AgentSession {
9128
10577
  customInstructions: undefined,
9129
10578
  signal: autoCompactionSignal,
9130
10579
  })) as SessionBeforeCompactResult | undefined;
10580
+ if (autoCompactionSignal.aborted) return await emitAborted();
9131
10581
 
9132
10582
  if (hookResult?.cancel) {
9133
10583
  await this.#emitSessionEvent({
@@ -9137,7 +10587,7 @@ export class AgentSession {
9137
10587
  aborted: true,
9138
10588
  willRetry: false,
9139
10589
  });
9140
- return;
10590
+ return { kind: "aborted", source: "hook" };
9141
10591
  }
9142
10592
 
9143
10593
  if (hookResult?.compaction) {
@@ -9147,6 +10597,7 @@ export class AgentSession {
9147
10597
  }
9148
10598
 
9149
10599
  const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
10600
+ if (autoCompactionSignal.aborted) return await emitAborted();
9150
10601
 
9151
10602
  let summary: string;
9152
10603
  let shortSummary: string | undefined;
@@ -9181,7 +10632,7 @@ export class AgentSession {
9181
10632
  errorMessage:
9182
10633
  "Auto-compaction skipped: previous unchanged maintenance request exceeded the model context window; change or reduce the conversation before retrying maintenance.",
9183
10634
  });
9184
- return;
10635
+ return { kind: "skipped" };
9185
10636
  }
9186
10637
  const retrySettings = this.settings.getGroup("retry");
9187
10638
  const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
@@ -9195,6 +10646,8 @@ export class AgentSession {
9195
10646
  let attempt = 0;
9196
10647
  while (true) {
9197
10648
  try {
10649
+ if (autoCompactionSignal.aborted) return await emitAborted();
10650
+
9198
10651
  compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
9199
10652
  ...this.#maintenanceProviderTransport(),
9200
10653
  promptOverride: compactionPrep.hookPrompt,
@@ -9293,7 +10746,7 @@ export class AgentSession {
9293
10746
  aborted: true,
9294
10747
  willRetry: false,
9295
10748
  });
9296
- return;
10749
+ return { kind: "aborted", source: "signal" };
9297
10750
  }
9298
10751
 
9299
10752
  const compactionEntryId = this.sessionManager.appendCompaction(
@@ -9306,6 +10759,7 @@ export class AgentSession {
9306
10759
  preserveData,
9307
10760
  );
9308
10761
  await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
10762
+ if (autoCompactionSignal.aborted) return await emitAborted();
9309
10763
 
9310
10764
  const result: CompactionResult = {
9311
10765
  summary,
@@ -9326,6 +10780,7 @@ export class AgentSession {
9326
10780
  willRetry: willRetry && !continuationSkipReason,
9327
10781
  continuationSkipReason,
9328
10782
  });
10783
+ if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
9329
10784
 
9330
10785
  if (willRetry) {
9331
10786
  this.#scheduleOverflowRetryContinuation(generation);
@@ -9335,6 +10790,7 @@ export class AgentSession {
9335
10790
  this.#scheduleAgentContinue({
9336
10791
  delayMs: 100,
9337
10792
  generation,
10793
+ suppressPredecessorAgentEnd: true,
9338
10794
  shouldContinue: () => this.agent.hasQueuedMessages(),
9339
10795
  onSkip: reason => this.#logCompactionContinuationSkipped("queued_continue", reason),
9340
10796
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
@@ -9342,6 +10798,7 @@ export class AgentSession {
9342
10798
  } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
9343
10799
  this.#scheduleAutoContinuePrompt(generation);
9344
10800
  }
10801
+ return { kind: "compacted" };
9345
10802
  } catch (error) {
9346
10803
  if (autoCompactionSignal.aborted) {
9347
10804
  await this.#emitSessionEvent({
@@ -9351,7 +10808,7 @@ export class AgentSession {
9351
10808
  aborted: true,
9352
10809
  willRetry: false,
9353
10810
  });
9354
- return;
10811
+ return { kind: "aborted", source: "signal" };
9355
10812
  }
9356
10813
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
9357
10814
  if (maintenanceAttemptSignature && this.#isOversizedMaintenanceError(errorMessage)) {
@@ -9368,6 +10825,7 @@ export class AgentSession {
9368
10825
  ? `Context overflow recovery failed: ${errorMessage}`
9369
10826
  : `Auto-compaction failed: ${errorMessage}`,
9370
10827
  });
10828
+ return { kind: "failed" };
9371
10829
  } finally {
9372
10830
  if (this.#autoCompactionAbortController === autoCompactionAbortController) {
9373
10831
  this.#autoCompactionAbortController = undefined;
@@ -9400,20 +10858,30 @@ export class AgentSession {
9400
10858
  * (auth/400/not-found) surface immediately; usage-limit, transient, and
9401
10859
  * unknown/no-code errors are retryable.
9402
10860
  */
10861
+
9403
10862
  #isRetryableError(message: AssistantMessage): boolean {
9404
- const classification = this.#classifyErrorForRetry(message);
9405
- if (classification === "local_unavailable") {
9406
- return this.#hasRetryFallbackCandidate({
9407
- currentSelector: this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined,
9408
- requireNonLocal: true,
9409
- });
10863
+ if (message.errorMessage?.startsWith("Model fallback chain exhausted;")) return false;
10864
+ const managedFallback = this.#defaultFallbackChain().chain.entries.length > 1;
10865
+ if (!managedFallback) {
10866
+ const classification = this.#classifyErrorForRetry(message);
10867
+ return (
10868
+ classification === "usage_limit" ||
10869
+ classification === "transient" ||
10870
+ classification === "unknown" ||
10871
+ classification === "first_event_timeout"
10872
+ );
9410
10873
  }
9411
- return (
9412
- classification === "usage_limit" ||
9413
- classification === "transient" ||
9414
- classification === "unknown" ||
9415
- classification === "first_event_timeout"
9416
- );
10874
+ const trigger = classifyFallbackTrigger({ message: message.errorMessage, status: message.errorStatus });
10875
+ if (
10876
+ trigger.class === "rate_limit" ||
10877
+ trigger.class === "quota" ||
10878
+ trigger.class === "auth" ||
10879
+ trigger.class === "server"
10880
+ ) {
10881
+ return true;
10882
+ }
10883
+ const classification = this.#classifyErrorForRetry(message);
10884
+ return classification === "transient" || classification === "unknown" || classification === "first_event_timeout";
9417
10885
  }
9418
10886
 
9419
10887
  #isTransientErrorMessage(errorMessage: string): boolean {
@@ -9548,232 +11016,6 @@ export class AgentSession {
9548
11016
  return "unknown";
9549
11017
  }
9550
11018
 
9551
- #getRetryFallbackChains(): RetryFallbackChains {
9552
- const configuredChains = this.settings.get("retry.fallbackChains");
9553
- if (!configuredChains || typeof configuredChains !== "object") return {};
9554
- return configuredChains as RetryFallbackChains;
9555
- }
9556
-
9557
- #validateRetryFallbackChains(): void {
9558
- const configuredChains = this.settings.get("retry.fallbackChains");
9559
- if (configuredChains === undefined) return;
9560
- if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
9561
- const msg = "retry.fallbackChains must be a mapping of role names to selector arrays.";
9562
- logger.warn(msg);
9563
- this.configWarnings.push(msg);
9564
- return;
9565
- }
9566
-
9567
- for (const [role, chain] of Object.entries(configuredChains)) {
9568
- if (!Array.isArray(chain)) {
9569
- const msg = `Fallback chain for role '${role}' must be an array of selector strings.`;
9570
- logger.warn(msg);
9571
- this.configWarnings.push(msg);
9572
- continue;
9573
- }
9574
- for (const selectorStr of chain) {
9575
- if (typeof selectorStr !== "string") {
9576
- const msg = `Fallback chain for role '${role}' contains a non-string selector.`;
9577
- logger.warn(msg);
9578
- this.configWarnings.push(msg);
9579
- continue;
9580
- }
9581
- const parsed = parseRetryFallbackSelector(selectorStr);
9582
- if (!parsed) {
9583
- const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
9584
- logger.warn(msg);
9585
- this.configWarnings.push(msg);
9586
- continue;
9587
- }
9588
- const exists = this.#modelRegistry.find(parsed.provider, parsed.id);
9589
- if (!exists) {
9590
- const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`;
9591
- logger.warn(msg);
9592
- this.configWarnings.push(msg);
9593
- }
9594
- }
9595
- }
9596
- }
9597
-
9598
- #getRetryFallbackRevertPolicy(): RetryFallbackRevertPolicy {
9599
- return this.settings.get("retry.fallbackRevertPolicy") === "never" ? "never" : "cooldown-expiry";
9600
- }
9601
-
9602
- #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
9603
- const configuredSelector = this.settings.getModelRole(role);
9604
- return configuredSelector ? parseRetryFallbackSelector(configuredSelector) : undefined;
9605
- }
9606
-
9607
- #clearActiveRetryFallback(): void {
9608
- this.#activeRetryFallback = undefined;
9609
- }
9610
-
9611
- #isRetryFallbackSelectorSuppressed(selector: RetryFallbackSelector): boolean {
9612
- return this.#modelRegistry.isSelectorSuppressed(selector.raw);
9613
- }
9614
-
9615
- #noteRetryFallbackCooldown(currentSelector: string, retryAfterMs: number | undefined, errorMessage: string): void {
9616
- let cooldownMs = retryAfterMs;
9617
- if (!cooldownMs || cooldownMs <= 0) {
9618
- const reason = parseRateLimitReason(errorMessage);
9619
- cooldownMs = reason === "UNKNOWN" ? 5 * 60 * 1000 : calculateRateLimitBackoffMs(reason);
9620
- }
9621
- this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs);
9622
- }
9623
-
9624
- #resolveRetryFallbackRole(currentSelector: string): string | undefined {
9625
- const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9626
- if (!parsedCurrent) return undefined;
9627
- const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
9628
- for (const role of Object.keys(this.#getRetryFallbackChains())) {
9629
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9630
- if (!primarySelector) continue;
9631
- if (primarySelector.raw === currentSelector) return role;
9632
- if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role;
9633
- }
9634
- return undefined;
9635
- }
9636
-
9637
- #getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] {
9638
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9639
- if (!primarySelector) return [];
9640
- const chain = [primarySelector];
9641
- const seen = new Set<string>([primarySelector.raw]);
9642
- for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
9643
- const parsed = parseRetryFallbackSelector(selector);
9644
- if (!parsed || seen.has(parsed.raw)) continue;
9645
- seen.add(parsed.raw);
9646
- chain.push(parsed);
9647
- }
9648
- return chain;
9649
- }
9650
-
9651
- #hasRetryFallbackCandidate(options: { currentSelector: string | undefined; requireNonLocal?: boolean }): boolean {
9652
- if (!options.currentSelector) return false;
9653
- const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(options.currentSelector);
9654
- if (!role) return false;
9655
- for (const selector of this.#findRetryFallbackCandidates(role, options.currentSelector)) {
9656
- if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
9657
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9658
- if (!candidate) continue;
9659
- if (options.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
9660
- return true;
9661
- }
9662
- return false;
9663
- }
9664
-
9665
- #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
9666
- const chain = this.#getRetryFallbackEffectiveChain(role);
9667
- if (chain.length <= 1) return [];
9668
- const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9669
- const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
9670
- const exactIndex = chain.findIndex(selector => selector.raw === currentSelector);
9671
- if (exactIndex >= 0) return chain.slice(exactIndex + 1);
9672
- const baseIndex = currentBaseSelector
9673
- ? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector)
9674
- : -1;
9675
- if (baseIndex >= 0) return chain.slice(baseIndex + 1);
9676
- return chain.slice(1);
9677
- }
9678
-
9679
- async #applyRetryFallbackCandidate(
9680
- role: string,
9681
- selector: RetryFallbackSelector,
9682
- currentSelector: string,
9683
- ): Promise<void> {
9684
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9685
- if (!candidate) {
9686
- throw new Error(`Retry fallback model not found: ${selector.raw}`);
9687
- }
9688
- const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
9689
- if (!apiKey) {
9690
- throw new Error(`No API key for retry fallback ${selector.raw}`);
9691
- }
9692
-
9693
- const currentThinkingLevel = this.thinkingLevel;
9694
- const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
9695
-
9696
- this.#setModelWithProviderSessionReset(candidate);
9697
- this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, "temporary");
9698
- this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
9699
- this.setThinkingLevel(nextThinkingLevel);
9700
- if (!this.#activeRetryFallback) {
9701
- this.#activeRetryFallback = {
9702
- role,
9703
- originalSelector: currentSelector,
9704
- originalThinkingLevel: currentThinkingLevel,
9705
- lastAppliedFallbackThinkingLevel: nextThinkingLevel,
9706
- };
9707
- } else {
9708
- this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
9709
- }
9710
- await this.#emitSessionEvent({
9711
- type: "retry_fallback_applied",
9712
- from: currentSelector,
9713
- to: selector.raw,
9714
- role,
9715
- });
9716
- }
9717
-
9718
- async #tryRetryModelFallback(currentSelector: string, options?: { requireNonLocal?: boolean }): Promise<boolean> {
9719
- const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
9720
- if (!role) return false;
9721
-
9722
- for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) {
9723
- if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
9724
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9725
- if (!candidate) continue;
9726
- if (options?.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
9727
- const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
9728
- if (!apiKey) continue;
9729
- await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
9730
- return true;
9731
- }
9732
-
9733
- return false;
9734
- }
9735
-
9736
- async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
9737
- if (!this.#activeRetryFallback) return;
9738
- if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
9739
-
9740
- const {
9741
- originalSelector: originalSelectorRaw,
9742
- originalThinkingLevel,
9743
- lastAppliedFallbackThinkingLevel,
9744
- } = this.#activeRetryFallback;
9745
- const originalSelector = parseRetryFallbackSelector(originalSelectorRaw);
9746
- if (!originalSelector) {
9747
- this.#clearActiveRetryFallback();
9748
- return;
9749
- }
9750
-
9751
- const currentModel = this.model;
9752
- if (!currentModel) return;
9753
- const currentSelector = formatRetryFallbackSelector(currentModel, this.thinkingLevel);
9754
- if (currentSelector === originalSelector.raw) {
9755
- if (!this.#isRetryFallbackSelectorSuppressed(originalSelector)) {
9756
- this.#clearActiveRetryFallback();
9757
- }
9758
- return;
9759
- }
9760
- if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
9761
-
9762
- const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
9763
- if (!primaryModel) return;
9764
- const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
9765
- if (!apiKey) return;
9766
-
9767
- const currentThinkingLevel = this.thinkingLevel;
9768
- const thinkingToApply =
9769
- currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
9770
- this.#setModelWithProviderSessionReset(primaryModel);
9771
- this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, "temporary");
9772
- this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
9773
- this.setThinkingLevel(thinkingToApply);
9774
- this.#clearActiveRetryFallback();
9775
- }
9776
-
9777
11019
  #parseRetryAfterMsFromError(errorMessage: string): number | undefined {
9778
11020
  const now = Date.now();
9779
11021
  const retryAfterMsMatch = /retry-after-ms\s*[:=]\s*(\d+)/i.exec(errorMessage);
@@ -9821,149 +11063,394 @@ export class AgentSession {
9821
11063
  }
9822
11064
  }
9823
11065
 
9824
- // Smart Fallback if no exact headers found
11066
+ // No provider retry hint was available.
9825
11067
  return undefined;
9826
11068
  }
9827
11069
 
11070
+ #managedFallbackPromptOptions(): {
11071
+ fallbackManaged?: boolean;
11072
+ nextFallbackAttempt?: (model: Model) => ReturnType<typeof beginAttempt>;
11073
+ onManagedAttemptAccepted?: () => void;
11074
+ onManagedAttemptOutcome?: (
11075
+ outcome: ManagedAttemptOutcome,
11076
+ ) => ManagedAttemptDecision | Promise<ManagedAttemptDecision>;
11077
+ } {
11078
+ const controller = this.#defaultFallbackChain();
11079
+ if (controller.chain.entries.length < 2) return {};
11080
+ return {
11081
+ fallbackManaged: true,
11082
+ nextFallbackAttempt: model => {
11083
+ controller.onAttemptStarted();
11084
+ return beginAttempt(formatModelString(model), String(++this.#fallbackInvocationId));
11085
+ },
11086
+ onManagedAttemptAccepted: () => controller.resetAttemptBudget(),
11087
+ onManagedAttemptOutcome: outcome => this.#handleManagedAttemptOutcome(outcome),
11088
+ };
11089
+ }
11090
+
11091
+ async #resetDefaultFallbackForNewTurn(): Promise<void> {
11092
+ if (!this.#defaultFallbackExhaustedLastTurn) return;
11093
+ const controller = this.#defaultFallbackChain();
11094
+ this.#defaultFallbackExhaustedLastTurn = false;
11095
+ controller.resetForNewTurn();
11096
+ if (controller.chain.entries.length > 1) {
11097
+ await this.#advanceDefaultFallback(controller, "new_turn", 0);
11098
+ }
11099
+ }
11100
+
11101
+ async #ensureDefaultFallbackResolution(): Promise<void> {
11102
+ const controller = this.#defaultFallbackChain();
11103
+ if (controller.chain.entries.length < 2) return;
11104
+ const resolutionStart = controller.activeIndex;
11105
+ const resolution = await resolveModelChainWithAuth(
11106
+ controller.chain.entries.slice(resolutionStart),
11107
+ this.#modelRegistry,
11108
+ this.settings,
11109
+ this.sessionId,
11110
+ { managedFallback: true },
11111
+ );
11112
+ const activeIndex = resolutionStart + resolution.activeIndex;
11113
+ if (activeIndex > resolutionStart) {
11114
+ this.seedDefaultFallbackResolution(activeIndex, [...controller.skips, ...resolution.skips]);
11115
+ } else {
11116
+ controller.seedResolution(activeIndex, [...controller.skips, ...resolution.skips]);
11117
+ }
11118
+ if (!resolution.model) throw new Error(this.#fallbackExhaustionError(controller));
11119
+ this.#setModelAuthoritatively(resolution.model, "restore");
11120
+ this.setThinkingLevel(resolution.explicitThinkingLevel ? resolution.thinkingLevel : this.thinkingLevel);
11121
+ }
11122
+
9828
11123
  /**
9829
- * Handle retryable errors with exponential backoff.
9830
- * @returns true if retry was initiated, false if max retries exceeded or disabled
11124
+ * Materialize the default controller from the persisted configured-chain
11125
+ * metadata. Consumers seed only resolution state; role/origin/identity stay
11126
+ * intrinsic to controller construction and are never inferred at runtime.
9831
11127
  */
9832
- async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
9833
- const retrySettings = this.settings.getGroup("retry");
9834
- if (!retrySettings.enabled) return false;
9835
- const retryClassification = this.#classifyErrorForRetry(message);
9836
- const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
9837
- const localUnavailable = retryClassification === "local_unavailable";
11128
+ #defaultFallbackChain(): FallbackChainController {
11129
+ const configuredChain = this.sessionManager.buildSessionContext().configuredModelChains.default;
11130
+ const settingsEntries = normalizeModelSelectorValue(
11131
+ this.settings.getModelRole("default") ?? (this.model ? formatModelString(this.model) : undefined),
11132
+ );
11133
+ const materializeSettingsChain =
11134
+ configuredChain?.origin === "legacy_session" &&
11135
+ configuredChain.entries.length === 1 &&
11136
+ settingsEntries.length > 1;
11137
+ if (materializeSettingsChain) {
11138
+ this.setConfiguredModelChain("default", settingsEntries, "modelRoles");
11139
+ }
11140
+ const chain: ConfiguredFallbackChain = materializeSettingsChain
11141
+ ? { role: "default", entries: settingsEntries, origin: "modelRoles", explicitHead: true }
11142
+ : configuredChain
11143
+ ? { ...configuredChain, entries: [...configuredChain.entries] }
11144
+ : { role: "default", entries: settingsEntries, origin: "session", explicitHead: true };
11145
+ const existing = this.#defaultFallbackController;
11146
+ if (
11147
+ existing &&
11148
+ (existing.chain.origin === "runtime" || existing.chain.entries.join("\u0000") === chain.entries.join("\u0000"))
11149
+ ) {
11150
+ return existing;
11151
+ }
11152
+ this.#defaultFallbackController = new FallbackChainController(chain, this.settings.get("fallback.maxAttempts"));
11153
+ return this.#defaultFallbackController;
11154
+ }
9838
11155
 
9839
- const generation = this.#promptGeneration;
9840
- this.#retryAttempt++;
11156
+ async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
11157
+ if (outcome.type === "run_terminal") {
11158
+ this.#defaultFallbackChain().resetAttemptBudget();
11159
+ return { type: "terminal", terminal: { stopReason: outcome.reason } };
11160
+ }
11161
+ return this.#handleRetryableError(
11162
+ outcome.failure.message,
11163
+ true,
11164
+ outcome.failure.transportFailure,
11165
+ ) as Promise<ManagedAttemptDecision>;
11166
+ }
9841
11167
 
9842
- // Create retry promise on first attempt so waitForRetry() can await it
9843
- // Ensure only one promise exists (avoid orphaned promises from concurrent calls)
9844
- if (!this.#retryPromise) {
9845
- const { promise, resolve } = Promise.withResolvers<void>();
9846
- this.#retryPromise = promise;
9847
- this.#retryResolve = resolve;
11168
+ #managedFallbackExhaustionMessage(discarded: AssistantMessage, errorMessage: string): AssistantMessage {
11169
+ return {
11170
+ ...discarded,
11171
+ content: [{ type: "text", text: "" }],
11172
+ stopReason: "error",
11173
+ errorMessage,
11174
+ timestamp: Date.now(),
11175
+ };
11176
+ }
11177
+
11178
+ #managedFallbackExhaustionDecision(discarded: AssistantMessage, errorMessage: string): ManagedAttemptDecision {
11179
+ return {
11180
+ type: "terminal",
11181
+ terminal: {
11182
+ stopReason: "exhausted",
11183
+ messages: [this.#managedFallbackExhaustionMessage(discarded, errorMessage)],
11184
+ },
11185
+ };
11186
+ }
11187
+
11188
+ #fallbackTriggerFor(
11189
+ message: AssistantMessage,
11190
+ allowLegacyUsageLimit: boolean,
11191
+ transportFailure?: TransportFailureFacts,
11192
+ ): { class: FallbackTriggerClass; retryAfterMs?: number } | undefined {
11193
+ const transport = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
11194
+ if (transport.class !== "other") return transport;
11195
+ const classification = this.#classifyErrorForRetry(message);
11196
+ if (allowLegacyUsageLimit && classification === "usage_limit") {
11197
+ return { class: "quota" };
11198
+ }
11199
+ if (classification === "transient" || classification === "first_event_timeout" || classification === "unknown") {
11200
+ return { class: "server", retryAfterMs: transport.retryAfterMs };
9848
11201
  }
11202
+ return undefined;
11203
+ }
9849
11204
 
9850
- if (!unboundedClass && this.#retryAttempt > retrySettings.maxRetries) {
9851
- // Max retries exceeded, emit final failure and reset
9852
- await this.#emitSessionEvent({
9853
- type: "auto_retry_end",
9854
- success: false,
9855
- attempt: this.#retryAttempt - 1,
9856
- finalError: message.errorMessage,
11205
+ async #advanceDefaultFallback(
11206
+ controller: FallbackChainController,
11207
+ reason: string,
11208
+ attemptsUsed: number,
11209
+ ): Promise<boolean> {
11210
+ while (!controller.isExhausted()) {
11211
+ const selector = controller.currentSelector();
11212
+ if (!selector) return false;
11213
+ const resolved = resolveModelRoleValue(selector, this.#modelRegistry.getAvailable(), {
11214
+ settings: this.settings,
11215
+ matchPreferences: { usageOrder: this.settings.getStorage()?.getModelUsageOrder() },
11216
+ modelRegistry: this.#modelRegistry,
9857
11217
  });
9858
- this.#retryAttempt = 0;
9859
- this.#resolveRetry(); // Resolve so waitForRetry() completes
11218
+ if (!resolved.model) {
11219
+ controller.onResolutionSkip("unknown_model");
11220
+ continue;
11221
+ }
11222
+ const managedCursorUnavailable = managedCursorFallbackUnavailableReason(resolved.model, selector);
11223
+ if (managedCursorUnavailable) {
11224
+ controller.onResolutionSkip(managedCursorUnavailable);
11225
+ continue;
11226
+ }
11227
+ const key = await this.#modelRegistry.getApiKey(resolved.model, this.sessionId);
11228
+ if (!key) {
11229
+ controller.onResolutionSkip("unauthenticated");
11230
+ continue;
11231
+ }
11232
+ const from =
11233
+ controller.tried.at(-1)?.selector ?? controller.chain.entries[controller.activeIndex - 1] ?? selector;
11234
+ const to = selector;
11235
+ this.#setModelAuthoritatively(resolved.model, "fallback-switch");
11236
+ this.setThinkingLevel(resolved.explicitThinkingLevel ? resolved.thinkingLevel : this.thinkingLevel);
11237
+ if (from !== to) {
11238
+ this.#emit({
11239
+ type: "model_fallback_switched",
11240
+ eventId: crypto.randomUUID(),
11241
+ from,
11242
+ to,
11243
+ reason,
11244
+ role:
11245
+ controller.chain.origin === "subagent"
11246
+ ? (controller.chain.identity ?? controller.chain.role)
11247
+ : controller.chain.role,
11248
+ scope: controller.chain.origin === "subagent" ? "subagent-call" : "session",
11249
+ activeIndex: controller.activeIndex,
11250
+ chainLength: controller.chain.entries.length,
11251
+ attemptsUsed,
11252
+ });
11253
+ }
11254
+ return true;
11255
+ }
11256
+ return false;
11257
+ }
11258
+
11259
+ #emitResolutionFallbackSwitch(controller: FallbackChainController): void {
11260
+ if (controller.activeIndex <= 0) return;
11261
+ const to = controller.currentSelector();
11262
+ const from = controller.chain.entries[controller.activeIndex - 1];
11263
+ if (!from || !to || from === to) return;
11264
+ const event: Extract<AgentSessionEvent, { type: "model_fallback_switched" }> = {
11265
+ type: "model_fallback_switched",
11266
+ eventId: crypto.randomUUID(),
11267
+ from,
11268
+ to,
11269
+ reason: "resolution",
11270
+ role:
11271
+ controller.chain.origin === "subagent"
11272
+ ? (controller.chain.identity ?? controller.chain.role)
11273
+ : controller.chain.role,
11274
+ scope: controller.chain.origin === "subagent" ? "subagent-call" : "session",
11275
+ activeIndex: controller.activeIndex,
11276
+ chainLength: controller.chain.entries.length,
11277
+ attemptsUsed: 0,
11278
+ };
11279
+ if (this.#eventListeners.length === 0) {
11280
+ this.#pendingFallbackSwitches.push(event);
11281
+ } else {
11282
+ this.#emit(event);
11283
+ }
11284
+ }
11285
+
11286
+ #fallbackExhaustionError(controller: FallbackChainController): string {
11287
+ const tried = controller.tried.map(failure => `${failure.selector} (${failure.reason})`).join(", ") || "none";
11288
+ const skipped = controller.skips.map(skip => `${skip.selector} (${skip.reason})`).join(", ") || "none";
11289
+ return `Model fallback chain exhausted; models tried: ${tried}; models skipped: ${skipped}`;
11290
+ }
11291
+
11292
+ async #markFailedManagedCredential(trigger: { class: FallbackTriggerClass; retryAfterMs?: number }): Promise<void> {
11293
+ if (!this.model || (trigger.class !== "auth" && trigger.class !== "quota" && trigger.class !== "rate_limit"))
11294
+ return;
11295
+
11296
+ const authStorage = this.#modelRegistry.authStorage;
11297
+ if (trigger.class === "auth") {
11298
+ const apiKey = await this.#modelRegistry.getApiKey(this.model, this.sessionId);
11299
+ if (isAuthenticated(apiKey)) {
11300
+ await authStorage.invalidateCredentialMatching(this.model.provider, apiKey, { sessionId: this.sessionId });
11301
+ }
11302
+ return;
11303
+ }
11304
+
11305
+ await authStorage.markUsageLimitReached(this.model.provider, this.sessionId, {
11306
+ retryAfterMs: trigger.retryAfterMs,
11307
+ });
11308
+ }
11309
+
11310
+ /** Handle retryable errors with exponential backoff. */
11311
+ async #handleRetryableError(
11312
+ message: AssistantMessage,
11313
+ managedOutcome = false,
11314
+ transportFailure?: TransportFailureFacts,
11315
+ ): Promise<boolean | ManagedAttemptDecision> {
11316
+ const controller = this.#defaultFallbackChain();
11317
+ const managedFallback = controller.chain.entries.length > 1;
11318
+ const trigger = this.#fallbackTriggerFor(message, !managedFallback, transportFailure);
11319
+ if (!trigger) {
11320
+ return managedOutcome
11321
+ ? this.#managedFallbackExhaustionDecision(message, message.errorMessage || "Model fallback attempt failed")
11322
+ : false;
11323
+ }
11324
+ const retrySettings = this.settings.getGroup("retry");
11325
+ const legacyRetryConfigured =
11326
+ this.settings.has("retry.enabled") ||
11327
+ this.settings.has("retry.maxRetries") ||
11328
+ this.settings.has("retry.baseDelayMs") ||
11329
+ this.settings.has("retry.maxDelayMs");
11330
+ // A bare default retry configuration must preserve the historical
11331
+ // fail-closed behavior for generic provider errors. Explicit retry
11332
+ // settings opt into the resilient legacy retry path.
11333
+ if (!managedFallback && (!retrySettings.enabled || !legacyRetryConfigured)) return false;
11334
+ const legacyClassification = managedFallback ? undefined : this.#classifyErrorForRetry(message);
11335
+ const legacyUnbounded = legacyClassification === "transient" || legacyClassification === "unknown";
11336
+ const attemptsUsed = managedFallback ? controller.attemptsUsed || 1 : this.#retryAttempt + 1;
11337
+ const outcome = managedFallback
11338
+ ? controller.onAttemptFailure(trigger.class, message.errorMessage || "Unknown error")
11339
+ : !legacyUnbounded && attemptsUsed > retrySettings.maxRetries
11340
+ ? "exhausted"
11341
+ : "retry";
11342
+ if (outcome === "exhausted") {
11343
+ if (managedFallback) {
11344
+ const errorMessage = this.#fallbackExhaustionError(controller);
11345
+ this.emitNotice("error", errorMessage, "fallback");
11346
+ this.#defaultFallbackExhaustedLastTurn = true;
11347
+ controller.resetSticky();
11348
+ return managedOutcome ? this.#managedFallbackExhaustionDecision(message, errorMessage) : false;
11349
+ }
9860
11350
  return false;
9861
11351
  }
9862
11352
 
11353
+ const generation = this.#promptGeneration;
9863
11354
  const errorMessage = message.errorMessage || "Unknown error";
9864
- const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
9865
- let delayMs = retrySettings.baseDelayMs * 2 ** (this.#retryAttempt - 1);
9866
- let switchedCredential = false;
9867
- let switchedModel = false;
9868
-
9869
- if (this.model && isUsageLimitError(errorMessage)) {
9870
- const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
9871
- const switched = await this.#modelRegistry.authStorage.markUsageLimitReached(
9872
- this.model.provider,
9873
- this.sessionId,
9874
- {
9875
- retryAfterMs,
9876
- baseUrl: this.model.baseUrl,
9877
- },
9878
- );
9879
- if (switched) {
9880
- switchedCredential = true;
9881
- delayMs = 0;
9882
- } else if (retryAfterMs > delayMs) {
9883
- // No more accounts to switch to — wait out the backoff
9884
- delayMs = retryAfterMs;
9885
- }
9886
- }
9887
-
9888
- const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
9889
- if (!switchedCredential && currentSelector) {
9890
- this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
9891
- switchedModel = await this.#tryRetryModelFallback(currentSelector, { requireNonLocal: localUnavailable });
9892
- if (switchedModel) {
9893
- delayMs = 0;
9894
- } else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
9895
- delayMs = parsedRetryAfterMs;
9896
- }
9897
- }
9898
-
9899
- // Fail-fast cap: if the provider asks us to wait longer than
9900
- // retry.maxDelayMs and we have no fallback credential or model to
9901
- // switch to, surface the error instead of sleeping. Defends against
9902
- // 3-hour Anthropic rate-limit windows that would otherwise leave a
9903
- // subagent (or interactive session) silently hung. The original
9904
- // assistant error message is preserved in agent state so the caller
9905
- // can act on it.
9906
- const maxDelayMs = retrySettings.maxDelayMs;
9907
- if (unboundedClass && !switchedCredential && !switchedModel) {
9908
- // Retry forever: honor a provider-supplied wait, otherwise cap the
9909
- // exponential backoff at the ceiling instead of giving up.
9910
- if (parsedRetryAfterMs !== undefined) {
9911
- delayMs = Math.max(delayMs, parsedRetryAfterMs);
9912
- } else if (maxDelayMs > 0) {
9913
- delayMs = Math.min(delayMs, maxDelayMs);
9914
- }
9915
- }
9916
- if (!unboundedClass && maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
9917
- const attempt = this.#retryAttempt;
11355
+ const retryAfterMs =
11356
+ trigger.retryAfterMs ?? (managedFallback ? undefined : this.#parseRetryAfterMsFromError(errorMessage));
11357
+ const delayMs =
11358
+ outcome === "advance"
11359
+ ? 0
11360
+ : managedFallback
11361
+ ? effectiveFallbackDelay(retrySettings.baseDelayMs, retrySettings.maxDelayMs, attemptsUsed, retryAfterMs)
11362
+ : retryAfterMs !== undefined
11363
+ ? Math.max(retrySettings.baseDelayMs * 2 ** Math.max(0, attemptsUsed - 1), retryAfterMs)
11364
+ : retrySettings.baseDelayMs * 2 ** Math.max(0, attemptsUsed - 1);
11365
+ if (!managedFallback && !legacyUnbounded && retrySettings.maxDelayMs > 0 && delayMs > retrySettings.maxDelayMs) {
9918
11366
  this.#retryAttempt = 0;
9919
11367
  await this.#emitSessionEvent({
9920
11368
  type: "auto_retry_end",
9921
11369
  success: false,
9922
- attempt,
9923
- finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
11370
+ attempt: attemptsUsed,
11371
+ finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${retrySettings.maxDelayMs}ms). Original error: ${errorMessage}`,
9924
11372
  });
9925
11373
  this.#resolveRetry();
9926
11374
  return false;
9927
11375
  }
9928
11376
 
9929
- // Create and install the backoff abort controller BEFORE emitting
9930
- // auto_retry_start, so a synchronous retryNow()/abortRetry() invoked from
9931
- // an event subscriber (e.g. the TUI Esc handler) is not lost in the gap
9932
- // between the event and the controller assignment.
9933
- const retryAbortController = new AbortController();
9934
- this.#retryAbortController?.abort();
9935
- this.#retryAbortController = retryAbortController;
9936
- this.#retryNowRequested = false;
11377
+ const retry = async (ownership?: ManagedAttemptContinuationOwnership): Promise<void> => {
11378
+ if (managedFallback) await this.#markFailedManagedCredential(trigger);
11379
+ let advanced = outcome !== "advance";
11380
+ let resolutionError: unknown;
11381
+ if (outcome === "advance") {
11382
+ try {
11383
+ advanced = await this.#advanceDefaultFallback(controller, trigger.class, attemptsUsed);
11384
+ } catch (error) {
11385
+ resolutionError = error;
11386
+ }
11387
+ }
11388
+ if (!advanced) {
11389
+ const errorMessage = resolutionError
11390
+ ? `${this.#fallbackExhaustionError(controller)}; resolution failed: ${resolutionError instanceof Error ? resolutionError.message : String(resolutionError)}`
11391
+ : this.#fallbackExhaustionError(controller);
11392
+ this.emitNotice("error", errorMessage, "fallback");
11393
+ if (managedOutcome && ownership) {
11394
+ this.agent.requestRunTerminal(ownership.logicalRunId, {
11395
+ stopReason: "exhausted",
11396
+ messages: [this.#managedFallbackExhaustionMessage(message, errorMessage)],
11397
+ });
11398
+ }
11399
+ this.#defaultFallbackExhaustedLastTurn = true;
11400
+ controller.resetSticky();
11401
+ this.#retryAttempt = 0;
11402
+ this.#resolveRetry();
11403
+ return;
11404
+ }
9937
11405
 
9938
- await this.#emitSessionEvent({
9939
- type: "auto_retry_start",
9940
- attempt: this.#retryAttempt,
9941
- maxAttempts: retrySettings.maxRetries,
9942
- delayMs,
9943
- errorMessage,
9944
- unbounded: unboundedClass,
9945
- });
11406
+ this.#retryAttempt = attemptsUsed;
11407
+ if (!this.#retryPromise) {
11408
+ const { promise, resolve } = Promise.withResolvers<void>();
11409
+ this.#retryPromise = promise;
11410
+ this.#retryResolve = resolve;
11411
+ }
9946
11412
 
9947
- // Remove error message from agent state (keep in session for history)
9948
- const messages = this.agent.state.messages;
9949
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
9950
- this.agent.replaceMessages(messages.slice(0, -1));
9951
- }
11413
+ const retryAbortController = new AbortController();
11414
+ this.#retryAbortController?.abort();
11415
+ this.#retryAbortController = retryAbortController;
11416
+ this.#retryNowRequested = false;
11417
+ await this.#emitSessionEvent({
11418
+ type: "auto_retry_start",
11419
+ attempt: this.#retryAttempt,
11420
+ maxAttempts: managedFallback ? controller.maxAttempts : retrySettings.maxRetries,
11421
+ delayMs,
11422
+ errorMessage,
11423
+ unbounded: !managedFallback && legacyUnbounded,
11424
+ });
9952
11425
 
9953
- // Wait with exponential backoff (abortable).
9954
- try {
9955
- await scheduler.wait(delayMs, { signal: retryAbortController.signal });
9956
- } catch {
9957
- if (this.#retryAbortController !== retryAbortController) {
9958
- return false;
11426
+ const messages = this.agent.state.messages;
11427
+ if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
11428
+ this.agent.replaceMessages(messages.slice(0, -1));
9959
11429
  }
9960
- this.#retryAbortController = undefined;
9961
- if (this.#retryNowRequested) {
9962
- // Retry-now: skip the remaining backoff and fall through to
9963
- // re-attempt immediately (keeps the retry session alive).
9964
- this.#retryNowRequested = false;
9965
- } else {
9966
- // Aborted during sleep (cancel) - emit end event so UI can clean up
11430
+
11431
+ try {
11432
+ await scheduler.wait(delayMs, { signal: retryAbortController.signal });
11433
+ } catch {
11434
+ if (this.#retryAbortController !== retryAbortController) return;
11435
+ this.#retryAbortController = undefined;
11436
+ if (this.#retryNowRequested) {
11437
+ // Fall through below so the retry continues immediately.
11438
+ } else {
11439
+ const attempt = this.#retryAttempt;
11440
+ this.#retryAttempt = 0;
11441
+ await this.#emitSessionEvent({
11442
+ type: "auto_retry_end",
11443
+ success: false,
11444
+ attempt,
11445
+ finalError: "Retry cancelled",
11446
+ });
11447
+ this.#resolveRetry();
11448
+ return;
11449
+ }
11450
+ }
11451
+ if (retryAbortController.signal.aborted && !this.#retryNowRequested) {
11452
+ if (this.#retryAbortController !== retryAbortController) return;
11453
+ this.#retryAbortController = undefined;
9967
11454
  const attempt = this.#retryAttempt;
9968
11455
  this.#retryAttempt = 0;
9969
11456
  await this.#emitSessionEvent({
@@ -9973,28 +11460,55 @@ export class AgentSession {
9973
11460
  finalError: "Retry cancelled",
9974
11461
  });
9975
11462
  this.#resolveRetry();
9976
- return false;
11463
+ return;
9977
11464
  }
9978
- }
9979
- if (this.#retryAbortController === retryAbortController) {
9980
- this.#retryAbortController = undefined;
9981
- }
11465
+ if (this.#retryAbortController === retryAbortController) this.#retryAbortController = undefined;
11466
+ this.#retryNowRequested = false;
9982
11467
 
9983
- // Retry via continue() outside the agent_end event callback chain.
9984
- // If the scheduled continue cannot run — it throws (e.g. AgentBusyError from a
9985
- // concurrent turn, or "Cannot continue ...") or is skipped because a newer
9986
- // generation took over — the agent_end that normally resolves #retryPromise
9987
- // never arrives. Finalize the retry in that case so #waitForPostPromptRecovery
9988
- // (and the in-flight prompt holding it open) cannot wedge the session as
9989
- // permanently busy, which would turn every later prompt() into a
9990
- // non-recoverable AgentBusyError loop.
9991
- this.#scheduleAgentContinue({
9992
- delayMs: 1,
9993
- generation,
9994
- onError: () => this.#failRetryRecovery("Retry continuation failed to start"),
9995
- onSkip: () => this.#failRetryRecovery("Retry continuation was superseded"),
9996
- });
11468
+ if (managedOutcome) {
11469
+ try {
11470
+ await this.#checkEstimatedContextBeforePrompt();
11471
+ if (!ownership?.isCurrent()) {
11472
+ const attempt = this.#retryAttempt;
11473
+ this.#retryAttempt = 0;
11474
+ await this.#emitSessionEvent({
11475
+ type: "auto_retry_end",
11476
+ success: false,
11477
+ attempt,
11478
+ finalError: "Retry continuation was superseded",
11479
+ });
11480
+ this.#resolveRetry();
11481
+ return;
11482
+ }
11483
+ await this.agent.continue(this.#managedFallbackPromptOptions());
11484
+ return;
11485
+ } catch (error) {
11486
+ const attempt = this.#retryAttempt;
11487
+ this.#retryAttempt = 0;
11488
+ try {
11489
+ await this.#emitSessionEvent({
11490
+ type: "auto_retry_end",
11491
+ success: false,
11492
+ attempt,
11493
+ finalError: error instanceof Error ? error.message : String(error),
11494
+ });
11495
+ } finally {
11496
+ this.#resolveRetry();
11497
+ }
11498
+ throw error;
11499
+ }
11500
+ }
11501
+
11502
+ this.#scheduleAgentContinue({
11503
+ delayMs: 1,
11504
+ generation,
11505
+ onError: () => this.#failRetryRecovery("Retry continuation failed to start"),
11506
+ onSkip: () => this.#failRetryRecovery("Retry continuation was superseded"),
11507
+ });
11508
+ };
9997
11509
 
11510
+ if (managedOutcome) return { type: "retry", continuation: retry };
11511
+ await retry();
9998
11512
  return true;
9999
11513
  }
10000
11514
 
@@ -10040,12 +11554,26 @@ export class AgentSession {
10040
11554
  this.#resolveRetry();
10041
11555
  }
10042
11556
 
10043
- async #promptAgentWithIdleRetry(messages: AgentMessage[], options?: { toolChoice?: ToolChoice }): Promise<void> {
11557
+ async #promptAgentWithIdleRetry(
11558
+ messages: AgentMessage[],
11559
+ options?: { toolChoice?: ToolChoice; fallbackManaged?: boolean; onRunAccepted?: () => void },
11560
+ predecessorAgentEndHold?: symbol,
11561
+ ): Promise<void> {
10044
11562
  const deadline = Date.now() + 30_000;
11563
+ let continuationHold = predecessorAgentEndHold;
10045
11564
  for (;;) {
10046
11565
  try {
10047
- await this.agent.prompt(messages, options);
10048
- return;
11566
+ const predecessorAgentEnd = this.#claimDeferredAgentEndForContinuation(
11567
+ continuationHold ?? this.#reserveDeferredAgentEndForContinuation(),
11568
+ );
11569
+ continuationHold = undefined;
11570
+ try {
11571
+ await this.agent.prompt(messages, options);
11572
+ return;
11573
+ } catch (error) {
11574
+ this.#restoreDeferredAgentEndAfterContinuationFailure(predecessorAgentEnd);
11575
+ throw error;
11576
+ }
10049
11577
  } catch (err) {
10050
11578
  if (!(err instanceof AgentBusyError)) {
10051
11579
  throw err;
@@ -11003,6 +12531,10 @@ export class AgentSession {
11003
12531
  this.#followUpMessages = [];
11004
12532
  this.#pendingNextTurnMessages = [];
11005
12533
  this.#scheduledHiddenNextTurnGeneration = undefined;
12534
+ const suspendedWorkflowGateEmitter = switchingToDifferentSession
12535
+ ? this.#suspendWorkflowGateEmitter(previousSessionState.sessionId)
12536
+ : undefined;
12537
+ let unavailableDefaultChainMessage: string | undefined;
11006
12538
 
11007
12539
  try {
11008
12540
  await this.sessionManager.setSessionFile(sessionPath);
@@ -11016,53 +12548,41 @@ export class AgentSession {
11016
12548
  const fallbackSelectedMCPToolNames = this.#getSessionDefaultSelectedMCPToolNames(sessionPath);
11017
12549
  await this.#restoreMCPSelectionsForSessionContext(sessionContext, { fallbackSelectedMCPToolNames });
11018
12550
 
11019
- // The target session is loaded and MCP selections are restored: the
11020
- // switch is committed far enough to discard pre-switch delivery queues.
11021
- // Clear before session_switch hooks, so messages enqueued by hooks belong
11022
- // to the new session and remain deliverable.
12551
+ // The target session is loaded and MCP selections are restored: discard
12552
+ // pre-switch delivery queues before completing the restored agent state.
11023
12553
  this.agent.clearAllQueues();
11024
12554
 
11025
- // Emit session_switch event to hooks
11026
- if (this.#extensionRunner) {
11027
- await this.#extensionRunner.emit({
11028
- type: "session_switch",
11029
- reason: "resume",
11030
- previousSessionFile,
11031
- });
11032
- }
11033
-
11034
12555
  this.agent.replaceMessages(sessionContext.messages);
11035
12556
  this.#resetInjectedContextSignatures();
11036
12557
  this.#syncTodoPhasesFromBranch();
11037
- if (switchingToDifferentSession) {
11038
- this.#closeAllProviderSessions("session switch");
11039
- } else if (didReloadConversationChange) {
11040
- this.#closeAllProviderSessions("session reload");
11041
- }
11042
-
11043
- // Restore model if saved
11044
- const defaultModelStr = sessionContext.models.default;
11045
- if (defaultModelStr) {
11046
- const slashIdx = defaultModelStr.indexOf("/");
11047
- if (slashIdx > 0) {
11048
- const provider = defaultModelStr.slice(0, slashIdx);
11049
- const modelId = defaultModelStr.slice(slashIdx + 1);
11050
- const availableModels = this.#modelRegistry.getAvailable();
11051
- const match = availableModels.find(m => m.provider === provider && m.id === modelId);
11052
- if (match) {
11053
- const currentModel = this.model;
11054
- const shouldResetProviderState =
11055
- switchingToDifferentSession ||
11056
- (currentModel !== undefined &&
11057
- (currentModel.provider !== match.provider ||
11058
- currentModel.id !== match.id ||
11059
- currentModel.api !== match.api));
11060
- if (shouldResetProviderState) {
11061
- this.#setModelWithProviderSessionReset(match);
11062
- } else {
11063
- this.agent.setModel(match);
11064
- }
11065
- }
12558
+ if (switchingToDifferentSession || didReloadConversationChange) {
12559
+ this.#closeAllProviderSessions(switchingToDifferentSession ? "session switch" : "session reload");
12560
+ this.#rebindProviderSessionState(new Map());
12561
+ }
12562
+
12563
+ const configuredDefaultChain = sessionContext.configuredModelChains.default?.entries;
12564
+ const defaultEntries =
12565
+ configuredDefaultChain ?? (sessionContext.models.default ? [sessionContext.models.default] : []);
12566
+ this.#defaultFallbackController = undefined;
12567
+ if (defaultEntries.length > 0) {
12568
+ const resolution = await resolveModelChainWithAuth(
12569
+ defaultEntries,
12570
+ this.#modelRegistry,
12571
+ this.settings,
12572
+ this.sessionId,
12573
+ { managedFallback: true },
12574
+ );
12575
+ const controller = this.#defaultFallbackChain();
12576
+ this.seedDefaultFallbackResolution(resolution.activeIndex, resolution.skips);
12577
+ if (!resolution.model) {
12578
+ unavailableDefaultChainMessage = this.#fallbackExhaustionError(controller);
12579
+ throw new Error(unavailableDefaultChainMessage);
12580
+ }
12581
+ if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
12582
+ this.#setModelAuthoritatively(resolution.model, "restore");
12583
+ }
12584
+ if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
12585
+ this.setThinkingLevel(resolution.thinkingLevel);
11066
12586
  }
11067
12587
  }
11068
12588
 
@@ -11072,9 +12592,14 @@ export class AgentSession {
11072
12592
  .some(entry => entry.type === "service_tier_change");
11073
12593
  const defaultThinkingLevel = this.settings.get("defaultThinkingLevel");
11074
12594
  const configuredServiceTier = this.settings.get("serviceTier");
12595
+ const persistedThinkingLevel = hasThinkingEntry
12596
+ ? (sessionContext.thinkingLevel as ThinkingLevel | undefined)
12597
+ : defaultThinkingLevel;
11075
12598
  const nextThinkingLevel = resolveThinkingLevelForModel(
11076
12599
  this.model,
11077
- hasThinkingEntry ? (sessionContext.thinkingLevel as ThinkingLevel | undefined) : defaultThinkingLevel,
12600
+ persistedThinkingLevel === ThinkingLevel.Inherit
12601
+ ? this.#getInheritedThinkingLevel()
12602
+ : persistedThinkingLevel,
11078
12603
  );
11079
12604
  this.#thinkingLevel = nextThinkingLevel;
11080
12605
  this.agent.setThinkingLevel(toReasoningEffort(nextThinkingLevel));
@@ -11083,6 +12608,9 @@ export class AgentSession {
11083
12608
  : configuredServiceTier === "none"
11084
12609
  ? undefined
11085
12610
  : configuredServiceTier;
12611
+ // Establish the successor's durable session identity only after every
12612
+ // restored state facet is live. Identity-bound extension hooks run below.
12613
+ await this.sessionManager.ensureOnDisk();
11086
12614
 
11087
12615
  if (switchingToDifferentSession) {
11088
12616
  this.#resetHindsightConversationTrackingIfHindsight();
@@ -11090,10 +12618,24 @@ export class AgentSession {
11090
12618
  }
11091
12619
 
11092
12620
  this.#reconnectToAgent();
12621
+ // session_switch is the post-commit identity signal. SDK authority and
12622
+ // other identity-bound integrations must not observe the successor until
12623
+ // messages, model state, MCP selections, and the agent subscription are live.
12624
+ if (this.#extensionRunner) {
12625
+ await this.#extensionRunner.emit({
12626
+ type: "session_switch",
12627
+ reason: "resume",
12628
+ previousSessionFile,
12629
+ });
12630
+ }
12631
+ if (suspendedWorkflowGateEmitter)
12632
+ this.#bindWorkflowGateEmitter(previousSessionState.sessionId, suspendedWorkflowGateEmitter);
11093
12633
  return true;
11094
12634
  } catch (error) {
11095
12635
  this.sessionManager.restoreState(previousSessionState);
12636
+ this.#defaultFallbackController = undefined;
11096
12637
  this.#syncAgentSessionId(previousSessionState.sessionId);
12638
+ this.#restoreWorkflowGateEmitter(suspendedWorkflowGateEmitter);
11097
12639
  this.#rekeyHindsightMemoryForCurrentSessionId();
11098
12640
  let restoreMcpError: unknown;
11099
12641
  try {
@@ -11133,6 +12675,10 @@ export class AgentSession {
11133
12675
  if (restoreMcpError) {
11134
12676
  throw restoreMcpError;
11135
12677
  }
12678
+ if (unavailableDefaultChainMessage) {
12679
+ this.emitNotice("error", `Could not restore session model: ${unavailableDefaultChainMessage}`, "fallback");
12680
+ return false;
12681
+ }
11136
12682
  throw error;
11137
12683
  }
11138
12684
  }
@@ -11151,6 +12697,7 @@ export class AgentSession {
11151
12697
  cancelled: boolean;
11152
12698
  }> {
11153
12699
  const previousSessionFile = this.sessionFile;
12700
+ const previousWorkflowGateSessionId = this.sessionId;
11154
12701
  const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
11155
12702
 
11156
12703
  if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
@@ -11189,22 +12736,17 @@ export class AgentSession {
11189
12736
  }
11190
12737
  this.#syncTodoPhasesFromBranch();
11191
12738
  this.#syncAgentSessionId();
12739
+ this.#bindWorkflowGateEmitter(previousWorkflowGateSessionId);
11192
12740
  this.#rekeyHindsightMemoryForCurrentSessionId();
11193
12741
  this.#resetHindsightConversationTrackingIfHindsight();
12742
+ this.#closeAllProviderSessions("session branch");
12743
+ this.#rebindProviderSessionState(new Map());
11194
12744
 
11195
12745
  // Reload messages from entries (works for both file and in-memory mode)
11196
12746
  const sessionContext = this.buildDisplaySessionContext();
11197
12747
 
11198
12748
  await this.#restoreMCPSelectionsForSessionContext(sessionContext);
11199
12749
 
11200
- // Emit session_branch event to hooks (after branch completes)
11201
- if (this.#extensionRunner) {
11202
- await this.#extensionRunner.emit({
11203
- type: "session_branch",
11204
- previousSessionFile,
11205
- });
11206
- }
11207
-
11208
12750
  if (!skipConversationRestore) {
11209
12751
  this.agent.replaceMessages(sessionContext.messages);
11210
12752
  this.#resetInjectedContextSignatures();
@@ -11212,6 +12754,14 @@ export class AgentSession {
11212
12754
  }
11213
12755
 
11214
12756
  this.#resetIrcRosterDeliveryState();
12757
+ // session_branch is the post-commit identity signal. Publish it only after
12758
+ // the successor's messages and MCP selections are restored.
12759
+ if (this.#extensionRunner) {
12760
+ await this.#extensionRunner.emit({
12761
+ type: "session_branch",
12762
+ previousSessionFile,
12763
+ });
12764
+ }
11215
12765
 
11216
12766
  return { selectedText, cancelled: false };
11217
12767
  }
@@ -11669,6 +13219,15 @@ export class AgentSession {
11669
13219
  });
11670
13220
  }
11671
13221
 
13222
+ async fetchUsageReportsForControl(): Promise<UsageReport[] | null> {
13223
+ const authStorage = this.#modelRegistry.authStorage;
13224
+ if (!authStorage.fetchUsageReports) return null;
13225
+ return authStorage.fetchUsageReports({
13226
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
13227
+ logDetails: false,
13228
+ });
13229
+ }
13230
+
11672
13231
  /**
11673
13232
  * Estimate context tokens from messages, using the last assistant usage when available.
11674
13233
  */
@@ -11735,9 +13294,9 @@ export class AgentSession {
11735
13294
 
11736
13295
  /**
11737
13296
  * Observed heuristic→actual token correction for the compaction keep window
11738
- * (Finding 7). Compares the provider's real prompt tokens against the chars/4
11739
- * heuristic estimate of the same content (stable system prefix + history
11740
- * before the last usage-bearing assistant turn — that turn's own output is
13297
+ * (Finding 7). Compares the provider's real prompt tokens against the
13298
+ * script-aware display-token heuristic estimate of the same content (stable
13299
+ * system prefix + history before the last usage-bearing assistant turn — that turn's own output is
11741
13300
  * the response, not part of the request's prompt, so it belongs on neither
11742
13301
  * side of the ratio). Image-bearing content is bucketed out
11743
13302
  * of BOTH sides using the identical fixed IMAGE_TOKEN_ESTIMATE so the 1200-token