@gajae-code/coding-agent 0.14.2 → 0.15.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 (345) hide show
  1. package/CHANGELOG.md +120 -1
  2. package/dist/types/async/job-manager.d.ts +4 -1
  3. package/dist/types/autoresearch/dashboard.d.ts +29 -4
  4. package/dist/types/autoresearch/data-context.d.ts +25 -0
  5. package/dist/types/autoresearch/git.d.ts +65 -7
  6. package/dist/types/autoresearch/harness.d.ts +58 -0
  7. package/dist/types/autoresearch/prompts.d.ts +51 -0
  8. package/dist/types/autoresearch/runs.d.ts +173 -0
  9. package/dist/types/autoresearch/types.d.ts +9 -139
  10. package/dist/types/capability/types.d.ts +20 -0
  11. package/dist/types/cli/accounts-cli.d.ts +1 -0
  12. package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
  13. package/dist/types/config/autorouting-contract.d.ts +146 -0
  14. package/dist/types/config/autorouting-generator.d.ts +40 -0
  15. package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
  16. package/dist/types/config/autorouting.d.ts +35 -0
  17. package/dist/types/config/model-discovery-manager.d.ts +8 -1
  18. package/dist/types/config/model-registry.d.ts +30 -1
  19. package/dist/types/config/provider-selection-policy.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +61 -3
  21. package/dist/types/config/settings.d.ts +3 -0
  22. package/dist/types/coordinator/contract.d.ts +1 -1
  23. package/dist/types/coordinator-mcp/durability.d.ts +29 -0
  24. package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
  25. package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
  26. package/dist/types/coordinator-mcp/server.d.ts +14 -0
  27. package/dist/types/crash/index-store.d.ts +3 -0
  28. package/dist/types/crash/record-loader.d.ts +2 -0
  29. package/dist/types/crash/upstream/relay.d.ts +5 -3
  30. package/dist/types/cursor.d.ts +12 -0
  31. package/dist/types/defaults/gjc-defaults.d.ts +35 -1
  32. package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
  33. package/dist/types/discovery/helpers.d.ts +1 -1
  34. package/dist/types/edit/index.d.ts +2 -1
  35. package/dist/types/eval/py/executor.d.ts +2 -0
  36. package/dist/types/extensibility/extensions/loader.d.ts +32 -0
  37. package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
  38. package/dist/types/extensibility/skills.d.ts +2 -0
  39. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
  40. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  41. package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
  42. package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
  43. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
  44. package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
  45. package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
  46. package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
  47. package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
  48. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
  49. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
  50. package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
  51. package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
  52. package/dist/types/hooks/skill-keywords.d.ts +1 -1
  53. package/dist/types/main.d.ts +0 -10
  54. package/dist/types/modes/acp/acp-agent.d.ts +13 -6
  55. package/dist/types/modes/components/model-selector.d.ts +14 -0
  56. package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
  57. package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
  58. package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
  59. package/dist/types/modes/interactive-mode.d.ts +3 -0
  60. package/dist/types/modes/types.d.ts +9 -0
  61. package/dist/types/rlm/artifacts.d.ts +4 -4
  62. package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
  63. package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
  64. package/dist/types/runtime-mcp/config.d.ts +6 -0
  65. package/dist/types/runtime-mcp/manager.d.ts +10 -0
  66. package/dist/types/sdk/acp/adapter.d.ts +0 -4
  67. package/dist/types/sdk/broker/broker.d.ts +1 -1
  68. package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
  69. package/dist/types/sdk/broker/session-index.d.ts +15 -0
  70. package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
  71. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  72. package/dist/types/sdk/bus/index.d.ts +8 -0
  73. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
  74. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
  75. package/dist/types/sdk/client/client.d.ts +39 -4
  76. package/dist/types/sdk/host/host.d.ts +1 -0
  77. package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
  78. package/dist/types/sdk/host/model-pin.d.ts +29 -0
  79. package/dist/types/sdk/host/session-runtime.d.ts +2 -1
  80. package/dist/types/sdk/lifecycle-session.d.ts +13 -0
  81. package/dist/types/sdk/router/session-router.d.ts +27 -1
  82. package/dist/types/sdk/session.d.ts +11 -1
  83. package/dist/types/session/agent-session.d.ts +36 -6
  84. package/dist/types/session/artifacts.d.ts +16 -1
  85. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  86. package/dist/types/session/session-manager.d.ts +84 -2
  87. package/dist/types/session/session-staging-paths.d.ts +3 -0
  88. package/dist/types/skill-state/canonical-skills.d.ts +1 -1
  89. package/dist/types/skill-state/workflow-hud.d.ts +12 -21
  90. package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
  91. package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
  92. package/dist/types/system-prompt.d.ts +23 -0
  93. package/dist/types/task/executor.d.ts +35 -4
  94. package/dist/types/task/index.d.ts +17 -3
  95. package/dist/types/task/receipt.d.ts +3 -1
  96. package/dist/types/task/types.d.ts +144 -0
  97. package/dist/types/tools/atomic-file-write.d.ts +72 -0
  98. package/dist/types/tools/descriptors.d.ts +1 -0
  99. package/dist/types/tools/index.d.ts +36 -1
  100. package/dist/types/tools/move-session.d.ts +40 -0
  101. package/dist/types/tools/python.d.ts +12 -0
  102. package/dist/types/tools/resource-gc.d.ts +0 -3
  103. package/dist/types/tools/search.d.ts +8 -1
  104. package/dist/types/tools/skill.d.ts +9 -4
  105. package/dist/types/tools/subagent.d.ts +33 -3
  106. package/dist/types/utils/xml-escape.d.ts +2 -0
  107. package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
  108. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
  109. package/package.json +11 -12
  110. package/scripts/build-sdk-package-smoke.ts +7 -5
  111. package/scripts/check-autorouting-tier-map.ts +183 -0
  112. package/scripts/generate-sdk-operation-inventory.ts +12 -0
  113. package/scripts/generate-tool-catalog.ts +40 -5
  114. package/src/async/job-manager.ts +5 -2
  115. package/src/autoresearch/dashboard.ts +134 -314
  116. package/src/autoresearch/data-context.ts +35 -0
  117. package/src/autoresearch/git.ts +186 -26
  118. package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
  119. package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
  120. package/src/autoresearch/prompts/prompt.md +103 -0
  121. package/src/autoresearch/prompts.ts +126 -0
  122. package/src/autoresearch/runs.ts +708 -0
  123. package/src/autoresearch/types.ts +9 -150
  124. package/src/capability/index.ts +4 -4
  125. package/src/capability/types.ts +20 -0
  126. package/src/cli/accounts-cli.ts +21 -5
  127. package/src/cli/config-cli.ts +25 -1
  128. package/src/cli/fast-help.ts +1 -2
  129. package/src/cli/list-models.ts +5 -8
  130. package/src/cli/setup-cli.ts +8 -0
  131. package/src/cli.ts +2 -21
  132. package/src/commands/autoresearch.ts +33 -0
  133. package/src/commands/coordinator.ts +15 -0
  134. package/src/commands/launch.ts +2 -4
  135. package/src/commands/quick-lane.ts +1 -1
  136. package/src/commands/sdk.ts +24 -0
  137. package/src/commands/state.ts +1 -1
  138. package/src/config/autorouting-contract.ts +474 -0
  139. package/src/config/autorouting-generator.ts +238 -0
  140. package/src/config/autorouting-tier-map.ts +6108 -0
  141. package/src/config/autorouting.ts +120 -0
  142. package/src/config/model-discovery-manager.ts +42 -11
  143. package/src/config/model-registry.ts +348 -68
  144. package/src/config/provider-selection-policy.ts +60 -9
  145. package/src/config/settings-schema.ts +195 -6
  146. package/src/config/settings.ts +62 -1
  147. package/src/config.ts +2 -3
  148. package/src/coordinator/contract.ts +0 -1
  149. package/src/coordinator-mcp/codex-handoff.ts +68 -42
  150. package/src/coordinator-mcp/durability.ts +200 -0
  151. package/src/coordinator-mcp/event-webhook.ts +278 -0
  152. package/src/coordinator-mcp/question-state.ts +109 -46
  153. package/src/coordinator-mcp/server.ts +932 -504
  154. package/src/crash/index-store.ts +126 -22
  155. package/src/crash/record-loader.ts +7 -0
  156. package/src/crash/upstream/relay.ts +82 -34
  157. package/src/cursor.ts +84 -1
  158. package/src/debug/index.ts +26 -0
  159. package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
  160. package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
  161. package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
  162. package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
  163. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
  164. package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
  165. package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
  166. package/src/defaults/gjc-defaults.ts +89 -2
  167. package/src/defaults/gjc-skills.generated.ts +24 -17
  168. package/src/discovery/builtin.ts +18 -5
  169. package/src/discovery/helpers.ts +6 -6
  170. package/src/edit/index.ts +5 -2
  171. package/src/eval/py/executor.ts +8 -0
  172. package/src/extensibility/extensions/loader.ts +135 -13
  173. package/src/extensibility/extensions/wrapper.ts +1 -2
  174. package/src/extensibility/gjc-plugins/schema.ts +1 -1
  175. package/src/extensibility/runtime-skill-discovery.ts +2 -2
  176. package/src/extensibility/skill-management.ts +2 -3
  177. package/src/extensibility/skills.ts +16 -8
  178. package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
  179. package/src/gjc-runtime/gc-render.ts +0 -1
  180. package/src/gjc-runtime/gc-runtime.ts +1 -11
  181. package/src/gjc-runtime/launch-tmux.ts +3 -3
  182. package/src/gjc-runtime/psmux-detect.ts +2 -2
  183. package/src/gjc-runtime/python-transcript.ts +58 -0
  184. package/src/gjc-runtime/ralplan-runtime.ts +6 -18
  185. package/src/gjc-runtime/session-layout.ts +18 -7
  186. package/src/gjc-runtime/session-state-sidecar.ts +58 -9
  187. package/src/gjc-runtime/state-renderer.ts +0 -38
  188. package/src/gjc-runtime/state-runtime.ts +43 -22
  189. package/src/gjc-runtime/state-schema.ts +1 -1
  190. package/src/gjc-runtime/tmux-common.ts +7 -7
  191. package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
  192. package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
  193. package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
  194. package/src/gjc-runtime/workflow-command-ref.ts +15 -11
  195. package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
  196. package/src/gjc-runtime/workflow-manifest.ts +43 -83
  197. package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
  198. package/src/hooks/skill-keywords.ts +3 -3
  199. package/src/hooks/skill-state.ts +2 -2
  200. package/src/internal-urls/docs-index.generated.ts +26 -24
  201. package/src/lsp/index.ts +25 -5
  202. package/src/main.ts +2 -27
  203. package/src/modes/acp/acp-agent.ts +200 -75
  204. package/src/modes/components/model-selector.ts +216 -13
  205. package/src/modes/components/oauth-selector.ts +5 -5
  206. package/src/modes/components/skill-hud/render.ts +173 -35
  207. package/src/modes/components/smart-routing-panel.ts +531 -0
  208. package/src/modes/components/tool-status-header.ts +6 -3
  209. package/src/modes/controllers/command-controller.ts +17 -7
  210. package/src/modes/controllers/input-controller.ts +44 -9
  211. package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
  212. package/src/modes/controllers/selector-controller.ts +298 -65
  213. package/src/modes/interactive-mode.ts +8 -1
  214. package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
  215. package/src/modes/types.ts +9 -1
  216. package/src/prompts/agents/planner.md +1 -1
  217. package/src/prompts/system/system-prompt.md +1 -1
  218. package/src/prompts/system/volatile-project-context.md +2 -1
  219. package/src/prompts/tools/move-session.md +15 -0
  220. package/src/prompts/tools/python.md +12 -0
  221. package/src/prompts/tools/skill-discovery.md +1 -1
  222. package/src/prompts/tools/skill.md +2 -2
  223. package/src/prompts/tools/task-summary.md +1 -0
  224. package/src/prompts/tools/task.md +6 -0
  225. package/src/rlm/artifacts.ts +12 -10
  226. package/src/runtime/optional-runtime-services.ts +3 -2
  227. package/src/runtime/workspace-tree-service.ts +11 -3
  228. package/src/runtime-mcp/config.ts +8 -1
  229. package/src/runtime-mcp/manager.ts +33 -0
  230. package/src/sdk/acp/adapter.ts +48 -1
  231. package/src/sdk/broker/broker.ts +30 -2
  232. package/src/sdk/broker/discovery.ts +217 -2
  233. package/src/sdk/broker/lifecycle.ts +385 -59
  234. package/src/sdk/broker/session-index.ts +243 -48
  235. package/src/sdk/broker/transport.ts +1 -0
  236. package/src/sdk/bus/chat-daemon-control.ts +8 -2
  237. package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
  238. package/src/sdk/bus/index.ts +208 -11
  239. package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
  240. package/src/sdk/bus/reconciliation-store.ts +27 -5
  241. package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
  242. package/src/sdk/client/client.ts +204 -22
  243. package/src/sdk/host/host.ts +16 -0
  244. package/src/sdk/host/internal-autorouting-state.ts +13 -0
  245. package/src/sdk/host/model-pin.ts +101 -0
  246. package/src/sdk/host/reverse-leases.ts +2 -1
  247. package/src/sdk/host/session-runtime.test.ts +224 -1
  248. package/src/sdk/host/session-runtime.ts +125 -37
  249. package/src/sdk/lifecycle/service.ts +3 -1
  250. package/src/sdk/lifecycle-session.ts +42 -1
  251. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  252. package/src/sdk/router/session-router.ts +375 -36
  253. package/src/sdk/session.ts +547 -47
  254. package/src/session/account-inventory.ts +1 -1
  255. package/src/session/agent-session.ts +765 -189
  256. package/src/session/artifacts.ts +234 -4
  257. package/src/session/internal/managed-session-storage.ts +91 -4
  258. package/src/session/provider-safety-stop-hint.ts +3 -2
  259. package/src/session/session-manager.ts +775 -16
  260. package/src/session/session-staging-paths.ts +8 -0
  261. package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
  262. package/src/setup/host-plugin-setup.ts +2 -2
  263. package/src/skill-state/active-state.ts +2 -2
  264. package/src/skill-state/canonical-skills.ts +1 -1
  265. package/src/skill-state/initial-phase.ts +1 -1
  266. package/src/skill-state/workflow-hud.ts +41 -41
  267. package/src/skill-state/workflow-mutation-guard.ts +26 -7
  268. package/src/slash-commands/builtin-registry.ts +77 -0
  269. package/src/slash-commands/helpers/autorouting-status.ts +55 -0
  270. package/src/system-prompt.ts +102 -5
  271. package/src/task/discovery.ts +2 -3
  272. package/src/task/executor.ts +617 -43
  273. package/src/task/index.ts +262 -15
  274. package/src/task/receipt.ts +30 -0
  275. package/src/task/render.ts +11 -1
  276. package/src/task/types.ts +316 -0
  277. package/src/tools/atomic-file-write.ts +594 -0
  278. package/src/tools/bisect.ts +3 -2
  279. package/src/tools/descriptors.test.ts +3 -0
  280. package/src/tools/descriptors.ts +24 -0
  281. package/src/tools/image-gen.ts +5 -1
  282. package/src/tools/index.ts +62 -5
  283. package/src/tools/move-session.ts +107 -0
  284. package/src/tools/python.ts +205 -0
  285. package/src/tools/read.ts +88 -15
  286. package/src/tools/renderers.ts +2 -0
  287. package/src/tools/resource-gc.ts +8 -177
  288. package/src/tools/search.ts +30 -6
  289. package/src/tools/skill-discovery.ts +1 -0
  290. package/src/tools/skill.ts +37 -7
  291. package/src/tools/subagent-render.ts +119 -13
  292. package/src/tools/subagent.ts +91 -74
  293. package/src/tools/tool-catalog.generated.ts +64 -2
  294. package/src/tools/write.ts +16 -12
  295. package/src/utils/xml-escape.ts +14 -0
  296. package/src/web/search/providers/codex.ts +2 -2
  297. package/src/workflow/workflow-intent-diff.ts +13 -12
  298. package/src/workflow/workflow-intent-report-consumer.ts +6 -6
  299. package/dist/types/autoresearch/helpers.d.ts +0 -24
  300. package/dist/types/autoresearch/index.d.ts +0 -2
  301. package/dist/types/autoresearch/state.d.ts +0 -17
  302. package/dist/types/autoresearch/storage.d.ts +0 -142
  303. package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
  304. package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
  305. package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
  306. package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
  307. package/dist/types/commands/team.d.ts +0 -24
  308. package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
  309. package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
  310. package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
  311. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
  312. package/dist/types/gjc-runtime/team-store.d.ts +0 -168
  313. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
  314. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
  315. package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
  316. package/dist/types/rlm/index.d.ts +0 -47
  317. package/dist/types/rlm/preset.d.ts +0 -23
  318. package/dist/types/rlm/python-tool.d.ts +0 -16
  319. package/src/autoresearch/command-resume.md +0 -14
  320. package/src/autoresearch/index.ts +0 -536
  321. package/src/autoresearch/prompt.md +0 -103
  322. package/src/autoresearch/resume-message.md +0 -10
  323. package/src/autoresearch/state.ts +0 -273
  324. package/src/autoresearch/storage.ts +0 -699
  325. package/src/autoresearch/tools/init-experiment.ts +0 -272
  326. package/src/autoresearch/tools/log-experiment.ts +0 -524
  327. package/src/autoresearch/tools/run-experiment.ts +0 -475
  328. package/src/autoresearch/tools/update-notes.ts +0 -109
  329. package/src/commands/rlm.ts +0 -19
  330. package/src/commands/team.ts +0 -238
  331. package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
  332. package/src/defaults/gjc/skills/team/SKILL.md +0 -467
  333. package/src/gjc-runtime/team-gc.ts +0 -49
  334. package/src/gjc-runtime/team-launch.ts +0 -404
  335. package/src/gjc-runtime/team-notify.ts +0 -326
  336. package/src/gjc-runtime/team-runtime.ts +0 -5923
  337. package/src/gjc-runtime/team-store.ts +0 -878
  338. package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
  339. package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
  340. package/src/gjc-runtime/team-workers.ts +0 -1123
  341. package/src/prompts/system/rlm-report-command.md +0 -1
  342. package/src/prompts/system/rlm-research.md +0 -23
  343. package/src/rlm/index.ts +0 -365
  344. package/src/rlm/preset.ts +0 -76
  345. package/src/rlm/python-tool.ts +0 -68
@@ -48,7 +48,7 @@ import {
48
48
  type StablePrefixSnapshot,
49
49
  ThinkingLevel,
50
50
  } from "@gajae-code/agent-core";
51
- import { normalizeMessagesForProvider } from "@gajae-code/agent-core/agent-loop";
51
+ import { ESCAPED_NONASCII_RECOVERY_PROMPT, normalizeMessagesForProvider } from "@gajae-code/agent-core/agent-loop";
52
52
  import type { AttemptRunHandle, AttemptScope, AttemptScopeAuthority } from "@gajae-code/agent-core/attempt-scope";
53
53
  import {
54
54
  AUTO_HANDOFF_THRESHOLD_FOCUS,
@@ -104,6 +104,7 @@ import type {
104
104
  TransportFailureFacts,
105
105
  Usage,
106
106
  UsageReport,
107
+ UserMessage,
107
108
  } from "@gajae-code/ai/core";
108
109
  import {
109
110
  classifyContextOverflow,
@@ -309,8 +310,14 @@ import {
309
310
  registerCoordinatorRuntimeStateFinalizer,
310
311
  UNPROVEN_TOOL_LABEL,
311
312
  } from "../gjc-runtime/session-state-sidecar";
312
- import { requestGjcWorkerIntegrationAttempt } from "../gjc-runtime/team-runtime";
313
- import { GjcTeamWorkerHeartbeatReporter } from "../gjc-runtime/team-worker-heartbeat";
313
+ import {
314
+ isWorkflowRecoveryStalled,
315
+ projectLatestRalplanRun,
316
+ projectUltragoalRun,
317
+ trackWorkflowRecoveryZeroProgress,
318
+ type WorkflowRecoveryProjection,
319
+ type WorkflowRecoveryZeroProgressMemory,
320
+ } from "../gjc-runtime/workflow-recovery-projection";
314
321
  import { GoalRuntime } from "../goals/runtime";
315
322
  import type { Goal, GoalModeState } from "../goals/state";
316
323
  import type { HindsightSessionState } from "../hindsight/state";
@@ -486,12 +493,169 @@ import { ToolChoiceQueue } from "./tool-choice-queue";
486
493
  import { pruneSupersededMaintenanceReminders, pruneSupersededVolatileProjectContext } from "./volatile-context-pruning";
487
494
  import { YieldQueue } from "./yield-queue";
488
495
 
496
+ /**
497
+ * #4560: structured workflow recovery projection from canonical durable
498
+ * Ralplan/Ultragoal state, consumed by the compaction summary context and
499
+ * the post-compaction continuation prompt.
500
+ */
489
501
  interface CompactionStateSnapshot {
502
+ workflowRecovery?: WorkflowRecoveryProjection;
490
503
  goal: { objective: string; status: Goal["status"]; enabled: boolean } | undefined;
491
504
  openTodos: string[];
492
505
  activeSkills: Array<{ skill: string; phase: string }>;
493
506
  queuedMessages: boolean;
494
507
  lastAssistantStopReason: StopReason | undefined;
508
+ recentFileMutations: string[];
509
+ }
510
+
511
+ const FILE_MUTATION_TOOLS = new Set(["write", "edit", "apply_patch", "ast_edit"]);
512
+ const MAX_RECENT_FILE_MUTATIONS = 12;
513
+
514
+ function collectFileMutationPaths(toolName: string, args: unknown): string[] {
515
+ if (!FILE_MUTATION_TOOLS.has(toolName) || !args || typeof args !== "object" || Array.isArray(args)) return [];
516
+ const record = args as Record<string, unknown>;
517
+ const paths: string[] = [];
518
+ const addPath = (value: unknown) => {
519
+ if (typeof value === "string" && value.length > 0 && !paths.includes(value)) paths.push(value);
520
+ };
521
+ const directPath = getStringProperty(record, "path") ?? getStringProperty(record, "file_path");
522
+ addPath(directPath);
523
+
524
+ for (const path of collectStringPaths(record.paths)) addPath(path);
525
+ const edits = Array.isArray(record.edits) ? record.edits : [];
526
+ for (const edit of edits) {
527
+ if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
528
+ addPath(getStringProperty(edit as Record<string, unknown>, "rename"));
529
+ }
530
+
531
+ const input = getStringProperty(record, "input");
532
+ if (input) {
533
+ try {
534
+ for (const entry of expandApplyPatchToEntries({ input })) {
535
+ addPath(entry.path);
536
+ addPath(entry.rename);
537
+ }
538
+ } catch {
539
+ // If the edit input is not an apply_patch envelope, retain direct paths.
540
+ }
541
+ }
542
+ return paths;
543
+ }
544
+
545
+ function collectRecentFileMutations(messages: readonly AgentMessage[]): string[] {
546
+ const callsById = new Map<string, string[]>();
547
+ for (const message of messages) {
548
+ if (message.role !== "assistant") continue;
549
+ const content = (message as AssistantMessage).content;
550
+ if (!Array.isArray(content)) continue;
551
+ for (const block of content) {
552
+ if (block.type !== "toolCall" || !FILE_MUTATION_TOOLS.has(block.name)) continue;
553
+ const paths = collectFileMutationPaths(block.name, block.arguments).filter(path => path.length > 0);
554
+ if (paths.length > 0) callsById.set(block.id, paths);
555
+ }
556
+ }
557
+ const seen = new Set<string>();
558
+ const paths: string[] = [];
559
+ for (let index = messages.length - 1; index >= 0; index--) {
560
+ const message = messages[index];
561
+ if (message.role !== "toolResult") continue;
562
+ const result = message as { toolName?: string; toolCallId?: string; isError?: boolean };
563
+ if (result.isError || !result.toolName || !FILE_MUTATION_TOOLS.has(result.toolName)) continue;
564
+ const filePaths = result.toolCallId ? callsById.get(result.toolCallId) : undefined;
565
+ if (!filePaths) continue;
566
+ for (let pathIndex = filePaths.length - 1; pathIndex >= 0; pathIndex--) {
567
+ const filePath = filePaths[pathIndex];
568
+ if (!filePath || seen.has(filePath)) continue;
569
+ seen.add(filePath);
570
+ paths.push(filePath);
571
+ if (paths.length >= MAX_RECENT_FILE_MUTATIONS) break;
572
+ }
573
+ if (paths.length >= MAX_RECENT_FILE_MUTATIONS) break;
574
+ }
575
+ return paths;
576
+ }
577
+
578
+ /**
579
+ * #4560: render the structured workflow recovery projection as bounded
580
+ * compaction-context lines. Scope lines mark accepted scope and non-goals so
581
+ * post-compaction continuation reloads the accepted contract instead of
582
+ * re-deriving (and potentially expanding) it from summary prose.
583
+ */
584
+ function renderWorkflowRecoveryContext(recovery: WorkflowRecoveryProjection): string[] {
585
+ const lines: string[] = [];
586
+ const objective = sanitizeCompactionStateText(recovery.objective, 200);
587
+ lines.push(`Workflow contract (${recovery.skill}): ${objective}`);
588
+ const accepted = recovery.scope.filter(item => item.kind === "accepted").slice(0, 8);
589
+ if (accepted.length > 0) {
590
+ lines.push(`Accepted scope: ${accepted.map(item => sanitizeCompactionStateText(item.text, 120)).join("; ")}`);
591
+ }
592
+ const nonGoals = recovery.scope.filter(item => item.kind === "non_goal").slice(0, 6);
593
+ if (nonGoals.length > 0) {
594
+ lines.push(`Non-goals: ${nonGoals.map(item => sanitizeCompactionStateText(item.text, 120)).join("; ")}`);
595
+ }
596
+ if (recovery.acceptanceCriteria.length > 0) {
597
+ lines.push(
598
+ `Acceptance criteria: ${recovery.acceptanceCriteria.map(item => sanitizeCompactionStateText(item, 120)).join("; ")}`,
599
+ );
600
+ }
601
+ if (recovery.currentGoal) {
602
+ const goal = recovery.currentGoal;
603
+ lines.push(
604
+ `Current goal: ${sanitizeCompactionStateText(goal.goalId, 40)} status=${sanitizeCompactionStateText(goal.status, 40)} ${sanitizeCompactionStateText(goal.objective, 120)}`,
605
+ );
606
+ }
607
+ const progress = recovery.progress;
608
+ const progressParts: string[] = [];
609
+ if (progress.totalGoals !== undefined) {
610
+ progressParts.push(`goals ${progress.completedGoals ?? 0}/${progress.totalGoals}`);
611
+ }
612
+ if (progress.outstandingGoals !== undefined) progressParts.push(`outstanding ${progress.outstandingGoals}`);
613
+ if (progress.latestCohortSourceHash) progressParts.push(`sourceHash ${progress.latestCohortSourceHash}`);
614
+ if (progressParts.length > 0) lines.push(`Progress: ${progressParts.join(", ")}`);
615
+ lines.push(
616
+ `Next action: ${recovery.nextAction.actionClass}${recovery.nextAction.goalId ? ` (${recovery.nextAction.goalId})` : ""}`,
617
+ );
618
+ if (recovery.provenance.sha256) lines.push(`Contract digest: ${recovery.provenance.sha256}`);
619
+ return lines;
620
+ }
621
+
622
+ /**
623
+ * #4560: post-compaction continuation for recognized active workflows.
624
+ * Returns undefined when no structured projection exists (generic
625
+ * auto-continue is preserved) or when every active workflow skill is
626
+ * continuation-inert (paused/terminal/unknown stay inert). The prompt keeps
627
+ * latest-user-intent supremacy and forbids silent scope expansion: any work
628
+ * beyond the accepted contract must be classified and recorded, never assumed.
629
+ */
630
+ function buildWorkflowRecoveryContinuationPrompt(
631
+ recovery: WorkflowRecoveryProjection | undefined,
632
+ activeSkills: ReadonlyArray<{ skill: string; phase: string }>,
633
+ ): string | undefined {
634
+ if (!recovery) return undefined;
635
+ const recognized = activeSkills.some(
636
+ entry => entry.skill === recovery.skill && !isWorkflowContinuationInert(entry.skill, entry.phase),
637
+ );
638
+ if (!recognized) return undefined;
639
+ const lines = [
640
+ "Compaction removed earlier conversation history. Resume the active workflow from its durable contract below — do not re-derive or expand scope from the summary.",
641
+ "",
642
+ "<workflow-recovery>",
643
+ ...renderWorkflowRecoveryContext(recovery),
644
+ "</workflow-recovery>",
645
+ "",
646
+ "Rules:",
647
+ "- Reload this contract before acting; the durable workflow state (.gjc session state, plans, goals, ledger receipts) is authoritative over any summary prose.",
648
+ "- Resume the stated next action class unless the user's latest message supersedes it; user intent always wins.",
649
+ "- Do not expand accepted scope. Work beyond the accepted scope/non-goals must be classified as new scope and explicitly recorded (durable blocker or steering), never silently accepted.",
650
+ "- Do not repeat already-verified review generations when the recorded source hash and evidence basis are unchanged; continue from recorded progress instead.",
651
+ "- If the same next action has already been attempted with no measurable progress (same source hash, no completed obligations, same blocker state), record a durable blocker/escalation note instead of looping.",
652
+ ];
653
+ if (recovery.zeroProgress?.stalled) {
654
+ lines.push(
655
+ `STALLED: durable progress has not changed across ${recovery.zeroProgress.unchangedObservations + 1} compaction recoveries. Do not repeat the same next action again. Record a durable blocker or escalate to the operator now.`,
656
+ );
657
+ }
658
+ return lines.join("\n");
495
659
  }
496
660
 
497
661
  /** Escape XML-ish metacharacters and flatten newlines so state text cannot break compaction prompt framing. */
@@ -657,12 +821,6 @@ export interface AgentSessionConfig {
657
821
  workerIntegrationRequest?: (signal: AbortSignal) => Promise<void>;
658
822
  /** Bound terminal worker-integration settlement for embedded hosts and deterministic lifecycle tests. */
659
823
  workerIntegrationTimeoutMs?: number;
660
- /**
661
- * Override the runtime-owned `gjc team` worker heartbeat reporter. Defaults to a
662
- * reporter derived from the team worker environment, or none outside a worker pane.
663
- */
664
- teamWorkerHeartbeatReporter?: GjcTeamWorkerHeartbeatReporter;
665
-
666
824
  /** Loaded skills (already discovered by SDK) */
667
825
  skills?: Skill[];
668
826
  /** Skill loading warnings (already captured by SDK) */
@@ -1050,6 +1208,8 @@ const PROVIDER_FIRST_EVENT_TIMEOUT_ERROR = "Provider stream timed out while wait
1050
1208
  const WRAPPED_PROVIDER_FIRST_EVENT_TIMEOUT_ERROR = `Error: ${PROVIDER_FIRST_EVENT_TIMEOUT_ERROR}`;
1051
1209
  const PROVIDER_FIRST_EVENT_TIMEOUT_WITHOUT_ARTICLE_ERROR = "Provider stream timed out while waiting for first event";
1052
1210
  const BARE_DEFAULT_CODEX_OVERLOAD_ERROR = /^Codex error event(?:: .*)? \(code=server_is_overloaded(?:, [^)]+)*\)$/;
1211
+ /** Anthropic's typed capacity-overload `error.type`, the only overload code admitted below. */
1212
+ const ANTHROPIC_OVERLOADED_ERROR_TYPE = "overloaded_error";
1053
1213
  const KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES = {
1054
1214
  "anthropic-messages": new Set([
1055
1215
  PROVIDER_FIRST_EVENT_TIMEOUT_ERROR,
@@ -1123,6 +1283,40 @@ function isBareDefaultCodexOverload(message: AssistantMessage): boolean {
1123
1283
  );
1124
1284
  }
1125
1285
 
1286
+ /**
1287
+ * True when the whole error message is Anthropic's own typed capacity-overload
1288
+ * envelope. The provider's `overloaded_error` can arrive as a statusless SSE
1289
+ * error event, so the envelope is the only structured evidence available: it is
1290
+ * parsed as JSON and both the outer `type` and the nested `error.type` must
1291
+ * match exactly. Prose is never inspected, so a message that merely mentions
1292
+ * being overloaded cannot authorize a replay.
1293
+ */
1294
+ function isAnthropicOverloadedEnvelope(errorMessage: string | undefined): boolean {
1295
+ if (!errorMessage) return false;
1296
+ const trimmed = errorMessage.trim();
1297
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
1298
+ let parsed: unknown;
1299
+ try {
1300
+ parsed = JSON.parse(trimmed);
1301
+ } catch {
1302
+ return false;
1303
+ }
1304
+ if (typeof parsed !== "object" || parsed === null) return false;
1305
+ if ((parsed as { type?: unknown }).type !== "error") return false;
1306
+ const error = (parsed as { error?: unknown }).error;
1307
+ if (typeof error !== "object" || error === null) return false;
1308
+ return (error as { type?: unknown }).type === ANTHROPIC_OVERLOADED_ERROR_TYPE;
1309
+ }
1310
+
1311
+ function isBareDefaultAnthropicOverload(message: AssistantMessage): boolean {
1312
+ return (
1313
+ message.api === "anthropic-messages" &&
1314
+ isAnthropicOverloadedEnvelope(message.errorMessage) &&
1315
+ !hasBareDefaultRetryDisqualifyingFacts(message) &&
1316
+ !assistantMessageHasVisibleOrToolContent(message)
1317
+ );
1318
+ }
1319
+
1126
1320
  function assistantMessageHasVisibleOrToolContent(message: AssistantMessage): boolean {
1127
1321
  return message.content.some(content => {
1128
1322
  if (content.type === "text") return content.text.length > 0;
@@ -1922,6 +2116,15 @@ export interface DefaultFallbackRuntimeState {
1922
2116
  const AGENT_CONTINUE_BUSY_RESCHEDULE_BASE_DELAY_MS = 100;
1923
2117
  const AGENT_CONTINUE_BUSY_RESCHEDULE_MAX_DELAY_MS = 5_000;
1924
2118
  const AGENT_CONTINUE_BUSY_MAX_RESCHEDULES = 50;
2119
+ /**
2120
+ * Maximum un-charged managed-fallback retries for escaped-non-ASCII tool-call
2121
+ * turns within one logical run. Each retry is a fresh loop with its own
2122
+ * in-loop resample budget, so without this cap a deterministic escaper loops
2123
+ * forever: the fallback chain never sees a charge to exhaust on. Matching the
2124
+ * agent loop's own per-loop budget (MAX_ESCAPED_NONASCII_RESAMPLES + 1 wire
2125
+ * attempts) keeps one steering retry plus one blind retry before the run ends.
2126
+ */
2127
+ const MAX_ESCAPED_NONASCII_MANAGED_RETRIES = 2;
1925
2128
 
1926
2129
  function agentContinueBusyRescheduleDelayMs(attempt: number): number {
1927
2130
  const exponential = AGENT_CONTINUE_BUSY_RESCHEDULE_BASE_DELAY_MS * 2 ** Math.max(0, attempt - 1);
@@ -2229,6 +2432,8 @@ export class AgentSession {
2229
2432
  #retryPromise: Promise<void> | undefined = undefined;
2230
2433
  #retryResolve: (() => void) | undefined = undefined;
2231
2434
  #defaultFallbackController: FallbackChainController | undefined;
2435
+ /** Managed escaped-non-ASCII retries issued for the current logical run. Bounded so a deterministic escaper cannot loop forever through un-charged fallback retries. */
2436
+ #escapedNonAsciiManagedRetries = 0;
2232
2437
  #overflowMaintenanceAttempts = 0;
2233
2438
  #defaultFallbackExhaustedLastTurn = false;
2234
2439
  #fallbackInvocationId = 0;
@@ -2269,7 +2474,7 @@ export class AgentSession {
2269
2474
  */
2270
2475
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
2271
2476
  readonly #disposeAsyncJobManager: boolean;
2272
- readonly #ownedMcpManager: MCPManager | undefined;
2477
+ #ownedMcpManager: MCPManager | undefined;
2273
2478
  #startupTurnBarrier: Promise<void> | undefined;
2274
2479
  #pendingPythonMessages: Array<{
2275
2480
  message: PythonExecutionMessage;
@@ -2316,12 +2521,8 @@ export class AgentSession {
2316
2521
  }
2317
2522
 
2318
2523
  #turnIndex = 0;
2319
- #workerIntegrationScheduler: WorkerIntegrationRequestScheduler;
2524
+ #workerIntegrationScheduler: WorkerIntegrationRequestScheduler | undefined;
2320
2525
  #workerIntegrationRequestedForTurn = false;
2321
- /** Runtime-owned team worker heartbeat; `undefined` outside a `gjc team` worker pane. */
2322
- #teamWorkerHeartbeat: GjcTeamWorkerHeartbeatReporter | undefined;
2323
- #teamWorkerTurnsInFlight = 0;
2324
- #unregisterTeamWorkerAsyncJobChange: (() => void) | undefined;
2325
2526
  // First-party internal before-agent-start contributors (not user hooks).
2326
2527
  #beforeAgentStartContributors: BeforeAgentStartContributor[] = [];
2327
2528
 
@@ -2378,6 +2579,8 @@ export class AgentSession {
2378
2579
  #workspaceTreeService: LazyService<WorkspaceTreeRuntime> | undefined;
2379
2580
  #onWorkspaceTreeReady: ((tree: WorkspaceTree) => void | Promise<void>) | undefined;
2380
2581
  #networkPrewarmService: LazyService<NetworkPrewarmRuntime> | undefined;
2582
+ /** Set by `applyRescopedCwdState`: forces the next turn to re-scan at the new cwd. */
2583
+ #pendingWorkspaceTreeRescope = false;
2381
2584
  /** Throttle cache for the per-turn volatile workspace-tree scan (see #buildVolatileProjectContextMessage). */
2382
2585
  #cachedWorkspaceTree: WorkspaceTree | undefined;
2383
2586
  #cachedWorkspaceTreeAt = 0;
@@ -2437,6 +2640,18 @@ export class AgentSession {
2437
2640
  * surface a red "Operation aborted" line; cleared by a later non-silent abort
2438
2641
  * or by `abort`'s safety net when no aborted message_end is produced. */
2439
2642
  #silentAbortPending = false;
2643
+ /** In-flight `abort()` unwind. Fresh prompts wait so they cannot steer into the dying turn. */
2644
+ #abortUnwind: Promise<void> | undefined;
2645
+ #abortEpoch = 0;
2646
+ /**
2647
+ * Monotonic count of ADMITTED abort requests, advanced synchronously on every
2648
+ * `#abortWithOutcome` entry — including an abort that shares an already
2649
+ * in-flight unwind rather than starting its own. A prompt that waits for an
2650
+ * unwind captures this value and refuses to resume if it advanced while it
2651
+ * waited: abort is terminal, so work admitted before the user's latest abort
2652
+ * must never start just because the physical unwind was shared.
2653
+ */
2654
+ #abortAdmissionEpoch = 0;
2440
2655
  /** Monotonic counter for `enqueueCustomMessageDisplay` tag generation;
2441
2656
  * combined with `Date.now()` so tags stay unique even across rapid
2442
2657
  * same-tick enqueues. */
@@ -2488,6 +2703,7 @@ export class AgentSession {
2488
2703
  nonEditDeterminations: 0,
2489
2704
  };
2490
2705
  #promptInFlightCount = 0;
2706
+ #inFlightGenerations: number[] = [];
2491
2707
  #agentEventHandlersInFlight = 0;
2492
2708
  #queuedExtensionEventCount = 0;
2493
2709
  #extensionTurnGeneration = 0;
@@ -2798,12 +3014,18 @@ export class AgentSession {
2798
3014
 
2799
3015
  #beginInFlight(): void {
2800
3016
  this.#promptInFlightCount++;
3017
+ this.#inFlightGenerations.push(this.#abortEpoch);
2801
3018
  if (this.#promptInFlightCount === 1) {
2802
3019
  this.#acquirePowerAssertion();
2803
- // Runtime-owned worker liveness follows Agent turns and background jobs
2804
- // in #refreshTeamWorkerHeartbeat(), including internally dispatched turns.
2805
3020
  }
2806
3021
  }
3022
+
3023
+ /** True while a live agent loop or a non-aborted in-flight prompt owns the session. */
3024
+ #isLiveTurnBusy(): boolean {
3025
+ if (this.agent.state.isStreaming) return true;
3026
+ if (!this.isStreaming) return false;
3027
+ return this.#inFlightGenerations.every(generation => generation === this.#abortEpoch);
3028
+ }
2807
3029
  /**
2808
3030
  * Allocate a FRESH prompt attempt/lineage for an allowed owned-completion
2809
3031
  * delivery (corrected turn semantics). The new turn gets a new attempt epoch
@@ -2853,6 +3075,27 @@ export class AgentSession {
2853
3075
  return signal.aborted || this.#promptGeneration !== generation;
2854
3076
  }
2855
3077
 
3078
+ /**
3079
+ * Wait for the in-flight abort unwind, then prove no LATER abort was admitted
3080
+ * while waiting. Overlapping aborts share one physical unwind, so awaiting
3081
+ * `#abortUnwind` alone cannot distinguish "the abort I raced" from "a second
3082
+ * abort the user issued after me". Returns false when the caller must not
3083
+ * resume; the caller then rejects as a cancelled preflight rather than
3084
+ * starting a successor the user already aborted.
3085
+ *
3086
+ * Loops because an abort admitted during the wait can install its own unwind:
3087
+ * the fence only opens once no unwind is in flight and the admission epoch has
3088
+ * stopped advancing.
3089
+ */
3090
+ async #awaitAbortUnwindFence(): Promise<boolean> {
3091
+ const admissionEpoch = this.#abortAdmissionEpoch;
3092
+ while (this.#abortUnwind !== undefined) {
3093
+ await this.#abortUnwind;
3094
+ if (this.#abortAdmissionEpoch !== admissionEpoch) return false;
3095
+ }
3096
+ return this.#abortAdmissionEpoch === admissionEpoch;
3097
+ }
3098
+
2856
3099
  #throwIfPromptPreflightCancelled(generation: number, signal: AbortSignal): void {
2857
3100
  if (this.#isPromptPreflightCancelled(generation, signal)) {
2858
3101
  throw promptPreflightCancelledError();
@@ -3068,6 +3311,44 @@ export class AgentSession {
3068
3311
  #wakeScopedSettlementWaiters(): void {
3069
3312
  for (const check of [...this.#scopedSettlementWaiters]) check();
3070
3313
  }
3314
+ #abortedTurnTerminalPending(): boolean {
3315
+ return (
3316
+ this.#promptInFlightCount > 0 ||
3317
+ this.#agentEventHandlersInFlight > 0 ||
3318
+ this.#pendingAgentEndEmit !== undefined ||
3319
+ this.#agentEndPublicationInFlight > 0
3320
+ );
3321
+ }
3322
+
3323
+ /** Wait for the aborted turn's agent_end handler and publication, not just agent idle. */
3324
+ async #awaitAbortedTurnTerminal(): Promise<void> {
3325
+ await Promise.resolve();
3326
+ await this.#agentEndHandlingPromise;
3327
+ this.#flushPendingAgentEnd();
3328
+ await this.#agentEndPublicationPromise;
3329
+ while (this.#abortedTurnTerminalPending()) {
3330
+ const wake = Promise.withResolvers<void>();
3331
+ const check = () => {
3332
+ if (!this.#abortedTurnTerminalPending()) wake.resolve();
3333
+ };
3334
+ this.#scopedSettlementWaiters.add(check);
3335
+ check();
3336
+ try {
3337
+ // Wait ONLY on the state-change wake. Racing the agent_end handling or
3338
+ // publication promise here spins: an in-flight event handler that is not
3339
+ // an agent_end (a `message_end` extension handler, say) leaves both of
3340
+ // those already settled, so the race resolves immediately and the loop
3341
+ // re-runs as a microtask, starving timers until the handler finishes.
3342
+ // Every counter this loop reads wakes the scoped waiters when it drops.
3343
+ await wake.promise;
3344
+ } finally {
3345
+ this.#scopedSettlementWaiters.delete(check);
3346
+ }
3347
+ this.#flushPendingAgentEnd();
3348
+ await this.#agentEndHandlingPromise;
3349
+ await this.#agentEndPublicationPromise;
3350
+ }
3351
+ }
3071
3352
 
3072
3353
  /**
3073
3354
  * Wait for session settlement. `ignoreSelectionFenceGeneration` carries the
@@ -3107,10 +3388,10 @@ export class AgentSession {
3107
3388
 
3108
3389
  #endInFlight(): unknown {
3109
3390
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
3391
+ if (this.#inFlightGenerations.length > 0) this.#inFlightGenerations.shift();
3110
3392
  if (this.#promptInFlightCount !== 0) return undefined;
3111
3393
 
3112
3394
  this.#releasePowerAssertion();
3113
- this.#refreshTeamWorkerHeartbeat();
3114
3395
  let flushError: unknown;
3115
3396
  try {
3116
3397
  // The turn is over, so nothing can split a tool_use/tool_result pair any
@@ -3168,17 +3449,6 @@ export class AgentSession {
3168
3449
  if (errors.length > 1) throw new AggregateError(errors, "Multiple deferred prompt messages failed to flush");
3169
3450
  }
3170
3451
 
3171
- #refreshTeamWorkerHeartbeat(): void {
3172
- const hasOwnedBackgroundJob =
3173
- this.#agentId !== undefined &&
3174
- (this.#ownedAsyncJobManager?.getRunningJobs({ ownerId: this.#agentId }).length ?? 0) > 0;
3175
- if (this.#teamWorkerTurnsInFlight > 0 || hasOwnedBackgroundJob) {
3176
- this.#teamWorkerHeartbeat?.start();
3177
- } else {
3178
- this.#teamWorkerHeartbeat?.stop();
3179
- }
3180
- }
3181
-
3182
3452
  #flushPendingAgentEnd(): void {
3183
3453
  if (
3184
3454
  this.#promptInFlightCount > 0 ||
@@ -3280,25 +3550,11 @@ export class AgentSession {
3280
3550
  }
3281
3551
  });
3282
3552
  this.memoryBackend = config.memoryBackend ?? createMemoryBackendService(this.settings);
3283
- this.#workerIntegrationScheduler = new WorkerIntegrationRequestScheduler(
3284
- config.workerIntegrationRequest ??
3285
- (async signal => {
3286
- await requestGjcWorkerIntegrationAttempt(this.sessionManager.getCwd(), process.env, { signal }).catch(
3287
- error => {
3288
- logger.warn("GJC team worker integration request failed", { error: String(error) });
3289
- },
3290
- );
3291
- }),
3292
- config.workerIntegrationTimeoutMs,
3293
- );
3294
- // One publisher per worker process: subagent sessions share this process and its
3295
- // team env, so letting each create a reporter would run N timers writing the same
3296
- // record. The top-level session owns the pane's liveness.
3297
- this.#teamWorkerHeartbeat =
3298
- config.teamWorkerHeartbeatReporter ??
3299
- ((config.taskDepth ?? 0) === 0
3300
- ? GjcTeamWorkerHeartbeatReporter.forProcess(() => this.sessionManager.getCwd())
3301
- : undefined);
3553
+ // Worker integration is an injected seam only: hosts that need turn-end
3554
+ // integration supply the request, and no scheduler exists otherwise.
3555
+ this.#workerIntegrationScheduler = config.workerIntegrationRequest
3556
+ ? new WorkerIntegrationRequestScheduler(config.workerIntegrationRequest, config.workerIntegrationTimeoutMs)
3557
+ : undefined;
3302
3558
  this.notificationSessionController = config.notificationSessionController;
3303
3559
  this.taskDepth = config.taskDepth ?? 0;
3304
3560
  this.#workflowGatePublication = config.workflowGatePublication ?? "endpoint";
@@ -3542,9 +3798,6 @@ export class AgentSession {
3542
3798
  this.#ttsrManager = config.ttsrManager;
3543
3799
  this.#obfuscator = config.obfuscator;
3544
3800
  this.#agentId = config.agentId;
3545
- this.#unregisterTeamWorkerAsyncJobChange = this.#ownedAsyncJobManager?.onChange(() => {
3546
- this.#refreshTeamWorkerHeartbeat();
3547
- });
3548
3801
  this.#agentRegistry = config.agentRegistry;
3549
3802
  this.#providerSessionId = config.providerSessionId;
3550
3803
  this.#credentialSessionId = config.credentialSessionId;
@@ -3768,6 +4021,61 @@ export class AgentSession {
3768
4021
  ...(this.#activeSkillState.sessionId ? { session_id: this.#activeSkillState.sessionId } : {}),
3769
4022
  };
3770
4023
  }
4024
+ /**
4025
+ * Live prompt marker or restored durable workflow — the effective state the
4026
+ * cwd-local mutation guard must honor after resume.
4027
+ */
4028
+ getEffectiveActiveWorkflowSkillState(): { skill: string; sessionId: string } | undefined {
4029
+ const currentSessionId = this.sessionManager.getSessionId();
4030
+ const inMemory = this.#activeSkillState;
4031
+ if (
4032
+ inMemory &&
4033
+ (!inMemory.sessionId || inMemory.sessionId === currentSessionId) &&
4034
+ isCanonicalGjcWorkflowSkill(inMemory.skill)
4035
+ ) {
4036
+ return { skill: inMemory.skill, sessionId: inMemory.sessionId ?? currentSessionId };
4037
+ }
4038
+ if (
4039
+ this.#restoredWorkflowSkillState?.sessionId === currentSessionId &&
4040
+ isCanonicalGjcWorkflowSkill(this.#restoredWorkflowSkillState.skill)
4041
+ ) {
4042
+ return this.#restoredWorkflowSkillState;
4043
+ }
4044
+ return undefined;
4045
+ }
4046
+
4047
+ /** Replace the session-owned MCP manager after a cwd rescope. */
4048
+ async replaceOwnedMcpManager(next: MCPManager | undefined): Promise<void> {
4049
+ const previous = this.#ownedMcpManager;
4050
+ if (previous && previous !== next) {
4051
+ await previous.disconnectAll().catch(() => {});
4052
+ if (MCPManager.instance() === previous) MCPManager.setInstance(undefined);
4053
+ }
4054
+ this.#ownedMcpManager = next;
4055
+ if (next && MCPManager.instance() === undefined) MCPManager.setInstance(next);
4056
+ }
4057
+
4058
+ /** Swap named custom/project tools after a cwd rescope. */
4059
+ async replaceNamedCustomTools(previousNames: readonly string[], nextTools: CustomTool[]): Promise<void> {
4060
+ const previous = new Set(previousNames);
4061
+ const previousActive = this.getActiveToolNames();
4062
+ for (const name of previous) this.#toolRegistry.delete(name);
4063
+ const getCustomToolContext = () => this.#getCustomToolContext();
4064
+ const added: string[] = [];
4065
+ for (const customTool of nextTools) {
4066
+ const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool;
4067
+ const finalTool = (
4068
+ this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped
4069
+ ) as AgentTool;
4070
+ this.#toolRegistry.set(finalTool.name, finalTool);
4071
+ added.push(finalTool.name);
4072
+ }
4073
+ this.#invalidateDiscoveryCaches();
4074
+ await this.#applyActiveToolsByName([
4075
+ ...previousActive.filter(name => !previous.has(name)),
4076
+ ...added.filter(name => !previous.has(name) || previousActive.includes(name)),
4077
+ ]);
4078
+ }
3771
4079
 
3772
4080
  /** Best-effort accessor for the active skill's `current_phase` field from
3773
4081
  * its persisted mode-state file. Used by the `skill` tool to enforce the
@@ -3798,13 +4106,7 @@ export class AgentSession {
3798
4106
  /** Provider-facing ask metadata must expose only the active deep-interview phase. */
3799
4107
  getDeepInterviewAskStage(): "topology" | "post-topology" | undefined {
3800
4108
  const currentSessionId = this.sessionManager.getSessionId();
3801
- const inMemory = this.#activeSkillState;
3802
- const active =
3803
- inMemory && (!inMemory.sessionId || inMemory.sessionId === currentSessionId)
3804
- ? inMemory
3805
- : this.#restoredWorkflowSkillState?.sessionId === currentSessionId
3806
- ? this.#restoredWorkflowSkillState
3807
- : undefined;
4109
+ const active = this.getEffectiveActiveWorkflowSkillState();
3808
4110
  if (active?.skill !== "deep-interview") return undefined;
3809
4111
  try {
3810
4112
  assertNonEmptyGjcSessionId(currentSessionId, "AgentSession.getDeepInterviewAskStage");
@@ -4589,10 +4891,19 @@ export class AgentSession {
4589
4891
  if (event.type === "agent_end" && !maintenanceCheckpoint) {
4590
4892
  if (terminalOwner ? !terminalClaim?.ok : this.activePromptHandle !== undefined) return;
4591
4893
  }
4592
- // A prompt-owned event with a captured handle must have obtained an exact
4593
- // producer lease before its handler was created. Failed ownership never
4594
- // schedules prompt work or falls back to a successor's handle.
4595
- if (activePromptHandle && !eventLease) return;
4894
+ // Terminal publication is ownership-sensitive and is handled above. A
4895
+ // message/turn lifecycle event is different: its canonical transcript
4896
+ // append is the evidence needed to recover the run. Do not drop that
4897
+ // evidence merely because the best-effort post-prompt lease could not be
4898
+ // acquired. The current-handle check still fences a genuinely late event
4899
+ // from a replaced run without allowing a successor handle to be borrowed.
4900
+ if (
4901
+ activePromptHandle &&
4902
+ !eventLease &&
4903
+ event.type !== "agent_end" &&
4904
+ this.activePromptHandle !== activePromptHandle
4905
+ )
4906
+ return;
4596
4907
  if (eventLease && event.type === "agent_end")
4597
4908
  this.#postPromptLeases.set(eventLease.resourceRunId, eventLease);
4598
4909
  if (eventLease) {
@@ -4622,6 +4933,10 @@ export class AgentSession {
4622
4933
  this.#agentEventHandlersInFlight = Math.max(0, this.#agentEventHandlersInFlight - 1);
4623
4934
  this.#flushPendingAgentEnd();
4624
4935
  agentEndHandled?.resolve();
4936
+ // Every other in-flight counter republishes settlement when it drops; this
4937
+ // one is read by the abort drain and the session settlement waiters too,
4938
+ // so a handler finishing has to wake them or they wait on nothing.
4939
+ this.#resolveSessionSettlement();
4625
4940
  }
4626
4941
  })();
4627
4942
  if (eventLease) eventLease.track("post_prompt", "agent-session-event", handler);
@@ -4678,18 +4993,6 @@ export class AgentSession {
4678
4993
  } else if (event.type === "turn_end") {
4679
4994
  this.#closedExtensionTurnGeneration = this.#extensionTurnGeneration;
4680
4995
  }
4681
- if (event.type === "turn_start") {
4682
- this.#teamWorkerTurnsInFlight++;
4683
- this.#refreshTeamWorkerHeartbeat();
4684
- } else if (event.type === "turn_end") {
4685
- this.#teamWorkerTurnsInFlight = Math.max(0, this.#teamWorkerTurnsInFlight - 1);
4686
- this.#refreshTeamWorkerHeartbeat();
4687
- } else if (event.type === "agent_end") {
4688
- // Failed Agent runs can emit agent_end without turn_end. Reset rather than
4689
- // leaking a positive count that would report an idle/failed worker forever.
4690
- this.#teamWorkerTurnsInFlight = 0;
4691
- this.#refreshTeamWorkerHeartbeat();
4692
- }
4693
4996
  if (event.type === "message_update") {
4694
4997
  // Fast path: message_update maps to no sidecar state, so we must not
4695
4998
  // build the persistRuntimeState closure here (per-token hot path).
@@ -6197,14 +6500,24 @@ export class AgentSession {
6197
6500
  return;
6198
6501
  }
6199
6502
  if (!(await continuationAuthorized(signal))) return;
6503
+ // #4560: recognized active workflows resume from their
6504
+ // durable structured contract instead of the generic
6505
+ // prompt; unknown/paused/terminal workflows keep the
6506
+ // generic continuation and latest-user-intent supremacy.
6507
+ const recoverySnapshot = await this.#compactionStateSnapshot();
6508
+ const recoveryPrompt = buildWorkflowRecoveryContinuationPrompt(
6509
+ recoverySnapshot.workflowRecovery,
6510
+ recoverySnapshot.activeSkills,
6511
+ );
6512
+ const promptText = recoveryPrompt ?? autoContinuePrompt;
6200
6513
  await this.#promptWithMessage(
6201
6514
  {
6202
6515
  role: "developer",
6203
- content: [{ type: "text", text: autoContinuePrompt }],
6516
+ content: [{ type: "text", text: promptText }],
6204
6517
  attribution: "agent",
6205
6518
  timestamp: Date.now(),
6206
6519
  },
6207
- autoContinuePrompt,
6520
+ promptText,
6208
6521
  {
6209
6522
  skipPostPromptRecoveryWait: true,
6210
6523
  skipCompactionCheck: true,
@@ -6981,11 +7294,11 @@ export class AgentSession {
6981
7294
  }
6982
7295
 
6983
7296
  #requestWorkerIntegrationAttempt(): void {
6984
- this.#workerIntegrationScheduler.enqueue();
7297
+ this.#workerIntegrationScheduler?.enqueue();
6985
7298
  }
6986
7299
 
6987
7300
  async #flushWorkerIntegrationAttempt(): Promise<void> {
6988
- await this.#workerIntegrationScheduler.flush();
7301
+ await this.#workerIntegrationScheduler?.flush();
6989
7302
  }
6990
7303
 
6991
7304
  async #flushWorkerIntegrationForAgentEnd(): Promise<void> {
@@ -7377,6 +7690,7 @@ export class AgentSession {
7377
7690
  }
7378
7691
 
7379
7692
  async #dispose(): Promise<void> {
7693
+ await this.sessionManager.joinCwdTransition();
7380
7694
  const admissionClosed = this.#closeSessionAdmission();
7381
7695
  this.#isDisposed = true;
7382
7696
  // Reject new direct Python starts as soon as disposal begins (synchronously,
@@ -7494,9 +7808,6 @@ export class AgentSession {
7494
7808
  await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
7495
7809
  await disposeVmContextsByOwner(this.#evalKernelOwnerId);
7496
7810
  this.#releasePowerAssertion();
7497
- this.#unregisterTeamWorkerAsyncJobChange?.();
7498
- this.#unregisterTeamWorkerAsyncJobChange = undefined;
7499
- this.#teamWorkerHeartbeat?.dispose();
7500
7811
  // Disconnect the agent event listener BEFORE closing session resources so a late
7501
7812
  // provider/tool message_end cannot append to the closing SessionManager.
7502
7813
  this.#disconnectFromAgent();
@@ -7547,9 +7858,6 @@ export class AgentSession {
7547
7858
  this.#unregisterResourceGc = undefined;
7548
7859
  this.#unregisterSessionMemorySettings?.();
7549
7860
  this.#unregisterSessionMemorySettings = undefined;
7550
- this.#unregisterTeamWorkerAsyncJobChange?.();
7551
- this.#unregisterTeamWorkerAsyncJobChange = undefined;
7552
- this.#teamWorkerHeartbeat?.dispose();
7553
7861
  const work = Promise.allSettled([
7554
7862
  // kill:true so a forced exit also reaps spawned-app Chrome we own (headless
7555
7863
  // always closes; connected/attached browsers only disconnect — never killed).
@@ -7562,15 +7870,22 @@ export class AgentSession {
7562
7870
  disposeVmContextsByOwner(kernelOwnerId).catch((error: unknown) =>
7563
7871
  logger.warn("signal teardown: disposeVmContextsByOwner failed", { error }),
7564
7872
  ),
7565
- // Tool-owned resources (e.g. an autoresearch mission's persistent Python
7566
- // kernel) register their own disposer and are keyed by an owner id that is
7567
- // NOT #evalKernelOwnerId, so the two disposals above never reach them. Drain
7568
- // them here inside the same budget: without this a signal exit orphans the
7569
- // subprocess. #runToolSessionCleanups already clears the set, so the graceful
7570
- // dispose path running first makes this a no-op rather than a double free.
7873
+ // Tool-owned resources (e.g. the python tool's persistent per-session
7874
+ // kernel) register their own disposer keyed by an owner id that is NOT
7875
+ // #evalKernelOwnerId, so the two disposals above never reach them. Drain
7876
+ // BOTH cleanup sets here inside the same budget: the SDK binds a tool's
7877
+ // `registerSessionCleanup` to registerToolSessionTransitionCleanup, so a
7878
+ // python REPL kernel lands in the transition set while a directly-registered
7879
+ // cleanup lands in the other. Graceful dispose drains both; draining only
7880
+ // one here left signal exit orphaning the subprocess. Each runner clears
7881
+ // its set, so a graceful dispose running first makes this a no-op rather
7882
+ // than a double free.
7571
7883
  this.#runToolSessionCleanups().catch((error: unknown) =>
7572
7884
  logger.warn("signal teardown: tool session cleanups failed", { error }),
7573
7885
  ),
7886
+ this.#runToolSessionTransitionCleanups().catch((error: unknown) =>
7887
+ logger.warn("signal teardown: tool session transition cleanups failed", { error }),
7888
+ ),
7574
7889
  ]);
7575
7890
  await Promise.race([work, Bun.sleep(timeoutMs)]);
7576
7891
  }
@@ -8204,6 +8519,35 @@ export class AgentSession {
8204
8519
  },
8205
8520
  }) as T;
8206
8521
  }
8522
+ #wrapToolForCwdTransitionFence<T extends AgentTool>(tool: T): T {
8523
+ if (tool.name === "move_session") return tool;
8524
+ return new Proxy(tool, {
8525
+ get: (target, prop) => {
8526
+ if (prop !== "execute") return Reflect.get(target, prop, target);
8527
+ return async (
8528
+ toolCallId: string,
8529
+ args: unknown,
8530
+ signal: AbortSignal | undefined,
8531
+ onUpdate: never,
8532
+ ctx: never,
8533
+ ) => {
8534
+ return await this.sessionManager.runWithCwdReadLease(async () => {
8535
+ const admittedGeneration = this.sessionManager.getCwdGeneration();
8536
+ const result = await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
8537
+ // The lease keeps writers out for the whole execution, so this can
8538
+ // only trip if a caller bypassed the lease; surface it rather than
8539
+ // returning a result computed against a retired cwd.
8540
+ if (this.sessionManager.getCwdGeneration() !== admittedGeneration) {
8541
+ throw new Error(
8542
+ "Session working directory changed while this tool executed; retry against the new cwd.",
8543
+ );
8544
+ }
8545
+ return result;
8546
+ });
8547
+ };
8548
+ },
8549
+ }) as T;
8550
+ }
8207
8551
 
8208
8552
  /**
8209
8553
  /** Wrap a tool with the workflow mutation guard before permissions or execution. */
@@ -8251,16 +8595,18 @@ export class AgentSession {
8251
8595
  let wrappersByVersion = this.#guardedToolWrapperCache.get(tool);
8252
8596
  const cached = wrappersByVersion?.get(cacheKey);
8253
8597
  if (cached) return cached as T;
8254
- const wrapped = this.#wrapToolForWorkflowMutationGuard(
8255
- this.#wrapToolForAcpPermission(
8256
- guardToolForUltragoalAsk(
8257
- tool,
8258
- () => this.sessionManager.getCwd(),
8259
- () => ({
8260
- activeSkillState: this.getActiveSkillState(),
8261
- sessionId: this.sessionManager.getSessionId(),
8262
- }),
8263
- () => this.getSessionAgentDir(),
8598
+ const wrapped = this.#wrapToolForCwdTransitionFence(
8599
+ this.#wrapToolForWorkflowMutationGuard(
8600
+ this.#wrapToolForAcpPermission(
8601
+ guardToolForUltragoalAsk(
8602
+ tool,
8603
+ () => this.sessionManager.getCwd(),
8604
+ () => ({
8605
+ activeSkillState: this.getActiveSkillState(),
8606
+ sessionId: this.sessionManager.getSessionId(),
8607
+ }),
8608
+ () => this.getSessionAgentDir(),
8609
+ ),
8264
8610
  ),
8265
8611
  ),
8266
8612
  );
@@ -9771,18 +10117,26 @@ export class AgentSession {
9771
10117
  this.#cachedWorkspaceTreeAt = Date.now();
9772
10118
  this.#initialWorkspaceTree = undefined;
9773
10119
  includeTree = this.#cachedWorkspaceTree;
9774
- } else if (Date.now() - this.#cachedWorkspaceTreeAt >= VOLATILE_TREE_TTL_MS) {
10120
+ } else if (
10121
+ this.#pendingWorkspaceTreeRescope ||
10122
+ Date.now() - this.#cachedWorkspaceTreeAt >= VOLATILE_TREE_TTL_MS
10123
+ ) {
10124
+ // A rescope retires the cached tree regardless of TTL, and must re-scan
10125
+ // rather than reuse the launch-root snapshot the service already holds.
10126
+ const rescoped = this.#pendingWorkspaceTreeRescope;
10127
+ this.#pendingWorkspaceTreeRescope = false;
9775
10128
  if (this.#workspaceTreeService) {
9776
- const firstWorkspaceTree = this.#cachedWorkspaceTreeAt === 0;
10129
+ const firstWorkspaceTree = this.#cachedWorkspaceTreeAt === 0 && !rescoped;
9777
10130
  const runtime = await this.#workspaceTreeService.get("first-turn-barrier");
9778
10131
  this.#cachedWorkspaceTree = firstWorkspaceTree ? runtime.snapshot : await runtime.refresh();
9779
- publishStableWorkspaceTree = firstWorkspaceTree;
10132
+ publishStableWorkspaceTree = firstWorkspaceTree || rescoped;
9780
10133
  } else {
9781
10134
  try {
9782
10135
  this.#cachedWorkspaceTree = await buildWorkspaceTree(cwd, { timeoutMs: 5000 });
9783
10136
  } catch {
9784
10137
  this.#cachedWorkspaceTree = undefined;
9785
10138
  }
10139
+ publishStableWorkspaceTree = rescoped;
9786
10140
  }
9787
10141
  this.#cachedWorkspaceTreeAt = Date.now();
9788
10142
  includeTree = this.#cachedWorkspaceTree;
@@ -9867,18 +10221,31 @@ export class AgentSession {
9867
10221
  assertImagePlaceholdersHavePayload(expandedText, options?.images);
9868
10222
  const workflowIntentDiff = options?.synthetic ? null : buildWorkflowIntentDiff(expandedText);
9869
10223
  const claimsGenuineUserIntent = !options?.synthetic && options?.attribution !== "agent";
9870
- const admissionGeneration = this.#promptGeneration;
9871
- const admissionSignal = options?.preflightSignal
10224
+ let admissionGeneration = this.#promptGeneration;
10225
+ let admissionSignal = options?.preflightSignal
9872
10226
  ? AbortSignal.any([this.#promptPreflightAbortController.signal, options.preflightSignal])
9873
10227
  : this.#promptPreflightAbortController.signal;
9874
10228
  if (this.#pendingSelectionFences > 0) {
9875
10229
  await awaitPromptInvocationPreflight(this.#selectionFenceTail, admissionSignal);
9876
10230
  }
10231
+ let waitedForAbortUnwind = false;
10232
+ if (this.#abortUnwind) {
10233
+ // A LATER abort admitted while this prompt waited keeps abort terminal:
10234
+ // the successor is refused instead of refreshing its generation and
10235
+ // starting work the user already aborted twice.
10236
+ if (!(await this.#awaitAbortUnwindFence())) throw promptPreflightCancelledError();
10237
+ admissionGeneration = this.#promptGeneration;
10238
+ admissionSignal = options?.preflightSignal
10239
+ ? AbortSignal.any([this.#promptPreflightAbortController.signal, options.preflightSignal])
10240
+ : this.#promptPreflightAbortController.signal;
10241
+ waitedForAbortUnwind = true;
10242
+ }
9877
10243
  const deepInterviewUserIntentEpoch =
9878
10244
  claimsGenuineUserIntent && !this.isStreaming ? this.#claimDeepInterviewUserIntent() : undefined;
9879
10245
 
9880
- // If streaming, queue via steer() or followUp() based on option
9881
- if (this.isStreaming) {
10246
+ // If streaming, queue via steer() or followUp() based on option.
10247
+ // Abort unwind is awaited above so a successor is not busy against leftover in-flight.
10248
+ if (this.#isLiveTurnBusy() && !waitedForAbortUnwind) {
9882
10249
  if (!options?.streamingBehavior) {
9883
10250
  throw new AgentBusyError();
9884
10251
  }
@@ -9972,7 +10339,7 @@ export class AgentSession {
9972
10339
  // workflow skills can always call it.
9973
10340
  if (active && isCanonicalGjcWorkflowSkill(skill)) this.#attachAskTool();
9974
10341
  const sessionId = this.sessionManager.getSessionId();
9975
- // Canonical GJC workflow skills (deep-interview, ralplan, ultragoal, team)
10342
+ // Canonical GJC workflow skills (deep-interview, ralplan, ultragoal, autoresearch)
9976
10343
  // own their `.gjc/state/skill-active-state.json` row through the
9977
10344
  // `gjc state handoff` and `gjc state clear` runtime verbs. The prompt
9978
10345
  // observer must not overwrite an existing row (that clobbered handoff
@@ -10989,7 +11356,7 @@ export class AgentSession {
10989
11356
  if (this.isRetrying) return false;
10990
11357
  const messages = this.agent.state.messages;
10991
11358
  const last = messages[messages.length - 1];
10992
- return last?.role === "assistant";
11359
+ return last?.role === "assistant" || last?.role === "bashExecution" || last?.role === "pythonExecution";
10993
11360
  }
10994
11361
 
10995
11362
  queueDeferredMessage(message: CustomMessage): void {
@@ -11440,10 +11807,21 @@ export class AgentSession {
11440
11807
  if (images.length === 0) images = undefined;
11441
11808
  }
11442
11809
 
11443
- const admissionSignal = options?.preflightSignal
11810
+ let admissionSignal = options?.preflightSignal
11444
11811
  ? AbortSignal.any([this.#promptPreflightAbortController.signal, options.preflightSignal])
11445
11812
  : this.#promptPreflightAbortController.signal;
11446
- const preflightCancellationGeneration = this.#promptPreflightCancellationGeneration;
11813
+ let preflightCancellationGeneration = this.#promptPreflightCancellationGeneration;
11814
+ let waitedForAbortUnwind = false;
11815
+ if (this.#abortUnwind && options?.deliverAs === undefined) {
11816
+ // Same terminal-abort fence as `prompt()`: an overlapping second abort
11817
+ // refuses this retained submission instead of letting it start late.
11818
+ if (!(await this.#awaitAbortUnwindFence())) throw promptPreflightCancelledError();
11819
+ preflightCancellationGeneration = this.#promptPreflightCancellationGeneration;
11820
+ admissionSignal = options?.preflightSignal
11821
+ ? AbortSignal.any([this.#promptPreflightAbortController.signal, options.preflightSignal])
11822
+ : this.#promptPreflightAbortController.signal;
11823
+ waitedForAbortUnwind = true;
11824
+ }
11447
11825
  // Classify and reserve follow-up order synchronously, before any await
11448
11826
  // (selection fence or durable acceptance): a follow-up dispatch that is
11449
11827
  // not yet durably enqueued must already count as ahead, or a later plain
@@ -11451,7 +11829,14 @@ export class AgentSession {
11451
11829
  // delivery/steering and overtake it. The reservation is released once
11452
11830
  // the durable enqueue settles either way, so a rejected or cancelled
11453
11831
  // acceptance leaves no phantom ordering behind.
11454
- const queuedPlainPrompt = options?.queuedAtDispatch === true && options.deliverAs === undefined;
11832
+ // A busy SDK dispatch is only queued while the agent loop is live.
11833
+ // Abort unwind is not a live loop: queuedAtDispatch from that window
11834
+ // must not divert a fresh prompt into the dying turn's steer queue.
11835
+ // Keep the original busy-dispatch bit for promotion/ownership: after
11836
+ // unwind the successor starts its own turn and must fire onQueuedPromoted
11837
+ // so SDK pending correlation is not dropped.
11838
+ const dispatchedWhileBusy = options?.queuedAtDispatch === true && options.deliverAs === undefined;
11839
+ const queuedPlainPrompt = dispatchedWhileBusy;
11455
11840
  const hasFollowUpAhead = (): boolean =>
11456
11841
  this.#activeFollowUpReservationEpochs.size > 0 ||
11457
11842
  this.agent.snapshotFollowUp().length > 0 ||
@@ -11462,6 +11847,7 @@ export class AgentSession {
11462
11847
  !followUpAheadAtReservation &&
11463
11848
  !this.agent.state.isStreaming &&
11464
11849
  !this.#canAutoContinueForSteer();
11850
+ const promoteAfterAbortUnwind = waitedForAbortUnwind && dispatchedWhileBusy && !followUpAheadAtReservation;
11465
11851
  const deliverAs =
11466
11852
  options?.deliverAs ??
11467
11853
  (queuedPlainPrompt
@@ -11539,7 +11925,9 @@ export class AgentSession {
11539
11925
  // Compaction is intentionally NOT diverted here: prompt() handles an
11540
11926
  // in-flight compaction internally, and #queueSteer would otherwise park
11541
11927
  // the message in the steering queue with no turn to consume it.
11542
- if (this.isStreaming) {
11928
+ // Abort unwind is awaited before classification so a successor is not
11929
+ // parked in the dying turn's steer queue.
11930
+ if (this.#isLiveTurnBusy() && !waitedForAbortUnwind) {
11543
11931
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
11544
11932
  assertPreflightStillOpen();
11545
11933
  await this.#queueSteer(text, images, {
@@ -11554,7 +11942,7 @@ export class AgentSession {
11554
11942
  // Use prompt() with expandPromptTemplates: false to skip command handling and template expansion
11555
11943
  let queuedPromotionFired = false;
11556
11944
  const fireQueuedPromotion = () => {
11557
- if (!freshAtReservation || queuedPromotionFired) return;
11945
+ if ((!freshAtReservation && !promoteAfterAbortUnwind) || queuedPromotionFired) return;
11558
11946
  queuedPromotionFired = true;
11559
11947
  options?.onQueuedPromoted?.();
11560
11948
  };
@@ -11566,7 +11954,7 @@ export class AgentSession {
11566
11954
  fireQueuedPromotion();
11567
11955
  },
11568
11956
  onPreflightAcceptCommit:
11569
- options?.onPreflightAcceptCommit || freshAtReservation
11957
+ options?.onPreflightAcceptCommit || freshAtReservation || promoteAfterAbortUnwind
11570
11958
  ? async () => {
11571
11959
  if (options?.onPreflightAcceptCommit) await options.onPreflightAcceptCommit();
11572
11960
  else options?.onPreflightAccepted?.();
@@ -11599,6 +11987,21 @@ export class AgentSession {
11599
11987
  get queuedMessageCount(): number {
11600
11988
  return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
11601
11989
  }
11990
+ /**
11991
+ * Number of pending messages a user-facing drain can actually deliver back:
11992
+ * exactly the steering and follow-up queues that `clearQueue()`,
11993
+ * `popLastQueuedMessage()`, and `getQueuedMessageEntries()` operate on.
11994
+ *
11995
+ * Hidden next-turn context is deliberately excluded. Those entries are
11996
+ * authored by the agent (e.g. a `todo_write` failure reminder queued with
11997
+ * `deliverAs: "nextTurn"` and no `triggerTurn`), are never returned by the
11998
+ * drain handlers, and deliberately survive turn completion — so a UI gate
11999
+ * that counted them would report permanently pending work that no key press
12000
+ * can clear, locking the user out of their own input (#4741).
12001
+ */
12002
+ get drainableQueuedMessageCount(): number {
12003
+ return this.#steeringMessages.length + this.#followUpMessages.length;
12004
+ }
11602
12005
  /** Typed pending-message counts per queue (steering, follow-up, next-turn). */
11603
12006
  get pendingMessageCounts(): { steering: number; followUp: number; nextTurn: number } {
11604
12007
  return {
@@ -11728,6 +12131,27 @@ export class AgentSession {
11728
12131
  return this.#skills;
11729
12132
  }
11730
12133
 
12134
+ /**
12135
+ * Install the skill set discovered at a newly rescoped cwd (`move_session`).
12136
+ * Project-scoped skills belong to the directory they were discovered in, so
12137
+ * they must not survive a move out of it.
12138
+ */
12139
+ async replaceSkills(skills: Skill[]): Promise<void> {
12140
+ this.#skills = skills;
12141
+ await this.refreshBaseSystemPrompt();
12142
+ }
12143
+
12144
+ /**
12145
+ * Retire the cached workspace tree after a rescope so the next turn re-scans
12146
+ * at the new cwd instead of re-presenting the abandoned launcher root.
12147
+ */
12148
+ retireWorkspaceTreeForRescope(): void {
12149
+ this.#initialWorkspaceTree = undefined;
12150
+ this.#cachedWorkspaceTree = undefined;
12151
+ this.#cachedWorkspaceTreeAt = 0;
12152
+ this.#pendingWorkspaceTreeRescope = true;
12153
+ }
12154
+
11731
12155
  /** Skill loading warnings captured by SDK */
11732
12156
  get skillWarnings(): readonly SkillWarning[] {
11733
12157
  return this.#skillWarnings;
@@ -11751,13 +12175,16 @@ export class AgentSession {
11751
12175
  this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
11752
12176
  }
11753
12177
 
11754
- async #compactionStateSnapshot(): Promise<CompactionStateSnapshot> {
12178
+ async #compactionStateSnapshot(
12179
+ options: { trackWorkflowRecoveryProgress?: boolean } = {},
12180
+ ): Promise<CompactionStateSnapshot> {
11755
12181
  const snapshot: CompactionStateSnapshot = {
11756
12182
  goal: undefined,
11757
12183
  openTodos: [],
11758
12184
  activeSkills: [],
11759
12185
  queuedMessages: false,
11760
12186
  lastAssistantStopReason: undefined,
12187
+ recentFileMutations: [],
11761
12188
  };
11762
12189
  try {
11763
12190
  const goalState = this.getGoalModeState();
@@ -11780,6 +12207,9 @@ export class AgentSession {
11780
12207
  error: error instanceof Error ? error.message : String(error),
11781
12208
  });
11782
12209
  }
12210
+ // #4560: clear before the read so a failed refresh can never leave a
12211
+ // previous run's workflow owner in place for the recovery projector.
12212
+ this.#lastCompactionActiveSkills = [];
11783
12213
  try {
11784
12214
  const state = await readVisibleSkillActiveState(this.sessionManager.getCwd(), this.sessionId, {
11785
12215
  bypassCache: true,
@@ -11788,11 +12218,40 @@ export class AgentSession {
11788
12218
  .filter(entry => entry.active !== false)
11789
12219
  .slice(0, 5)
11790
12220
  .map(entry => ({ skill: entry.skill, phase: entry.phase ?? "unknown" }));
12221
+ this.#lastCompactionActiveSkills = snapshot.activeSkills;
11791
12222
  } catch (error) {
11792
12223
  logger.warn("Failed to read workflow state for compaction snapshot", {
11793
12224
  error: error instanceof Error ? error.message : String(error),
11794
12225
  });
11795
12226
  }
12227
+ try {
12228
+ // #4560: reload the durable workflow contract for recognized
12229
+ // Ralplan/Ultragoal runs so compaction carries a structured
12230
+ // recovery projection instead of summary prose alone. Degrades
12231
+ // safely: malformed/stale/tampered durable state leaves the
12232
+ snapshot.workflowRecovery =
12233
+ snapshot.goal?.status === "paused" ? undefined : await this.#projectWorkflowRecovery();
12234
+ if (snapshot.workflowRecovery && options.trackWorkflowRecoveryProgress) {
12235
+ this.#workflowRecoveryMemory = trackWorkflowRecoveryZeroProgress(
12236
+ this.#workflowRecoveryMemory,
12237
+ snapshot.workflowRecovery,
12238
+ );
12239
+ }
12240
+ if (snapshot.workflowRecovery && this.#workflowRecoveryMemory) {
12241
+ snapshot.workflowRecovery = {
12242
+ ...snapshot.workflowRecovery,
12243
+ zeroProgress: {
12244
+ ...snapshot.workflowRecovery.zeroProgress,
12245
+ unchangedObservations: this.#workflowRecoveryMemory.unchangedObservations,
12246
+ stalled: isWorkflowRecoveryStalled(this.#workflowRecoveryMemory),
12247
+ },
12248
+ };
12249
+ }
12250
+ } catch (error) {
12251
+ logger.warn("Failed to project workflow recovery state for compaction snapshot", {
12252
+ error: error instanceof Error ? error.message : String(error),
12253
+ });
12254
+ }
11796
12255
  try {
11797
12256
  snapshot.queuedMessages = this.agent.hasQueuedMessages() || this.#pendingNextTurnMessages.length > 0;
11798
12257
  } catch (error) {
@@ -11812,9 +12271,43 @@ export class AgentSession {
11812
12271
  error: error instanceof Error ? error.message : String(error),
11813
12272
  });
11814
12273
  }
12274
+ try {
12275
+ snapshot.recentFileMutations = collectRecentFileMutations(this.messages);
12276
+ } catch (error) {
12277
+ logger.warn("Failed to read recent file mutations for compaction snapshot", {
12278
+ error: error instanceof Error ? error.message : String(error),
12279
+ });
12280
+ }
11815
12281
  return snapshot;
11816
12282
  }
11817
12283
 
12284
+ /**
12285
+ * #4560: derive the structured workflow recovery projection for an
12286
+ * active recognized workflow from its canonical durable state. Returns
12287
+ * undefined for inactive/unrecognized workflows (generic behavior is
12288
+ * preserved) and for malformed durable state (safe degradation).
12289
+ */
12290
+ async #projectWorkflowRecovery(): Promise<WorkflowRecoveryProjection | undefined> {
12291
+ const entries = (this.#lastCompactionActiveSkills ?? []).filter(
12292
+ entry => !isWorkflowContinuationInert(entry.skill, entry.phase),
12293
+ );
12294
+ const cwd = this.sessionManager.getCwd();
12295
+ // Ultragoal runs own the live execution contract; prefer their plan.
12296
+ if (entries.some(entry => entry.skill === "ultragoal")) {
12297
+ const projection = await projectUltragoalRun({ cwd, sessionId: this.sessionId }).catch(() => undefined);
12298
+ if (projection) return projection;
12299
+ }
12300
+ if (entries.some(entry => entry.skill === "ralplan")) {
12301
+ const projection = await projectLatestRalplanRun({ cwd, sessionId: this.sessionId }).catch(() => undefined);
12302
+ if (projection) return projection;
12303
+ }
12304
+ return undefined;
12305
+ }
12306
+ /** #4560: zero-progress memory across compaction observations. */
12307
+ #workflowRecoveryMemory: WorkflowRecoveryZeroProgressMemory | undefined;
12308
+ /** #4560: active skills observed by the latest compaction snapshot. */
12309
+ #lastCompactionActiveSkills: Array<{ skill: string; phase: string }> = [];
12310
+
11818
12311
  #compactionStateContext(snapshot: CompactionStateSnapshot): string[] {
11819
12312
  const context: string[] = [];
11820
12313
  const goal = snapshot.goal;
@@ -11834,6 +12327,12 @@ export class AgentSession {
11834
12327
  const todos = snapshot.openTodos.map(todo => sanitizeCompactionStateText(todo, 120));
11835
12328
  context.push(`Open todos: ${todos.join("; ")}`);
11836
12329
  }
12330
+ if (snapshot.recentFileMutations.length > 0) {
12331
+ const files = snapshot.recentFileMutations.map(filePath => sanitizeCompactionStateText(filePath, 120));
12332
+ context.push(`Recent file mutations: ${files.join("; ")}`);
12333
+ }
12334
+ const recovery = snapshot.workflowRecovery;
12335
+ if (recovery) context.push(...renderWorkflowRecoveryContext(recovery));
11837
12336
  return context;
11838
12337
  }
11839
12338
 
@@ -11957,6 +12456,7 @@ export class AgentSession {
11957
12456
  this.#markRetryReplayUnsafe();
11958
12457
  this.abortRetry();
11959
12458
  this.#promptGeneration++;
12459
+ this.#abortEpoch++;
11960
12460
  this.#promptPreflightCancellationGeneration++;
11961
12461
  this.#promptPreflightAbortController.abort();
11962
12462
  this.#promptPreflightAbortController = new AbortController();
@@ -11980,66 +12480,94 @@ export class AgentSession {
11980
12480
  | "internal";
11981
12481
  silent?: boolean;
11982
12482
  }): Promise<AbortOutcome> {
11983
- this.#abortOptions(options);
11984
- const postPromptDrain = this.#cancelPostPromptTasks();
11985
- const managedLogicalRunId =
11986
- this.#defaultFallbackChain().chain.entries.length > 1 ? this.agent.currentManagedLogicalRunId : undefined;
11987
- this.agent.abort();
11988
- const cleanup = Promise.all([postPromptDrain, this.agent.waitForIdle()]).then(
11989
- () => ({ kind: "settled" as const }),
11990
- (cause: unknown) => ({ kind: "error" as const, cause }),
11991
- );
11992
- cleanup.catch(() => {});
11993
- let outcome: AbortOutcome;
11994
- if (options?.timeoutMs !== undefined && options.timeoutMs > 0) {
11995
- outcome = await Promise.race([
11996
- cleanup,
11997
- Bun.sleep(options.timeoutMs).then(() => ({ kind: "timeout" as const })),
11998
- ]);
11999
- if (outcome.kind === "timeout") {
12000
- this.#abandonPostPromptTasks();
12001
- const forceAbortLogicalRunId = this.agent.currentManagedLogicalRunId ?? this.#activeLogicalRunId;
12002
- try {
12003
- if (forceAbortLogicalRunId !== undefined)
12004
- this.agent.forceAbort("Abort cleanup timed out", forceAbortLogicalRunId);
12005
- else this.agent.forceAbort("Abort cleanup timed out");
12006
- } catch {
12007
- this.agent.forceAbort("Abort cleanup timed out");
12483
+ // Advance the admission epoch SYNCHRONOUSLY, before any await and before the
12484
+ // shared-unwind branch. A second abort that piggybacks on the first unwind
12485
+ // still creates a cancellation fence, so a prompt admitted between the two
12486
+ // aborts is refused rather than started after the shared unwind resolves.
12487
+ this.#abortAdmissionEpoch++;
12488
+ if (this.#abortUnwind) {
12489
+ // The shared unwind already ran `#abortOptions` for the FIRST abort, so this
12490
+ // abort must still apply the effects that are specific to its own request.
12491
+ //
12492
+ // Cancellation fence: a prompt admitted before this abort keeps a live
12493
+ // generation and preflight signal otherwise, and would start after the
12494
+ // shared unwind resolves — work the user has already aborted twice.
12495
+ this.#promptPreflightCancellationGeneration++;
12496
+ this.#promptPreflightAbortController.abort();
12497
+ this.#promptPreflightAbortController = new AbortController();
12498
+ // Abort visibility is per-request: a later real abort must not inherit an
12499
+ // earlier silent abort's suppression and swallow the user-visible notice.
12500
+ if (options?.silent !== true) this.#silentAbortPending = false;
12501
+ await this.#abortUnwind;
12502
+ return { kind: "settled" };
12503
+ }
12504
+ const unwind = Promise.withResolvers<void>();
12505
+ this.#abortUnwind = unwind.promise;
12506
+ try {
12507
+ this.#abortOptions(options);
12508
+ const postPromptDrain = this.#cancelPostPromptTasks();
12509
+ const managedLogicalRunId =
12510
+ this.#defaultFallbackChain().chain.entries.length > 1 ? this.agent.currentManagedLogicalRunId : undefined;
12511
+ this.agent.abort();
12512
+ const cleanup = Promise.all([postPromptDrain, this.agent.waitForIdle()]).then(
12513
+ () => ({ kind: "settled" as const }),
12514
+ (cause: unknown) => ({ kind: "error" as const, cause }),
12515
+ );
12516
+ cleanup.catch(() => {});
12517
+ let outcome: AbortOutcome;
12518
+ if (options?.timeoutMs !== undefined && options.timeoutMs > 0) {
12519
+ outcome = await Promise.race([
12520
+ cleanup,
12521
+ Bun.sleep(options.timeoutMs).then(() => ({ kind: "timeout" as const })),
12522
+ ]);
12523
+ if (outcome.kind === "timeout") {
12524
+ this.#abandonPostPromptTasks();
12525
+ const forceAbortLogicalRunId = this.agent.currentManagedLogicalRunId ?? this.#activeLogicalRunId;
12526
+ try {
12527
+ if (forceAbortLogicalRunId !== undefined)
12528
+ this.agent.forceAbort("Abort cleanup timed out", forceAbortLogicalRunId);
12529
+ else this.agent.forceAbort("Abort cleanup timed out");
12530
+ } catch {
12531
+ this.agent.forceAbort("Abort cleanup timed out");
12532
+ }
12533
+ this.emitNotice(
12534
+ "warning",
12535
+ "Abort cleanup timed out; forced session recovery. The previous provider stream or tool may still be unwinding in the background.",
12536
+ "abort",
12537
+ );
12008
12538
  }
12009
- this.emitNotice(
12010
- "warning",
12011
- "Abort cleanup timed out; forced session recovery. The previous provider stream or tool may still be unwinding in the background.",
12012
- "abort",
12013
- );
12539
+ } else {
12540
+ outcome = await cleanup;
12014
12541
  }
12015
- } else {
12016
- outcome = await cleanup;
12017
- }
12018
- try {
12019
- await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
12020
- if (managedLogicalRunId !== undefined)
12021
- this.agent.requestRunTerminal(managedLogicalRunId, { stopReason: "cancelled" });
12022
- this.#flushPendingBackgroundExchanges();
12023
- this.#flushPendingAgentEnd();
12024
- if (
12025
- !this.#cancelAndSubmitInProgress &&
12026
- (options?.cause ?? "internal") === "user_interrupt" &&
12027
- this.agent.hasQueuedSteering()
12028
- ) {
12029
- this.#scheduleAgentContinue({
12030
- delayMs: 1,
12031
- generation: this.#promptGeneration,
12032
- shouldContinue: () => this.agent.hasQueuedSteering(),
12033
- rescheduleOnBusy: true,
12034
- continueQueuedOnly: true,
12035
- });
12542
+ try {
12543
+ await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
12544
+ if (managedLogicalRunId !== undefined)
12545
+ this.agent.requestRunTerminal(managedLogicalRunId, { stopReason: "cancelled" });
12546
+ this.#flushPendingBackgroundExchanges();
12547
+ await this.#awaitAbortedTurnTerminal();
12548
+ if (
12549
+ !this.#cancelAndSubmitInProgress &&
12550
+ (options?.cause ?? "internal") === "user_interrupt" &&
12551
+ this.agent.hasQueuedSteering()
12552
+ ) {
12553
+ this.#scheduleAgentContinue({
12554
+ delayMs: 1,
12555
+ generation: this.#promptGeneration,
12556
+ shouldContinue: () => this.agent.hasQueuedSteering(),
12557
+ rescheduleOnBusy: true,
12558
+ continueQueuedOnly: true,
12559
+ });
12560
+ }
12561
+ return outcome;
12562
+ } catch (cause) {
12563
+ return { kind: "error", cause };
12564
+ } finally {
12565
+ this.#silentAbortPending = false;
12566
+ if (this.#toolChoiceQueue.hasInFlight) this.#toolChoiceQueue.reject("aborted");
12036
12567
  }
12037
- return outcome;
12038
- } catch (cause) {
12039
- return { kind: "error", cause };
12040
12568
  } finally {
12041
- this.#silentAbortPending = false;
12042
- if (this.#toolChoiceQueue.hasInFlight) this.#toolChoiceQueue.reject("aborted");
12569
+ this.#abortUnwind = undefined;
12570
+ unwind.resolve();
12043
12571
  }
12044
12572
  }
12045
12573
 
@@ -14952,7 +15480,7 @@ export class AgentSession {
14952
15480
  const compactionAbortController = new AbortController();
14953
15481
  this.#compactionAbortController = compactionAbortController;
14954
15482
  // Take this invocation's state snapshot for the summarizer context.
14955
- const compactionStateSnapshot = await this.#compactionStateSnapshot();
15483
+ const compactionStateSnapshot = await this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true });
14956
15484
 
14957
15485
  try {
14958
15486
  if (!this.model) {
@@ -15061,7 +15589,6 @@ export class AgentSession {
15061
15589
  if (compactionAbortController.signal.aborted) {
15062
15590
  throw new CompactionCancelledError();
15063
15591
  }
15064
-
15065
15592
  const compactionEntryId = this.sessionManager.appendCompaction(
15066
15593
  summary,
15067
15594
  shortSummary,
@@ -17007,7 +17534,7 @@ export class AgentSession {
17007
17534
  if (autoCompactionSignal.aborted) return { kind: "aborted", source: "signal" };
17008
17535
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
17009
17536
  if (autoCompactionSignal.aborted) return await emitAborted();
17010
- const compactionStateSnapshot = await this.#compactionStateSnapshot();
17537
+ const compactionStateSnapshot = await this.#compactionStateSnapshot({ trackWorkflowRecoveryProgress: true });
17011
17538
  if (autoCompactionSignal.aborted || this.#isDisposed || this.#promptGeneration !== generation) {
17012
17539
  return await emitAborted();
17013
17540
  }
@@ -17555,6 +18082,10 @@ export class AgentSession {
17555
18082
  if (this.#isTerminalProviderFirstEventTimeout(message)) return false;
17556
18083
  if (message.errorMessage?.startsWith("Model fallback chain exhausted;")) return false;
17557
18084
  const transportFailure = message.transportFailure;
18085
+ // Transport facts normally bypass typed classification in managed fallback.
18086
+ // A provider safety stop must still remain terminal on that path.
18087
+ if (message.stopReason === "error" && message.errorKind === "provider_safety_stop" && transportFailure)
18088
+ return false;
17558
18089
  const contextWindow = this.model?.contextWindow ?? 0;
17559
18090
  if (classifyContextOverflow(message, transportFailure, contextWindow)) return false;
17560
18091
  const managedFallback = this.#defaultFallbackChain().chain.entries.length > 1;
@@ -17796,6 +18327,15 @@ export class AgentSession {
17796
18327
  return undefined;
17797
18328
  }
17798
18329
 
18330
+ #escapedNonAsciiRecoveryMessage(): UserMessage {
18331
+ return {
18332
+ role: "user",
18333
+ content: ESCAPED_NONASCII_RECOVERY_PROMPT,
18334
+ synthetic: true,
18335
+ timestamp: Date.now(),
18336
+ };
18337
+ }
18338
+
17799
18339
  #managedFallbackPromptOptions(): {
17800
18340
  fallbackManaged?: boolean;
17801
18341
  nextFallbackAttempt?: (model: Model) => FallbackAttemptToken;
@@ -17821,6 +18361,9 @@ export class AgentSession {
17821
18361
  }
17822
18362
 
17823
18363
  async #resetDefaultFallbackForNewTurn(): Promise<void> {
18364
+ // A fresh user turn gets a fresh escaped-non-ASCII retry budget: the
18365
+ // defect is per-turn wire luck, not a sticky model property.
18366
+ this.#escapedNonAsciiManagedRetries = 0;
17824
18367
  const controller = this.#defaultFallbackChain();
17825
18368
  if (this.#defaultFallbackExhaustedLastTurn) {
17826
18369
  this.#defaultFallbackExhaustedLastTurn = false;
@@ -17924,15 +18467,35 @@ export class AgentSession {
17924
18467
  // evidence: never charge the attempt, advance the chain, or suppress the
17925
18468
  // selector. The loop already removed the defective turn from history and
17926
18469
  // bounded its own resample budget, so this decision just re-issues the
17927
- // same request on the same model. Once the loop declines (budget spent),
17928
- // it falls through to the terminal per-call rejection, so the retry here
17929
- // is a continuation of the same logical run rather than a new prompt.
18470
+ // same request on the same model. The re-issue carries the transient
18471
+ // steering instruction exactly once (when the discarded attempt did not
18472
+ // already have one), so a deterministic escaper has a reason to change
18473
+ // its spelling; the instruction never lands in durable history.
18474
+ //
18475
+ // The retries are un-charged by design, so a deterministic escaper
18476
+ // would otherwise loop forever: each continuation is a fresh loop with
18477
+ // a fresh in-loop resample budget, and the fallback chain never sees a
18478
+ // charge to exhaust on. Bound them per logical run and fail closed to
18479
+ // the terminal exhaustion message once the budget is spent — the same
18480
+ // fail-closed answer the unmanaged path gives via the per-call
18481
+ // rejection. New user turns reset the budget in #resetDefaultFallbackForNewTurn.
18482
+ this.#escapedNonAsciiManagedRetries += 1;
18483
+ if (this.#escapedNonAsciiManagedRetries > MAX_ESCAPED_NONASCII_MANAGED_RETRIES) {
18484
+ return this.#managedFallbackExhaustionDecision(
18485
+ outcome.message,
18486
+ `Managed fallback retried the escaped non-ASCII tool-call turn ${MAX_ESCAPED_NONASCII_MANAGED_RETRIES} times without a literal-UTF-8 re-issue; giving up so the run fails closed instead of looping.`,
18487
+ );
18488
+ }
17930
18489
  this.#defaultFallbackChain().discardStartedAttempt();
18490
+ const steering = outcome.steeringPending === true;
17931
18491
  return {
17932
18492
  type: "retry",
17933
18493
  continuation: async ownership => {
17934
18494
  if (!ownership.isCurrent() || ownership.lease.signal.aborted) return;
17935
- await this.agent.continue(this.#managedFallbackPromptOptions());
18495
+ await this.agent.continue({
18496
+ ...this.#managedFallbackPromptOptions(),
18497
+ ...(steering ? { transientRecoveryMessage: this.#escapedNonAsciiRecoveryMessage() } : {}),
18498
+ });
17936
18499
  },
17937
18500
  };
17938
18501
  }
@@ -18027,6 +18590,7 @@ export class AgentSession {
18027
18590
  // result is returned verbatim below. Narrowing it away here would silently
18028
18591
  // disable the terminal-403 guard in `#markFailedCredential`.
18029
18592
  ): { class: FallbackTriggerClass; retryAfterMs?: number; authDisposition?: AuthDisposition } | undefined {
18593
+ if (message.stopReason === "error" && message.errorKind === "provider_safety_stop") return undefined;
18030
18594
  if (classifyContextOverflow(message, transportFailure, this.model?.contextWindow ?? 0)) return undefined;
18031
18595
  const transport = classifyFallbackTrigger(transportFailure ?? { status: message.errorStatus });
18032
18596
  if (transport.class !== "other") return transport;
@@ -18247,6 +18811,18 @@ export class AgentSession {
18247
18811
  }
18248
18812
  : false;
18249
18813
  }
18814
+ // A typed provider safety stop is terminal even when managed fallback is
18815
+ // enabled. Preserve the original refusal instead of treating the absent
18816
+ // fallback trigger as chain exhaustion; do not broaden this to every
18817
+ // untyped terminal classification.
18818
+ if (message.stopReason === "error" && message.errorKind === "provider_safety_stop") {
18819
+ return managedOutcome
18820
+ ? {
18821
+ type: "terminal",
18822
+ terminal: { stopReason: "error", messages: [message] },
18823
+ }
18824
+ : false;
18825
+ }
18250
18826
  // retry.enabled=false always surfaces immediately, matching the explicit
18251
18827
  // user opt-out. The managed provider-fallback chain keeps its own
18252
18828
  // availability policy.
@@ -18260,6 +18836,7 @@ export class AgentSession {
18260
18836
  }
18261
18837
  const firstEventTimeout = classification === "first_event_timeout";
18262
18838
  const emptyResponse = classification === "empty_response";
18839
+ const canReplayProviderOverload = isBareDefaultCodexOverload(message) || isBareDefaultAnthropicOverload(message);
18263
18840
  const reportedRetryMaxAttempts = transportFailure?.retryMaxAttempts;
18264
18841
  if (reportedRetryMaxAttempts !== undefined) {
18265
18842
  this.#providerRetryMaxAttempts = Math.min(
@@ -18338,24 +18915,22 @@ export class AgentSession {
18338
18915
  if (!managedFallback && assistantMessageHasVisibleOrToolContent(message)) {
18339
18916
  return false;
18340
18917
  }
18341
- // Bare defaults retain their narrow watchdog and Codex admissions. A
18342
- // first-event timeout adds the typed, content-free, current-clean-scope
18343
- // requirement above; other transient watchdogs preserve legacy behavior.
18918
+ // Bare defaults retain their narrow watchdog and provider capacity-overload
18919
+ // admissions. A first-event timeout adds the typed, content-free,
18920
+ // current-clean-scope requirement above; other transient watchdogs preserve
18921
+ // legacy behavior. A provider overload is admitted only from that provider's
18922
+ // own typed overload code on a content-free attempt carrying no conflicting
18923
+ // transport facts, so replaying it cannot duplicate observable work.
18344
18924
  const canReplayEmptyResponse = emptyResponse && (this.#retryAttempt === 0 || this.#hasCleanRetryReplaySafety);
18345
18925
  if (!managedFallback && !legacyRetryConfigured && !canReplayRotatedCredential && !canReplayEmptyResponse) {
18346
- const bareDefaultCodexOverload = isBareDefaultCodexOverload(message);
18347
- const canReplayCodexOverload = bareDefaultCodexOverload;
18348
18926
  if (
18349
- (!canReplayCodexOverload &&
18927
+ (!canReplayProviderOverload &&
18350
18928
  !this.#isTypedFirstEventTimeout(message) &&
18351
18929
  !messageOnlyWatchdogTimeout &&
18352
18930
  (hasBareDefaultRetryDisqualifyingFacts(message) ||
18353
18931
  (classification !== "transient" && classification !== "first_event_timeout") ||
18354
18932
  !BARE_DEFAULT_WATCHDOG_ERROR.test(message.errorMessage ?? ""))) ||
18355
- (!canReplayCodexOverload &&
18356
- !firstEventTimeout &&
18357
- !messageOnlyWatchdogTimeout &&
18358
- !this.#hasCleanRetryReplaySafety)
18933
+ (!firstEventTimeout && !messageOnlyWatchdogTimeout && !this.#hasCleanRetryReplaySafety)
18359
18934
  ) {
18360
18935
  return false;
18361
18936
  }
@@ -18363,6 +18938,7 @@ export class AgentSession {
18363
18938
  const legacyUnbounded =
18364
18939
  !managedFallback &&
18365
18940
  classification === "transient" &&
18941
+ !canReplayProviderOverload &&
18366
18942
  !this.#isIdleStreamStallErrorMessage(message.errorMessage ?? "");
18367
18943
 
18368
18944
  const failedSelector = managedFallback ? controller.currentSelector() : undefined;