@animalabs/agent-framework 0.22.1 → 0.22.2

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 (359) hide show
  1. package/README.md +1 -1
  2. package/dist/src/agent.d.ts +192 -19
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +404 -65
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server-core.d.ts +41 -0
  7. package/dist/src/api/mcp-server-core.d.ts.map +1 -0
  8. package/dist/src/api/mcp-server-core.js +593 -0
  9. package/dist/src/api/mcp-server-core.js.map +1 -0
  10. package/dist/src/api/mcp-server.d.ts +2 -36
  11. package/dist/src/api/mcp-server.d.ts.map +1 -1
  12. package/dist/src/api/mcp-server.js +15 -595
  13. package/dist/src/api/mcp-server.js.map +1 -1
  14. package/dist/src/api/server.d.ts +9 -1
  15. package/dist/src/api/server.d.ts.map +1 -1
  16. package/dist/src/api/server.js +50 -26
  17. package/dist/src/api/server.js.map +1 -1
  18. package/dist/src/api/types.d.ts +9 -1
  19. package/dist/src/api/types.d.ts.map +1 -1
  20. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  21. package/dist/src/code-execution/py-runner.js.map +1 -1
  22. package/dist/src/framework.d.ts +393 -113
  23. package/dist/src/framework.d.ts.map +1 -1
  24. package/dist/src/framework.js +1748 -582
  25. package/dist/src/framework.js.map +1 -1
  26. package/dist/src/gate/event-gate.d.ts.map +1 -1
  27. package/dist/src/gate/gate-script.d.ts.map +1 -1
  28. package/dist/src/gate/gate-script.js.map +1 -1
  29. package/dist/src/index.d.ts +2 -2
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/index.js +2 -2
  32. package/dist/src/index.js.map +1 -1
  33. package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
  34. package/dist/src/mcpl/channel-registry.d.ts +26 -7
  35. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  36. package/dist/src/mcpl/channel-registry.js +102 -22
  37. package/dist/src/mcpl/channel-registry.js.map +1 -1
  38. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  39. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  40. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  41. package/dist/src/mcpl/errors.js.map +1 -1
  42. package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
  43. package/dist/src/mcpl/feature-set-manager.js +4 -3
  44. package/dist/src/mcpl/feature-set-manager.js.map +1 -1
  45. package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
  46. package/dist/src/mcpl/inference-router.d.ts.map +1 -1
  47. package/dist/src/mcpl/log-value.d.ts +54 -0
  48. package/dist/src/mcpl/log-value.d.ts.map +1 -0
  49. package/dist/src/mcpl/log-value.js +109 -0
  50. package/dist/src/mcpl/log-value.js.map +1 -0
  51. package/dist/src/mcpl/prose-stream-router.d.ts +6 -6
  52. package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
  53. package/dist/src/mcpl/prose-stream-router.js +4 -4
  54. package/dist/src/mcpl/prose-stream-router.js.map +1 -1
  55. package/dist/src/mcpl/push-coalescer.d.ts +38 -3
  56. package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
  57. package/dist/src/mcpl/push-coalescer.js +81 -16
  58. package/dist/src/mcpl/push-coalescer.js.map +1 -1
  59. package/dist/src/mcpl/push-handler.d.ts +26 -3
  60. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  61. package/dist/src/mcpl/push-handler.js +87 -23
  62. package/dist/src/mcpl/push-handler.js.map +1 -1
  63. package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
  64. package/dist/src/mcpl/reference-fetcher.js.map +1 -1
  65. package/dist/src/mcpl/references.d.ts +2 -2
  66. package/dist/src/mcpl/references.d.ts.map +1 -1
  67. package/dist/src/mcpl/references.js.map +1 -1
  68. package/dist/src/mcpl/refusal-lines.d.ts +37 -0
  69. package/dist/src/mcpl/refusal-lines.d.ts.map +1 -0
  70. package/dist/src/mcpl/refusal-lines.js +61 -0
  71. package/dist/src/mcpl/refusal-lines.js.map +1 -0
  72. package/dist/src/mcpl/scope-manager.d.ts.map +1 -1
  73. package/dist/src/mcpl/server-connection.d.ts +18 -2
  74. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  75. package/dist/src/mcpl/server-connection.js +66 -11
  76. package/dist/src/mcpl/server-connection.js.map +1 -1
  77. package/dist/src/mcpl/server-registry.d.ts.map +1 -1
  78. package/dist/src/mcpl/tool-classes.d.ts +1 -1
  79. package/dist/src/mcpl/tool-classes.d.ts.map +1 -1
  80. package/dist/src/mcpl/tool-classes.js +3 -2
  81. package/dist/src/mcpl/tool-classes.js.map +1 -1
  82. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  83. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  84. package/dist/src/mcpl/transport.d.ts +23 -5
  85. package/dist/src/mcpl/transport.d.ts.map +1 -1
  86. package/dist/src/mcpl/transport.js +103 -10
  87. package/dist/src/mcpl/transport.js.map +1 -1
  88. package/dist/src/mcpl/types.d.ts +30 -26
  89. package/dist/src/mcpl/types.d.ts.map +1 -1
  90. package/dist/src/mcpl/types.js.map +1 -1
  91. package/dist/src/module-registry.d.ts.map +1 -1
  92. package/dist/src/module-registry.js.map +1 -1
  93. package/dist/src/modules/api/index.d.ts.map +1 -1
  94. package/dist/src/modules/discord/discord-js-client.d.ts.map +1 -1
  95. package/dist/src/modules/discord/discord-js-client.js.map +1 -1
  96. package/dist/src/modules/discord/index.d.ts.map +1 -1
  97. package/dist/src/modules/discord/index.js +4 -2
  98. package/dist/src/modules/discord/index.js.map +1 -1
  99. package/dist/src/modules/health/index.d.ts.map +1 -1
  100. package/dist/src/modules/health/index.js.map +1 -1
  101. package/dist/src/modules/history/index.d.ts.map +1 -1
  102. package/dist/src/modules/history/index.js.map +1 -1
  103. package/dist/src/modules/history/semantic.d.ts.map +1 -1
  104. package/dist/src/modules/history/semantic.js.map +1 -1
  105. package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -1
  106. package/dist/src/modules/workspace/branch-intent.js.map +1 -1
  107. package/dist/src/modules/workspace/disk-agreement.d.ts +22 -4
  108. package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -1
  109. package/dist/src/modules/workspace/disk-agreement.js +63 -25
  110. package/dist/src/modules/workspace/disk-agreement.js.map +1 -1
  111. package/dist/src/modules/workspace/effects.d.ts.map +1 -1
  112. package/dist/src/modules/workspace/index.d.ts +8 -4
  113. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  114. package/dist/src/modules/workspace/index.js +39 -12
  115. package/dist/src/modules/workspace/index.js.map +1 -1
  116. package/dist/src/modules/workspace/observe.d.ts +43 -8
  117. package/dist/src/modules/workspace/observe.d.ts.map +1 -1
  118. package/dist/src/modules/workspace/observe.js +264 -39
  119. package/dist/src/modules/workspace/observe.js.map +1 -1
  120. package/dist/src/modules/workspace/reconcile.d.ts +17 -1
  121. package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
  122. package/dist/src/modules/workspace/reconcile.js +57 -14
  123. package/dist/src/modules/workspace/reconcile.js.map +1 -1
  124. package/dist/src/modules/workspace/types.d.ts +27 -2
  125. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  126. package/dist/src/modules/workspace/types.js.map +1 -1
  127. package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
  128. package/dist/src/modules/workspace/watcher.js.map +1 -1
  129. package/dist/src/operator-log.d.ts.map +1 -1
  130. package/dist/src/operator-log.js.map +1 -1
  131. package/dist/src/provider-waits.d.ts.map +1 -1
  132. package/dist/src/provider-waits.js.map +1 -1
  133. package/dist/src/queue.d.ts.map +1 -1
  134. package/dist/src/record-journal.d.ts.map +1 -1
  135. package/dist/src/record-journal.js.map +1 -1
  136. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  137. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  138. package/dist/src/recovery/offline-branch.d.ts.map +1 -1
  139. package/dist/src/recovery/offline-branch.js +29 -24
  140. package/dist/src/recovery/offline-branch.js.map +1 -1
  141. package/dist/src/recovery/recover-cli.js.map +1 -1
  142. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -1
  143. package/dist/src/runtime/liveness-watchdog.js.map +1 -1
  144. package/dist/src/shutdown.d.ts.map +1 -1
  145. package/dist/src/shutdown.js.map +1 -1
  146. package/dist/src/stored-removal.d.ts +73 -0
  147. package/dist/src/stored-removal.d.ts.map +1 -0
  148. package/dist/src/stored-removal.js +100 -0
  149. package/dist/src/stored-removal.js.map +1 -0
  150. package/dist/src/thinking-token-stamp.js.map +1 -1
  151. package/dist/src/tool-image-ledger.d.ts.map +1 -1
  152. package/dist/src/tool-image-ledger.js.map +1 -1
  153. package/dist/src/tool-presentation.d.ts.map +1 -1
  154. package/dist/src/tool-presentation.js.map +1 -1
  155. package/dist/src/tool-result-guard.d.ts.map +1 -1
  156. package/dist/src/tool-result-guard.js.map +1 -1
  157. package/dist/src/tune-out/coordinator.d.ts.map +1 -1
  158. package/dist/src/tune-out/coordinator.js.map +1 -1
  159. package/dist/src/tune-out/tools.d.ts +1 -1
  160. package/dist/src/tune-out/tools.d.ts.map +1 -1
  161. package/dist/src/types/agent.d.ts +42 -6
  162. package/dist/src/types/agent.d.ts.map +1 -1
  163. package/dist/src/types/framework.d.ts +10 -0
  164. package/dist/src/types/framework.d.ts.map +1 -1
  165. package/dist/src/types/module.d.ts +8 -4
  166. package/dist/src/types/module.d.ts.map +1 -1
  167. package/dist/src/types/trace.d.ts +61 -10
  168. package/dist/src/types/trace.d.ts.map +1 -1
  169. package/dist/src/usage/usage-tracker.d.ts.map +1 -1
  170. package/dist/test/agent-runtime-settings.test.js +1139 -3
  171. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  172. package/dist/test/batched-wake-locus.test.js.map +1 -1
  173. package/dist/test/channel-label-history.test.js.map +1 -1
  174. package/dist/test/code-execution.test.js.map +1 -1
  175. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
  176. package/dist/test/conversation-router.test.js.map +1 -1
  177. package/dist/test/conversation-routing.test.js +240 -6
  178. package/dist/test/conversation-routing.test.js.map +1 -1
  179. package/dist/test/discord-awareness-outbox.test.js +104 -0
  180. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  181. package/dist/test/discord-typing-keepalive.test.d.ts +2 -0
  182. package/dist/test/discord-typing-keepalive.test.d.ts.map +1 -0
  183. package/dist/test/discord-typing-keepalive.test.js +77 -0
  184. package/dist/test/discord-typing-keepalive.test.js.map +1 -0
  185. package/dist/test/ephemeral-settle.test.js.map +1 -1
  186. package/dist/test/event-gate-passthrough.test.js.map +1 -1
  187. package/dist/test/event-gate-policy-mutation.test.js.map +1 -1
  188. package/dist/test/event-gate-rate-limit.test.js.map +1 -1
  189. package/dist/test/event-gate-script.test.js.map +1 -1
  190. package/dist/test/event-gate-selfwake.test.js.map +1 -1
  191. package/dist/test/event-gate-tags.test.js.map +1 -1
  192. package/dist/test/event-gate.test.js.map +1 -1
  193. package/dist/test/explicit-prose-routing.test.js +363 -42
  194. package/dist/test/explicit-prose-routing.test.js.map +1 -1
  195. package/dist/test/framework.test.js.map +1 -1
  196. package/dist/test/helpers/coalescing-fixture.d.ts +2 -0
  197. package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
  198. package/dist/test/helpers/coalescing-fixture.js +8 -2
  199. package/dist/test/helpers/coalescing-fixture.js.map +1 -1
  200. package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
  201. package/dist/test/helpers/mock-membrane.js.map +1 -1
  202. package/dist/test/hide-then-turn.test.d.ts +2 -0
  203. package/dist/test/hide-then-turn.test.d.ts.map +1 -0
  204. package/dist/test/hide-then-turn.test.js +101 -0
  205. package/dist/test/hide-then-turn.test.js.map +1 -0
  206. package/dist/test/history-module-overview.test.js.map +1 -1
  207. package/dist/test/history-module-semantic.test.js.map +1 -1
  208. package/dist/test/history-module.test.js.map +1 -1
  209. package/dist/test/history-semantic-review-findings.test.js.map +1 -1
  210. package/dist/test/host-quiesce.test.js +125 -0
  211. package/dist/test/host-quiesce.test.js.map +1 -1
  212. package/dist/test/inference-exhaustion-rewind.test.js +81 -3
  213. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
  214. package/dist/test/inference-failure-bounds.test.js +1 -0
  215. package/dist/test/inference-failure-bounds.test.js.map +1 -1
  216. package/dist/test/kv-unified-open-flight-retry.test.js.map +1 -1
  217. package/dist/test/liveness-watchdog.test.js.map +1 -1
  218. package/dist/test/maintenance-timer.test.js +1564 -2
  219. package/dist/test/maintenance-timer.test.js.map +1 -1
  220. package/dist/test/mcp-server-bin.test.d.ts +2 -0
  221. package/dist/test/mcp-server-bin.test.d.ts.map +1 -0
  222. package/dist/test/mcp-server-bin.test.js +142 -0
  223. package/dist/test/mcp-server-bin.test.js.map +1 -0
  224. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
  225. package/dist/test/mcpl-child-env.test.js +1 -1
  226. package/dist/test/mcpl-child-env.test.js.map +1 -1
  227. package/dist/test/mcpl-coalesced-content-parity.test.d.ts +2 -0
  228. package/dist/test/mcpl-coalesced-content-parity.test.d.ts.map +1 -0
  229. package/dist/test/mcpl-coalesced-content-parity.test.js +532 -0
  230. package/dist/test/mcpl-coalesced-content-parity.test.js.map +1 -0
  231. package/dist/test/mcpl-coalescing.test.js.map +1 -1
  232. package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
  233. package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
  234. package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
  235. package/dist/test/mcpl-reconnect-events.test.js.map +1 -1
  236. package/dist/test/mcpl-reconnect-reregistration.test.js +2 -0
  237. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  238. package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
  239. package/dist/test/mcpl-reference-fields.test.js.map +1 -1
  240. package/dist/test/mcpl-rejection-log-lines.test.d.ts +2 -0
  241. package/dist/test/mcpl-rejection-log-lines.test.d.ts.map +1 -0
  242. package/dist/test/mcpl-rejection-log-lines.test.js +349 -0
  243. package/dist/test/mcpl-rejection-log-lines.test.js.map +1 -0
  244. package/dist/test/mcpl-stderr-tail.test.d.ts +2 -0
  245. package/dist/test/mcpl-stderr-tail.test.d.ts.map +1 -0
  246. package/dist/test/mcpl-stderr-tail.test.js +210 -0
  247. package/dist/test/mcpl-stderr-tail.test.js.map +1 -0
  248. package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -1
  249. package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
  250. package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -1
  251. package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -1
  252. package/dist/test/mcpl-transport-close.test.d.ts +2 -0
  253. package/dist/test/mcpl-transport-close.test.d.ts.map +1 -0
  254. package/dist/test/mcpl-transport-close.test.js +373 -0
  255. package/dist/test/mcpl-transport-close.test.js.map +1 -0
  256. package/dist/test/mcpl-transport-spawn.test.d.ts +2 -0
  257. package/dist/test/mcpl-transport-spawn.test.d.ts.map +1 -0
  258. package/dist/test/mcpl-transport-spawn.test.js +58 -0
  259. package/dist/test/mcpl-transport-spawn.test.js.map +1 -0
  260. package/dist/test/mcpl-ws-transport.test.js +1 -0
  261. package/dist/test/mcpl-ws-transport.test.js.map +1 -1
  262. package/dist/test/offline-recovery-branch.test.js +344 -0
  263. package/dist/test/offline-recovery-branch.test.js.map +1 -1
  264. package/dist/test/operator-surgery.test.js +197 -0
  265. package/dist/test/operator-surgery.test.js.map +1 -1
  266. package/dist/test/overbudget-drain-breaker.test.js +1 -0
  267. package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
  268. package/dist/test/pack-installs-alone.test.d.ts +2 -0
  269. package/dist/test/pack-installs-alone.test.d.ts.map +1 -0
  270. package/dist/test/pack-installs-alone.test.js +284 -0
  271. package/dist/test/pack-installs-alone.test.js.map +1 -0
  272. package/dist/test/physical-window-restart.test.d.ts +6 -4
  273. package/dist/test/physical-window-restart.test.d.ts.map +1 -1
  274. package/dist/test/physical-window-restart.test.js +275 -10
  275. package/dist/test/physical-window-restart.test.js.map +1 -1
  276. package/dist/test/placed-reaction-baseline.test.js.map +1 -1
  277. package/dist/test/present-while-acting.test.js +949 -6
  278. package/dist/test/present-while-acting.test.js.map +1 -1
  279. package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
  280. package/dist/test/provider-waits.test.js +67 -4
  281. package/dist/test/provider-waits.test.js.map +1 -1
  282. package/dist/test/puppet-tool-call.test.js +2 -0
  283. package/dist/test/puppet-tool-call.test.js.map +1 -1
  284. package/dist/test/reaction-baseline-platform.test.js.map +1 -1
  285. package/dist/test/record-journal.test.js.map +1 -1
  286. package/dist/test/refusal-rewind.test.js +9 -0
  287. package/dist/test/refusal-rewind.test.js.map +1 -1
  288. package/dist/test/refusal-single-call.test.js.map +1 -1
  289. package/dist/test/release-bump.test.d.ts +2 -0
  290. package/dist/test/release-bump.test.d.ts.map +1 -0
  291. package/dist/test/release-bump.test.js +248 -0
  292. package/dist/test/release-bump.test.js.map +1 -0
  293. package/dist/test/release-changelog.test.js.map +1 -1
  294. package/dist/test/release-holds.test.d.ts +2 -0
  295. package/dist/test/release-holds.test.d.ts.map +1 -0
  296. package/dist/test/release-holds.test.js +456 -0
  297. package/dist/test/release-holds.test.js.map +1 -0
  298. package/dist/test/same-round-think-policy.test.js.map +1 -1
  299. package/dist/test/shutdown-errors.test.js.map +1 -1
  300. package/dist/test/silent-heartbeat-rows.test.js.map +1 -1
  301. package/dist/test/stop-with-host-operations.test.d.ts +20 -0
  302. package/dist/test/stop-with-host-operations.test.d.ts.map +1 -0
  303. package/dist/test/stop-with-host-operations.test.js +1174 -0
  304. package/dist/test/stop-with-host-operations.test.js.map +1 -0
  305. package/dist/test/stop-with-work-in-flight.test.js +162 -50
  306. package/dist/test/stop-with-work-in-flight.test.js.map +1 -1
  307. package/dist/test/surgery-marks-receipt.test.js.map +1 -1
  308. package/dist/test/tool-batch-hook.test.js.map +1 -1
  309. package/dist/test/tool-result-guard.test.js +131 -9
  310. package/dist/test/tool-result-guard.test.js.map +1 -1
  311. package/dist/test/tool-result-spill.test.js.map +1 -1
  312. package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -1
  313. package/dist/test/trunk-channel-routing.test.js +330 -7
  314. package/dist/test/trunk-channel-routing.test.js.map +1 -1
  315. package/dist/test/tune-out-e2e.test.js.map +1 -1
  316. package/dist/test/unconvertible-tool-result.test.js.map +1 -1
  317. package/dist/test/unsent-compiles.test.d.ts +2 -0
  318. package/dist/test/unsent-compiles.test.d.ts.map +1 -0
  319. package/dist/test/unsent-compiles.test.js +139 -0
  320. package/dist/test/unsent-compiles.test.js.map +1 -0
  321. package/dist/test/workspace-branch-continuation.test.js.map +1 -1
  322. package/dist/test/workspace-materialize-freshness.test.js.map +1 -1
  323. package/dist/test/workspace-read-text.test.js.map +1 -1
  324. package/dist/test/workspace-three-way.test.js +907 -7
  325. package/dist/test/workspace-three-way.test.js.map +1 -1
  326. package/dist/test/workspace-watcher.test.js.map +1 -1
  327. package/dist/test/xml-round-persist.test.js.map +1 -1
  328. package/dist/tsconfig.tsbuildinfo +1 -1
  329. package/package.json +8 -6
  330. package/src/agent.ts +411 -66
  331. package/src/api/mcp-server-core.ts +681 -0
  332. package/src/api/mcp-server.ts +16 -685
  333. package/src/api/server.ts +51 -29
  334. package/src/api/types.ts +9 -1
  335. package/src/framework.ts +1899 -641
  336. package/src/index.ts +2 -2
  337. package/src/mcpl/channel-registry.ts +126 -31
  338. package/src/mcpl/feature-set-manager.ts +4 -3
  339. package/src/mcpl/log-value.ts +116 -0
  340. package/src/mcpl/prose-stream-router.ts +6 -6
  341. package/src/mcpl/push-coalescer.ts +76 -12
  342. package/src/mcpl/push-handler.ts +95 -23
  343. package/src/mcpl/refusal-lines.ts +59 -0
  344. package/src/mcpl/server-connection.ts +70 -11
  345. package/src/mcpl/tool-classes.ts +3 -2
  346. package/src/mcpl/transport.ts +105 -14
  347. package/src/mcpl/types.ts +5 -1
  348. package/src/modules/discord/index.ts +4 -2
  349. package/src/modules/workspace/disk-agreement.ts +74 -18
  350. package/src/modules/workspace/index.ts +41 -11
  351. package/src/modules/workspace/observe.ts +265 -39
  352. package/src/modules/workspace/reconcile.ts +71 -14
  353. package/src/modules/workspace/types.ts +26 -1
  354. package/src/recovery/offline-branch.ts +32 -28
  355. package/src/stored-removal.ts +126 -0
  356. package/src/types/agent.ts +42 -6
  357. package/src/types/framework.ts +10 -0
  358. package/src/types/module.ts +8 -4
  359. package/src/types/trace.ts +62 -11
package/src/framework.ts CHANGED
@@ -6,7 +6,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF
6
6
  import { JsStore } from '@animalabs/chronicle';
7
7
  import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
8
8
  import { MembraneError } from '@animalabs/membrane';
9
- import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager';
9
+ import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks } from '@animalabs/context-manager';
10
10
  import type { CacheWireReceipt } from './kv-unified-wire.js';
11
11
  import type { SilentHeartbeatStamp } from './silent-heartbeat.js';
12
12
  import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js';
@@ -19,6 +19,14 @@ import type {
19
19
  StoredMessage,
20
20
  TokenBudget,
21
21
  } from '@animalabs/context-manager';
22
+ import {
23
+ bodyGroupRun,
24
+ bodyRangeIn,
25
+ locateMessageIndex,
26
+ locateMessageIndices,
27
+ removeStoredMessage,
28
+ removeStoredMessages,
29
+ } from './stored-removal.js';
22
30
  import type {
23
31
  FrameworkConfig,
24
32
  InferencePolicy,
@@ -61,11 +69,12 @@ import type {
61
69
  SameRoundThinkTextPolicy,
62
70
  } from './types/index.js';
63
71
  import { ProcessQueueImpl } from './queue.js';
64
- import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage } from './shutdown.js';
72
+ import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js';
65
73
  import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js';
66
74
  import { Agent } from './agent.js';
67
75
  import { ModuleRegistry, checkedToolResult, isStateExistsError, storableToolResult, thrownResult } from './module-registry.js';
68
76
  import { McplServerRegistry } from './mcpl/server-registry.js';
77
+ import { logValue } from './mcpl/log-value.js';
69
78
  import { FeatureSetManager } from './mcpl/feature-set-manager.js';
70
79
  import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
71
80
  import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
@@ -394,7 +403,7 @@ import { isToolAllowed } from './mcpl/tool-policy.js';
394
403
  import { EventGate, formatShadowWarning } from './gate/event-gate.js';
395
404
  import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
396
405
  import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
397
- import type { McplServerConnection } from './mcpl/server-connection.js';
406
+ import { stderrTailOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js';
398
407
  import { isWebSocketTransport } from './mcpl/transport.js';
399
408
  import type {
400
409
  McplServerConfig,
@@ -522,53 +531,110 @@ interface AdmittedEnvelope {
522
531
  }
523
532
 
524
533
  /**
525
- * Whether a stream resumes its logical turn rather than starting one: a budget
526
- * restart, or a tool-result-guard retry. Such a stream keeps the turn's
527
- * routing state (the sticky destination, the turn's destination) and queues
528
- * its deliveries behind the turn's earlier ones.
534
+ * Whether a stream resumes its turn's request rather than starting one: a
535
+ * budget restart, or a tool-result-guard retry. Such a start takes in no new
536
+ * input, and keeps the turn's locus, trigger channel and tool-call count. Its
537
+ * routing state is the logical turn's, as for every start that continues the
538
+ * turn (TurnRouting).
529
539
  */
530
540
  function resumesLogicalTurn(reason: string | undefined): boolean {
531
541
  return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
532
542
  }
533
543
 
534
544
  /**
535
- * One turn's routing and delivery receipts (AgentFramework.turnRouting).
536
- * A fresh turn gets a new object; a queued delivery keeps its own turn's.
545
+ * One logical turn's routing state and receipt tallies
546
+ * (AgentFramework.turnRouting): everything the turn has decided about where
547
+ * its speech goes. Every stream of the turn shares it: the start that opens
548
+ * the turn, a resume (resumesLogicalTurn), and each start while the turn is
549
+ * open (AgentFramework.openLogical): a framework retry, the re-inference that
550
+ * consumes unstreamed tool results, a capacity-held turn's later compile. So
551
+ * none of them re-decides the locus, drops a sticky destination or a hold, or
552
+ * forgets a ` !`, and the receipt at the turn's end reports every stream's
553
+ * deliveries. A silent start has one of its own, so it neither takes nor
554
+ * disturbs an open turn's. A queued delivery keeps the object of the turn
555
+ * that queued it.
537
556
  */
538
557
  interface TurnRouting {
558
+ /**
559
+ * The channel whose message triggered the turn (item-3 redux), which the
560
+ * locus is resolved from when the turn opens, and which the channel
561
+ * registry reports as the turn's default outgoing channel. A turn that
562
+ * channel_opens a channel moves it there. Kept across the turn's streams:
563
+ * a budget restart's trigger carries no channel, and dropping it there
564
+ * sent Sol's DM reply to a stale guild channel (2026-07-22).
565
+ */
566
+ triggerChannel?: string;
567
+ /**
568
+ * Locus and hybrid mode: where the turn's unpinned plain prose lands.
569
+ * Frozen when the turn opens (home channel for a fork, else the trigger
570
+ * channel, else the global default), so ambient chatter, reactions and
571
+ * system markers can't move it (2026-07-21 Cairn lounge misroute). Since
572
+ * 2026-07-31 a re-pin moves it (repinTurnLocus): an addressed message, or a
573
+ * follow-up in a channel the turn engaged. The agent's own channel_open
574
+ * moves it too. Unset in explicit and disabled mode, and for a turn with
575
+ * nowhere to speak.
576
+ */
577
+ locus?: string;
578
+ /**
579
+ * Channels the turn sent into with a send tool (SEND_ENGAGEMENT_TOOLS): a
580
+ * conversational follow-up in one of them is addressed by context, so it
581
+ * re-pins the locus (2026-07-31 n=7: q's #portables reply, with no
582
+ * mention, followed the stale pin into repligate's DM).
583
+ */
584
+ engaged: Set<string>;
539
585
  /** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */
540
586
  pin?: string;
541
587
  /**
542
588
  * Explicit mode: the destination the turn's delivered rounds have set,
543
- * decided at each round boundary (roundDestination), where a stream that
544
- * resumes the turn starts.
589
+ * decided at each round boundary (roundDestination), where each of the
590
+ * turn's streams starts.
545
591
  */
546
592
  destination: string | null;
547
- /** Hybrid: held back after a malformed or unresolved envelope, until a later `>>>` names a target. */
593
+ /**
594
+ * Hybrid: held back after `>>>skip_reply`, or an envelope that is malformed,
595
+ * doesn't resolve or isn't delivered, until a later `>>>` names a target.
596
+ */
548
597
  hybridHold: boolean;
549
- /** What the logical turn keeps across a new stream (LogicalTurn). */
550
- logical: LogicalTurn;
551
- }
552
-
553
- /**
554
- * What every stream of one logical turn shares, from the fresh start that
555
- * opens it until it settles (AgentFramework.openLogical): a framework retry,
556
- * a re-inference after unstreamed tool results, a capacity-held turn's later
557
- * compile. beginAgentTurn starts each with fresh routing, but a ` !` asked
558
- * for in any of them stands, and the receipt at the turn's end reports every
559
- * stream's deliveries, even ones that land after a new stream began.
560
- */
561
- interface LogicalTurn {
562
598
  /** The turn asked for a ` !` continuation; taken once. */
563
599
  continuation: boolean;
564
600
  /** Successful plain-prose destinations in delivery order; deduped by the receipt. */
565
601
  deliveries: string[];
566
602
  /** Plain-prose segments the turn suppressed, reported by its receipt. */
567
603
  suppressed: number;
604
+ /**
605
+ * The messages that would re-pin the locus (repinChannel) that no tool
606
+ * boundary of the turn weighed, in the order they were stored
607
+ * (noteArrival). A message stored for this agent is one when none of the
608
+ * turn's streams was running: stored during a capacity hold or the wait
609
+ * for a re-inference, flushed at the end of a stream that didn't settle
610
+ * the turn, or stored by the start that continues it. So is one a boundary
611
+ * drained but couldn't re-pin for, being over budget or without a live
612
+ * stream. A message stored for another ordinary resident is one whenever
613
+ * it lands, since residents read one message slot and a boundary weighs
614
+ * only the messages drained for its own agent (noteForOtherResidents). A
615
+ * message tune-out diverted isn't one, since a resident's compiled view
616
+ * leaves it out. A boundary that re-pins clears them, since what it injects
617
+ * is newer. The turn's next start clears them all, and in locus or hybrid
618
+ * mode first re-pins to the newest one still in the agent's store, as a
619
+ * boundary would have weighed its injections (remainingArrival). One
620
+ * removed meanwhile, such as an unread coalesced message its server
621
+ * retracted, isn't there for the agent to answer.
622
+ */
623
+ arrivals: Arrival[];
624
+ }
625
+
626
+ /** A message that would re-pin its turn's locus (TurnRouting.arrivals). */
627
+ interface Arrival {
628
+ messageId: MessageId;
629
+ /** The channel it re-pins the locus to. */
630
+ channelId: string;
568
631
  }
569
632
 
570
633
  function freshTurnRouting(): TurnRouting {
571
- return { destination: null, hybridHold: false, logical: { continuation: false, deliveries: [], suppressed: 0 } };
634
+ return {
635
+ engaged: new Set(), destination: null, hybridHold: false,
636
+ continuation: false, deliveries: [], suppressed: 0, arrivals: [],
637
+ };
572
638
  }
573
639
 
574
640
  /** How a round's prose is delivered: kept private, and under which turn's routing. */
@@ -637,6 +703,119 @@ function normalizeDiscordAwarenessDeadline(value: number | undefined): number {
637
703
  );
638
704
  }
639
705
 
706
+ /** Messages copied into another conversation (a fork's seed) without their
707
+ * `thinking` and `redacted_thinking` blocks: a fork inherits the template's
708
+ * conversation, not its reasoning. A signed block was minted under the
709
+ * template's prefix, which the fork's doesn't promise to match; unsigned
710
+ * reasoning (text-only providers, XML mode) goes by the same rule. On the
711
+ * OpenAI Responses path a removed block also carried the reasoning item
712
+ * (`rawItem`) the message's other items were paired with by id, so in a
713
+ * message that lost its thinking those items lose their `rawItem` and render
714
+ * from their fields, without ids; an opaque carrier (a text block whose raw
715
+ * item isn't a `message`, such as a compaction) keeps it, since the raw item
716
+ * is its whole content. An Anthropic or XML-mode message's thinking carries
717
+ * no raw item, and its only raw items are such carriers (membrane keeps a
718
+ * block it has no type for, such as a `server_tool_use`, that way), so it
719
+ * loses only its thinking. A message with no content left, whether
720
+ * the filter emptied it or it was already empty, is left out, since a
721
+ * request can't carry an empty message; any other message without thinking
722
+ * is kept as it was. */
723
+ function withoutThinking<M extends { content: ContentBlock[] }>(messages: readonly M[]): M[] {
724
+ const out: M[] = [];
725
+ for (const message of messages) {
726
+ const content = message.content.filter((b) => !isThinkingBlock(b));
727
+ if (content.length === 0) continue;
728
+ out.push(content.length === message.content.length
729
+ ? message
730
+ : { ...message, content: content.map(releasePairedRawItem) });
731
+ }
732
+ return out;
733
+ }
734
+
735
+ function isThinkingBlock(block: ContentBlock): boolean {
736
+ return block.type === 'thinking' || block.type === 'redacted_thinking';
737
+ }
738
+
739
+ function rawItemOf(block: ContentBlock): unknown {
740
+ return (block as { rawItem?: unknown }).rawItem;
741
+ }
742
+
743
+ /** A block without the provider-native item it was parsed from, unless that
744
+ * item is the block's whole content: an opaque carrier, a text block whose
745
+ * raw item isn't a `message` item (membrane keeps compaction and other items
746
+ * it has no block for that way). */
747
+ function releasePairedRawItem(block: ContentBlock): ContentBlock {
748
+ const raw = rawItemOf(block);
749
+ if (raw === undefined) return block;
750
+ const rawType = typeof raw === 'object' && raw !== null ? (raw as { type?: unknown }).type : undefined;
751
+ if (block.type === 'text' && rawType !== 'message') return block;
752
+ const { rawItem: _released, ...rest } = block as ContentBlock & { rawItem?: unknown };
753
+ return rest as ContentBlock;
754
+ }
755
+
756
+ /** One log line for a fork's seed that left something out, so an operator who
757
+ * sees a fork answer differently from its template has something to check:
758
+ * the template's thinking blocks, and the tool blocks the pairing pass
759
+ * dropped (calls with no result in the seed, results whose call isn't in
760
+ * it). `withoutReasoning` is the seed before that pass, `seed` after it. */
761
+ function logSeedOmissions(
762
+ fork: string,
763
+ template: string,
764
+ compiled: readonly { content: ContentBlock[] }[],
765
+ withoutReasoning: readonly { content: ContentBlock[] }[],
766
+ seed: readonly { content: ContentBlock[] }[],
767
+ ): void {
768
+ const count = (messages: readonly { content: ContentBlock[] }[], test: (b: ContentBlock) => boolean) =>
769
+ messages.reduce((n, m) => n + m.content.filter(test).length, 0);
770
+ const thinking = count(compiled, isThinkingBlock);
771
+ const calls = count(withoutReasoning, (b) => b.type === 'tool_use') - count(seed, (b) => b.type === 'tool_use');
772
+ const results = count(withoutReasoning, (b) => b.type === 'tool_result') - count(seed, (b) => b.type === 'tool_result');
773
+ if (thinking + calls + results === 0) return;
774
+ console.log(
775
+ `[conversation] ${fork}: seeded from ${template} without ${thinking} thinking block(s), ` +
776
+ `${calls} unanswered tool call(s) and ${results} orphaned tool result(s)`,
777
+ );
778
+ }
779
+
780
+ /**
781
+ * A maintenance provider call withdrawn because a surgery holds the store, or
782
+ * because stop() has closed provider admission: either is the framework's own
783
+ * doing, and no failure. It sent nothing: it was refused at the provider
784
+ * gate, or woken there from its wait. For a call outside context-manager
785
+ * 0.14's compression fallback ladder, a merge's or a chunk's canonical call
786
+ * with its retries after a tool_use stop, context-manager counts no attempt.
787
+ * A merge keeps its queue entry, so the next tick after the hold, or after
788
+ * the next start, does it again. A chunk's compression has already left the
789
+ * queue: it logs "Failed to compress chunk (hierarchical)" with this error
790
+ * and records the call, and the chunk, still uncompressed, is queued again at
791
+ * that manager's next rebuild (its next initialization, message, sent compile
792
+ * or released hold). A call inside the ladder, after a chunk's canonical call
793
+ * was refused or cut short, is caught by the rung that made it and recorded
794
+ * as a provider error, since 0.14 rethrows only its own
795
+ * CompressionBranchDiscard there; once every rung has failed, the chunk's
796
+ * request family is quarantined, with its alert, and the tick returns.
797
+ */
798
+ class MaintenanceWithdrawnError extends Error {
799
+ constructor(reason: string) {
800
+ super(`maintenance call withdrawn: ${reason}`);
801
+ this.name = 'MaintenanceWithdrawnError';
802
+ }
803
+ }
804
+
805
+ /**
806
+ * A runtime-settings preview's reason, from what stopped it: the strategy's
807
+ * known throws as the codes `RuntimeSettingsPreview.reason` documents, and
808
+ * anything else verbatim. Message matching is fragile by nature, so an
809
+ * unknown message surfaces as the reason rather than being swallowed.
810
+ */
811
+ function previewStoppedReason(error: unknown): string {
812
+ const message = error instanceof Error ? error.message : String(error);
813
+ if (message.includes('requires reinitialization for the current branch generation')) return 'branch_generation_changed';
814
+ if (message.includes('already running; previews must not overlap')) return 'preview_in_flight';
815
+ if (message.includes('requires adaptiveResolution')) return 'no_adaptive_resolution';
816
+ return message;
817
+ }
818
+
640
819
  class DiscordAwarenessAccountingError extends Error {
641
820
  constructor(operation: string, cause: unknown) {
642
821
  const detail = cause instanceof Error ? cause.message : String(cause);
@@ -745,6 +924,28 @@ export class ResumeBlockedError extends Error {
745
924
  }
746
925
  }
747
926
 
927
+ /**
928
+ * How `runEphemeralToCompletion` rejects when a retryable provider failure
929
+ * states a wait the run won't sleep: one longer than an ephemeral run sleeps
930
+ * in its turn (600 s), an infinite one, or one that is `NaN`. The run ends at
931
+ * once, without retrying inside the wait, and every other ending of the run
932
+ * rejects with a plain `Error`. `retryAfterMs` is the wait as the provider
933
+ * stated it, in milliseconds, so `Infinity` or `NaN` in those two cases: a
934
+ * host that schedules a rerun from it handles a value that isn't finite, and
935
+ * one past the 2,147,483,647 ms a timer can hold. The message says the same
936
+ * in words. A host can test `instanceof` or the `name`, which also holds for
937
+ * an error from another copy of this package.
938
+ */
939
+ export class ProviderWaitNotSleptError extends Error {
940
+ constructor(
941
+ message: string,
942
+ readonly retryAfterMs: number,
943
+ ) {
944
+ super(message);
945
+ this.name = 'ProviderWaitNotSleptError';
946
+ }
947
+ }
948
+
748
949
  interface RedoEntry {
749
950
  branchName: string;
750
951
  checkpoint: TurnCheckpoint;
@@ -767,72 +968,6 @@ class DefaultInferencePolicy implements InferencePolicy {
767
968
  }
768
969
  }
769
970
 
770
- /** Minimal read surface the live-surgery helpers need from a ContextManager. */
771
- interface SurgeryReadableCm {
772
- getMessageCount(): number;
773
- getMessageWindow(
774
- offset: number,
775
- limit: number,
776
- opts?: { resolveBlobs?: boolean; alignToBodyGroups?: boolean },
777
- ): { messages: Array<{ id: unknown; bodyGroupId?: string }>; startIndex: number };
778
- }
779
-
780
- const SURGERY_SCAN_WINDOW = 500;
781
-
782
- /**
783
- * Slot index of `messageId` on the active branch, or -1. Scans from the tail
784
- * in blob-free windows: operator targets are almost always recent, and a
785
- * full `getAllMessages()` re-inflates every attachment on the branch.
786
- */
787
- function locateMessageIndex(cm: SurgeryReadableCm, messageId: string): number {
788
- const hit = locateMessageIndices(cm, new Set([messageId])).get(messageId);
789
- return hit ? hit.index : -1;
790
- }
791
-
792
- /** Tail-first scan resolving many ids at once; stops as soon as all are found. */
793
- function locateMessageIndices(
794
- cm: SurgeryReadableCm,
795
- ids: Set<string>,
796
- ): Map<string, { index: number; bodyGroupId?: string }> {
797
- const found = new Map<string, { index: number; bodyGroupId?: string }>();
798
- const total = cm.getMessageCount();
799
- for (let end = total; end > 0 && found.size < ids.size; end -= SURGERY_SCAN_WINDOW) {
800
- const start = Math.max(0, end - SURGERY_SCAN_WINDOW);
801
- const { messages, startIndex } = cm.getMessageWindow(start, end - start, { resolveBlobs: false });
802
- for (let i = messages.length - 1; i >= 0; i--) {
803
- const id = String(messages[i].id);
804
- if (ids.has(id) && !found.has(id)) {
805
- found.set(id, {
806
- index: startIndex + i,
807
- ...(messages[i].bodyGroupId ? { bodyGroupId: messages[i].bodyGroupId } : {}),
808
- });
809
- }
810
- }
811
- }
812
- return found;
813
- }
814
-
815
- /**
816
- * Inclusive slot range of the body-group run containing `index` (the slot
817
- * itself when it is not sharded). Delegates to the store's own
818
- * `alignToBodyGroups` edge walk — one contiguity assumption, not two.
819
- */
820
- function bodyGroupRun(
821
- cm: SurgeryReadableCm,
822
- index: number,
823
- ): { from: number; to: number; fromId: string; toId: string; group: boolean } {
824
- const win = cm.getMessageWindow(index, 1, { alignToBodyGroups: true, resolveBlobs: false });
825
- const from = win.startIndex;
826
- const to = win.startIndex + win.messages.length - 1;
827
- return {
828
- from,
829
- to,
830
- fromId: String(win.messages[0].id),
831
- toId: String(win.messages[win.messages.length - 1].id),
832
- group: win.messages[0].bodyGroupId !== undefined,
833
- };
834
- }
835
-
836
971
  function describeMarkers(m: SurgeryMarkerReceipt): string {
837
972
  if (m.status === 'queued') return `queued:${m.queued}`;
838
973
  if (m.status === 'not-scheduled') return 'not-scheduled(batch retired)';
@@ -1341,18 +1476,19 @@ function boundFailureFields(record: Record<string, unknown>): Record<string, unk
1341
1476
  *
1342
1477
  * 400 and 422 are how a provider answers a request that fails its
1343
1478
  * validation, as a history it can't accept does, but other refusals come
1344
- * back the same way, such as Anthropic's 400 for a credit balance that's too
1345
- * low. A 413 is the request's size. When the newest exchange made it too
1346
- * large, as an oversized attachment does, shedding that exchange may bring
1347
- * the request back under the limit, and nothing else in the framework
1348
- * recovers a 413; when the history as a whole is too large, the shed doesn't
1349
- * help, and the breaker stops at its rewind cap. The 413 arm takes both
1350
- * labels a 413 arrives under, invalid_request and context_length, because
1351
- * membrane's label for a 413 differs by version and adapter. Other statuses
1352
- * keep the history, since shed history can't be got back and the status
1353
- * gives no sign that a shed would help: a 404 is a model or an endpoint that
1354
- * doesn't exist, and 405, 409, 410 and 415 are about the HTTP request or the
1355
- * resource, as a rule, rather than the conversation.
1479
+ * back the same way, such as a refusal of the account that membrane doesn't
1480
+ * recognize by its text. A 413 is the request's size. When the newest
1481
+ * exchange made it too large, as an oversized attachment does, shedding that
1482
+ * exchange may bring the request back under the limit, and nothing else in
1483
+ * the framework recovers a 413; when the history as a whole is too large,
1484
+ * the shed doesn't help, and the breaker stops at its rewind cap. The 413 arm
1485
+ * takes both labels membranes have given a 413, invalid_request and
1486
+ * context_length (see the notes below). Other statuses keep the history,
1487
+ * since a shed takes the exchange out of the agent's context, which only an
1488
+ * operator's undo brings back, and the status gives no sign that a shed
1489
+ * would help: a 404 is a model or an endpoint that doesn't exist, and 405,
1490
+ * 409, 410 and 415 are about the HTTP request or the resource, as a rule,
1491
+ * rather than the conversation.
1356
1492
  *
1357
1493
  * The table test in test/inference-exhaustion-rewind.test.ts is this
1358
1494
  * predicate's contract. The notes below on which membrane version gives which
@@ -1367,13 +1503,24 @@ function boundFailureFields(record: Record<string, unknown>): Record<string, unk
1367
1503
  * adapter files every status it doesn't list (404, 405, 409, 410, 413,
1368
1504
  * 415, 422 …) as invalid_request, so it alone produces an invalid_request
1369
1505
  * at 413 or 422, and no 0.5.86 adapter produces a context_length at 413.
1370
- * - From membrane #56 (merged, not yet in a release), one table serves every
1371
- * adapter. Auth codes and context overflow are checked on a 400 or 422
1372
- * first, a 404 is invalid_request, a 413 is context_length, and other 4xx
1373
- * are invalid_request. So a 413 still sheds, an Anthropic one included,
1374
- * while a context_length at 400 or 422, a token overflow, keeps the
1375
- * history. The 422 arm then also covers an OpenAI-compatible server, such
1376
- * as a FastAPI one, that answers a validation failure with 422.
1506
+ * - From membrane 0.5.87 (membrane #56 and #107), this package's floor, every
1507
+ * adapter sends an HTTP failure through one status table
1508
+ * (errorFromProviderStatus). On a 400 or 422, auth codes and reasons and
1509
+ * the two account refusals it recognizes by their text (a balance too low,
1510
+ * usage limits reached) are auth, and a context overflow, by its code or
1511
+ * text, is context_length. A 401, 402 or 403 is auth, a 408 timeout, a 429
1512
+ * rate_limit, a 404 invalid_request and a 413 context_length; other 4xx
1513
+ * are invalid_request. So a 413 the table types sheds, an Anthropic one
1514
+ * included, while a context_length at 400 or 422, a token overflow, keeps
1515
+ * the history, and so does an account refusal, which is auth. An adapter
1516
+ * can type a failure before the table does, as Anthropic's does for a
1517
+ * gateway's aggregate error (server). A failure with no HTTP status takes
1518
+ * the status its provider code implies when the table knows the code, as
1519
+ * for Anthropic's mid-stream errors, and is otherwise typed from its
1520
+ * message, with no status. The 422 arm also covers an
1521
+ * OpenAI-compatible server, such as a FastAPI one, that answers a
1522
+ * validation failure with 422. The test of provider answers through the
1523
+ * table, beside the table test, pins how the two meet at the floor.
1377
1524
  *
1378
1525
  * An error with no status, from a membrane that reports none, keeps the rule
1379
1526
  * the breaker had before: the type alone.
@@ -1477,6 +1624,10 @@ export class AgentFramework {
1477
1624
  private stopPromise: Promise<void> | null = null;
1478
1625
  private stopCompleted = false;
1479
1626
  private ownedStoreClosed = false;
1627
+ /** An operator's operations in flight, and the config materializations they
1628
+ * start. stop() waits until this set is empty before it tears down, unless
1629
+ * its wait budget runs out. */
1630
+ private readonly operatorOperations = new Set<{ label: string; promise: Promise<void> }>();
1480
1631
  private membrane: Membrane;
1481
1632
  private queue: ProcessQueueImpl;
1482
1633
  private agents: Map<string, Agent> = new Map();
@@ -1484,16 +1635,6 @@ export class AgentFramework {
1484
1635
  private inferencePolicy: InferencePolicy;
1485
1636
  private errorPolicy: ErrorPolicy;
1486
1637
  private pendingRequests: InferenceRequest[] = [];
1487
- /**
1488
- * Per-agent channel that triggered the agent's CURRENT inference turn, if any
1489
- * (item-3 redux). Read by the ChannelRegistry's `activeChannelResolver` to
1490
- * route a single-trunk agent's plain-text speech back to the channel it is
1491
- * answering, instead of the process-global most-recent-inbound locus that a
1492
- * concurrent message elsewhere can hijack. Set at turn start (or cleared for a
1493
- * heartbeat / no-trigger turn) in startAgentStream; overwritten by the next
1494
- * turn. Never read between turns (a given agent runs one turn at a time).
1495
- */
1496
- private activeTriggerChannels: Map<string, string> = new Map();
1497
1638
  private running = false;
1498
1639
  private loopPromise: Promise<void> | null = null;
1499
1640
  /** Quiesce/maintenance mode (issue #122). DELIBERATELY separate from
@@ -1516,6 +1657,14 @@ export class AgentFramework {
1516
1657
  private maintenanceRunId = 0;
1517
1658
  private currentMaintenanceRun: ContextMaintenanceRun | null = null;
1518
1659
  private maintenanceHistory: ContextMaintenanceRun[] = [];
1660
+ /** Each agent's last reported maintenance failure, its whole text and when
1661
+ * it was alerted, until its maintenance goes without one
1662
+ * (reportMaintenanceFailure). */
1663
+ private maintenanceFailureReported = new Map<string, { text: string; at: number }>();
1664
+ /** Run records whose failure repeated one already reported, and whose report was suppressed. */
1665
+ private maintenanceRepeats = new WeakSet<ContextMaintenanceAgentRun>();
1666
+ /** An operator's maintain (maintenanceTick) asked for the pass running or about to run: the run it records is kept in the history whatever it met. A pass with nothing to run records none. */
1667
+ private maintenancePassRequested = false;
1519
1668
  private providerGates: Map<string, LocalProviderGate> = new Map();
1520
1669
  private providerAccelerationCooldowns: Map<string, ProviderAccelerationCooldown> = new Map();
1521
1670
  private providerAccelerationRecoveries: Map<string, ProviderAccelerationRecovery> = new Map();
@@ -1538,32 +1687,6 @@ export class AgentFramework {
1538
1687
  private processLoggingBroadcast: boolean;
1539
1688
  private activeStreams: Map<string, Promise<void>> = new Map();
1540
1689
 
1541
- /** Per-agent output locus FROZEN for the CURRENT logical turn. Resolved
1542
- * eagerly in startAgentStream (home → addressed trigger → global default)
1543
- * and never moved until the next turn: mid-turn injected messages — ambient
1544
- * chatter, reactions, system markers — must not hijack where the agent's
1545
- * plain prose lands (2026-07-21 Cairn lounge misroute). Lives here — not in
1546
- * driveStream locals — so the pin survives a context-budget stream restart,
1547
- * which continues the same logical turn in a fresh driveStream. Cleared or
1548
- * re-set at the start of every non-restart turn. Since 2026-07-31 the pin
1549
- * is no longer fully frozen: an ADDRESSED conversational injection — or a
1550
- * conversational follow-up in a channel the agent itself explicitly sent
1551
- * into this turn (turnEngagedChannels) — re-pins it at the boundary (see
1552
- * the addressed re-pin block in the tool-result handler). Ambient chatter
1553
- * elsewhere, reactions, and system markers still cannot move it. */
1554
- private turnLocusPins: Map<string, string> = new Map();
1555
- /** Channels the agent EXPLICITLY sent into during the CURRENT turn
1556
- * (SEND_ENGAGEMENT_TOOLS, successful calls), per agent. Second re-pin
1557
- * signal: a conversational reply in a channel the agent just engaged is
1558
- * addressed by context even without a mention (2026-07-31 n=7: q's
1559
- * #portables follow-up — no @mention — was answered in trailing prose
1560
- * that followed the stale pin into repligate's DM). No author-kind
1561
- * filter: agent-residents are full participants, and the bot flag
1562
- * tracks nothing that matters (antra). Reactions/system markers are
1563
- * excluded by isConversationalInjection; the engaged-this-turn scope
1564
- * keeps unrelated channels from moving the pin. Cleared at every fresh
1565
- * turn's start; budget restarts keep it. */
1566
- private turnEngagedChannels: Map<string, Set<string>> = new Map();
1567
1690
  /** A tool boundary injected fresh CONVERSATIONAL input (a real message —
1568
1691
  * not a reaction or a system marker) into the live stream. Tells
1569
1692
  * driveStream to clear sticky explicit-send suppression before handling
@@ -1579,12 +1702,13 @@ export class AgentFramework {
1579
1702
  /** Latest bounced (undelivered) prose per agent — the `{{unsent}}` source. */
1580
1703
  private proseClipboards: Map<string, string> = new Map();
1581
1704
  /**
1582
- * Each agent's routing state for its current turn (TurnRouting): the
1583
- * sticky destination, the turn's destination for a resumed stream,
1584
- * hybrid's hold, a `!` continuation and receipt tallies. A fresh turn starts a new object
1585
- * instead of clearing the old one, and a stream that resumes the turn
1586
- * (resumesLogicalTurn) keeps it. A queued delivery holds the object of the
1587
- * turn that queued it, so prose a turn leaves queued when it ends (a
1705
+ * Each agent's routing state for its current logical turn (TurnRouting):
1706
+ * its trigger channel, locus and engaged channels, the sticky destination,
1707
+ * the turn's destination, hybrid's hold, a ` !` continuation and receipt
1708
+ * tallies. The start that opens a turn makes a
1709
+ * new object instead of clearing the old one, and every later stream of
1710
+ * the turn shares it (openLogical). A queued delivery holds the object of
1711
+ * the turn that queued it, so prose a turn leaves queued when it ends (a
1588
1712
  * skip_reply ends it before its posts have run) still follows that turn's
1589
1713
  * destination, and its late writes can't reach the next turn.
1590
1714
  */
@@ -1596,25 +1720,27 @@ export class AgentFramework {
1596
1720
  */
1597
1721
  private proseSpeechChains: Map<string, Promise<void>> = new Map();
1598
1722
  /**
1599
- * Each agent's open logical turn (LogicalTurn). It stays open from the
1600
- * fresh start that opens it until the turn settles (settleAgent: it
1601
- * completes, a tool ends it, or it fails or is abandoned), and every fresh
1602
- * start that routes prose while it is open continues it: a framework
1603
- * retry, the start that consumes tool results the non-streaming fallback
1604
- * left (whichever wake the scheduler starts it with), the later compile a
1605
- * provider-capacity hold defers a turn to. A silent start neither
1606
- * continues nor opens one. A stream that ends without settling needs to do
1607
- * nothing to carry its turn. Settling only detaches the turn from future
1608
- * starts: the settling stream keeps its own object, which its remaining
1609
- * deliveries, its receipt and its ` !` check go on using (a completion
1610
- * settles before it awaits its chain and writes its receipt). So every
1611
- * logical end must close inheritance once, before the next fresh start:
1612
- * a turn left open would hand its object, late writes and all, to an
1613
- * unrelated turn. A tool-ended turn settles before the next turn can
1614
- * start. Only the stream that holds the open turn closes it; disposal
1615
- * drops it, and stop() clears every agent's.
1723
+ * Each agent's open logical turn: its routing state and receipt tallies
1724
+ * (TurnRouting). It stays open from the start that opens it until the
1725
+ * turn settles (settleAgent: it completes, a tool ends it, or it fails or
1726
+ * is abandoned), and every start that routes prose while it is open
1727
+ * continues it with that object: a framework retry, the start that
1728
+ * consumes tool results the non-streaming fallback left (whichever wake
1729
+ * the scheduler starts it with), the later compile a provider-capacity
1730
+ * hold defers a turn to. A silent start neither continues nor opens one,
1731
+ * and leaves the open turn's object untouched (its own is the live routing
1732
+ * while it runs: turnRouting). A stream that ends without
1733
+ * settling needs to do nothing to carry its turn. Settling only detaches
1734
+ * the turn from future starts: the settling stream keeps its own object,
1735
+ * which its remaining deliveries, its receipt and its ` !` check go on
1736
+ * using (a completion settles before it awaits its chain and writes its
1737
+ * receipt). So every logical end must close inheritance once, before the
1738
+ * next fresh start: a turn left open would hand its object, late writes
1739
+ * and all, to an unrelated turn. A tool-ended turn settles before the
1740
+ * next turn can start. Only the stream that holds the open turn closes
1741
+ * it; disposal drops it, and stop() clears every agent's.
1616
1742
  */
1617
- private openLogical: Map<string, LogicalTurn> = new Map();
1743
+ private openLogical: Map<string, TurnRouting> = new Map();
1618
1744
  /** Consecutive bounce-wakes per agent; capped so an agent that keeps
1619
1745
  * emitting unprefixed prose can't wake-loop (notices still append). */
1620
1746
  private proseBounceStreaks: Map<string, number> = new Map();
@@ -1675,6 +1801,8 @@ export class AgentFramework {
1675
1801
  * tick counts in the success log stop meaning anything. One kick per agent
1676
1802
  * at a time; cleared when the kick settles (success or failure). */
1677
1803
  private overBudgetDrainInFlight: Set<string> = new Set();
1804
+ /** The over-budget drain kicks running, which a branch move waits for as it waits for a maintenance pass. */
1805
+ private overBudgetDrains = new Set<Promise<void>>();
1678
1806
  /** Per-agent current rewind episode: the single consolidated marker's id and
1679
1807
  * how many turns have been shed so far. One marker per episode, updated in
1680
1808
  * place (see updateRewindMarker); cleared when the episode ends. */
@@ -2490,17 +2618,27 @@ export class AgentFramework {
2490
2618
  }
2491
2619
 
2492
2620
  /**
2493
- * Stop the event loop. Failed module/MCPL cleanup can be retried with its
2494
- * original context; storage stays open until that cleanup succeeds.
2495
- * @param teardownTimeoutMs Optional module/MCPL wait budget. Omit to wait
2496
- * until cleanup settles, as required by existing module/child contracts. This
2497
- * does not bound earlier stream or event-loop quiescence, or cancel cleanup.
2621
+ * Stop the event loop. After a failed or timed-out wait on operator, module
2622
+ * or MCPL cleanup, storage stays open and stop() can be called again. A
2623
+ * timed-out wait lets teardown go on while that work may still act on what
2624
+ * is being torn down.
2625
+ * @param teardownTimeoutMs Optional budget for each wait on cleanup: an
2626
+ * operator's operations already running, then modules and MCPL together.
2627
+ * Omit to wait until cleanup settles, as required by existing module/child
2628
+ * contracts. This does not bound earlier stream, event-loop or maintenance
2629
+ * quiescence (a maintenance pass or over-budget drain kick in flight), or
2630
+ * cancel cleanup.
2498
2631
  */
2499
2632
  stop(teardownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS): Promise<void> {
2500
2633
  try { validateShutdownTimeout(teardownTimeoutMs); }
2501
2634
  catch (error) { return Promise.reject(error); }
2502
2635
  if (this.stopPromise) return this.stopPromise;
2503
- const attempt = this.stopOwned(teardownTimeoutMs);
2636
+ // Claim the attempt before any of its body runs: stopOwned's synchronous
2637
+ // start can emit traces (the push coalescer's suspend() does), and a
2638
+ // listener that calls stop() there must get this attempt, not start a
2639
+ // second teardown beside it.
2640
+ let settle!: { resolve: () => void; reject: (error: unknown) => void };
2641
+ const attempt = new Promise<void>((resolve, reject) => { settle = { resolve, reject }; });
2504
2642
  this.stopPromise = attempt;
2505
2643
  void attempt.then(() => { this.stopCompleted = true; }, () => {});
2506
2644
  // Failed cleanup can be retried while its original store is open. A
@@ -2508,6 +2646,9 @@ export class AgentFramework {
2508
2646
  void attempt.catch(() => {
2509
2647
  if (!this.ownedStoreClosed && this.stopPromise === attempt) this.stopPromise = null;
2510
2648
  });
2649
+ // The body still starts in this call, so its synchronous steps (closing
2650
+ // the queue among them) happen before stop() returns.
2651
+ this.stopOwned(teardownTimeoutMs).then(settle.resolve, settle.reject);
2511
2652
  return attempt;
2512
2653
  }
2513
2654
 
@@ -2519,6 +2660,56 @@ export class AgentFramework {
2519
2660
  }
2520
2661
  }
2521
2662
 
2663
+ /** Register before invoking the body, so the set holds the operation for
2664
+ * its whole life: its synchronous trace callbacks can reenter stop(), and
2665
+ * that stop() must find it. Invocation stays in this stack, preserving
2666
+ * immediate mode changes; the returned promise carries its result to the
2667
+ * caller. */
2668
+ private trackOperatorOperation<T>(label: string, run: () => Promise<T>): Promise<T> {
2669
+ let resolve!: (value: T | PromiseLike<T>) => void;
2670
+ let reject!: (error: unknown) => void;
2671
+ const operation = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
2672
+ const entry = { label, promise: operation.then(() => {}, () => {}) };
2673
+ this.operatorOperations.add(entry);
2674
+ void entry.promise.then(() => { this.operatorOperations.delete(entry); });
2675
+ try { resolve(run()); } catch (error) { reject(error); }
2676
+ return operation;
2677
+ }
2678
+
2679
+ /** Whether stop() has begun: an attempt in flight (stopPromise is set before
2680
+ * its body runs, which emits traces before the queue closes) or a closed
2681
+ * queue (after a failed or completed stop()). The admission rule for every
2682
+ * operator entry point, the host/command listener included. */
2683
+ private stopHasBegun(): boolean {
2684
+ return !!this.stopPromise || !!this.queue?.isClosed;
2685
+ }
2686
+
2687
+ private refuseOperatorAfterStop(verb: string): void {
2688
+ if (this.stopHasBegun()) throw new Error(verb + ' refused: the framework is stopping');
2689
+ }
2690
+
2691
+ /** Wait in rounds until the set is empty: admitted work can still start work
2692
+ * of its own. All rounds share the caller's deadline rather than renewing
2693
+ * its budget. */
2694
+ private async waitForOperatorOperations(timeoutMs: number | undefined): Promise<void> {
2695
+ const deadline = timeoutMs === undefined ? undefined : performance.now() + timeoutMs;
2696
+ while (this.operatorOperations.size > 0) {
2697
+ const remaining = deadline === undefined ? undefined : Math.ceil(deadline - performance.now());
2698
+ const timedOut = () => new ShutdownTimeoutError(
2699
+ [...this.operatorOperations].map(({ label }) => label), timeoutMs!, [],
2700
+ );
2701
+ if (remaining !== undefined && remaining < 1) throw timedOut();
2702
+ try {
2703
+ await waitForShutdown([...this.operatorOperations], remaining);
2704
+ } catch (error) {
2705
+ // Report the original budget and everything still pending, including
2706
+ // child work added after this round's snapshot.
2707
+ if (error instanceof ShutdownTimeoutError) throw timedOut();
2708
+ throw error;
2709
+ }
2710
+ }
2711
+ }
2712
+
2522
2713
  private async stopOwned(teardownTimeoutMs: number | undefined): Promise<void> {
2523
2714
  this.pushCoalescer?.suspend();
2524
2715
  this.flushCoalescingSnapshot();
@@ -2560,8 +2751,10 @@ export class AgentFramework {
2560
2751
  // A stopped host must never hang behind its own cooldown.
2561
2752
  this.cancelProviderAdmission();
2562
2753
  // An open logical turn doesn't survive a stop: a start after a restart
2563
- // on this instance opens a new one rather than carrying its ` !` and
2564
- // receipt tallies across (openLogical).
2754
+ // on this instance opens a new one rather than continuing the old turn's
2755
+ // routing (openLogical). A resume still continues its turn, with its
2756
+ // routing state (turnRouting), since it carries that turn's own request
2757
+ // on: beginAgentTurn opens the turn again for it.
2565
2758
  this.openLogical.clear();
2566
2759
 
2567
2760
  // Cancel all active streams
@@ -2587,14 +2780,27 @@ export class AgentFramework {
2587
2780
  clearInterval(this.maintenanceTimer);
2588
2781
  this.maintenanceTimer = null;
2589
2782
  }
2590
- if (this.maintenancePass) {
2591
- await this.maintenancePass;
2592
- }
2783
+ // The pass and any over-budget drain kick: a compression in flight lands
2784
+ // before the store closes.
2785
+ await this.settleMaintenancePass();
2593
2786
 
2594
2787
  if (this.loopPromise) {
2595
2788
  await this.loopPromise;
2596
2789
  }
2597
2790
 
2791
+ // Admission closed when stop() began. What admitted operations start now,
2792
+ // such as a surgery's config materialization, joins the set, so wait until
2793
+ // the set is empty. This comes after the streams and the event loop, which
2794
+ // a quiesce or a surgery may be waiting on. If the budget runs out,
2795
+ // teardown goes on while the operation may still act on what it tears
2796
+ // down, and the store stays open for another stop().
2797
+ let operatorOperationsTimedOut: unknown;
2798
+ try {
2799
+ await this.waitForOperatorOperations(teardownTimeoutMs);
2800
+ } catch (error) {
2801
+ operatorOperationsTimedOut = error;
2802
+ }
2803
+
2598
2804
  // Stop typing indicators
2599
2805
  this.channelRegistry?.stopAll();
2600
2806
 
@@ -2621,7 +2827,7 @@ export class AgentFramework {
2621
2827
  }
2622
2828
  // Timed-out or failed teardown retains its context and may still write.
2623
2829
  // Sync available state best-effort, but leave its store open for a retry.
2624
- const shutdownErrors: unknown[] = [];
2830
+ const shutdownErrors: unknown[] = operatorOperationsTimedOut === undefined ? [] : [operatorOperationsTimedOut];
2625
2831
  for (const result of await Promise.allSettled(shutdownPromises)) {
2626
2832
  if (result.status === 'rejected') shutdownErrors.push(result.reason);
2627
2833
  }
@@ -2716,18 +2922,26 @@ export class AgentFramework {
2716
2922
  }
2717
2923
  private async withAuxiliaryAdmission<T>(agentName: string, run: () => Promise<T>, model?: string): Promise<T> {
2718
2924
  const gate = this.providerGate(agentName);
2925
+ // A surgery's hold or a stop withdraws the call before a provider wait
2926
+ // can refuse it: either is the framework's own doing, and no failure.
2927
+ this.withdrawMaintenanceUnderSurgery();
2928
+ this.withdrawMaintenanceWhenStopping(agentName);
2719
2929
  this.refuseHeldProviderModel(agentName, model);
2720
- if (this.providerGateBlocked(agentName)) {
2930
+ // A parked call is woken when the gate opens, when stop() closes
2931
+ // admission, or when a surgery takes the store; one woken while the gate
2932
+ // is still closed parks again.
2933
+ while (this.providerGateBlocked(agentName)) {
2721
2934
  gate.deferredAuxiliary++;
2722
2935
  try {
2723
2936
  await new Promise<void>((resolve) => gate.auxiliaryWaiters.push(resolve));
2724
2937
  } finally {
2725
2938
  gate.deferredAuxiliary = Math.max(0, gate.deferredAuxiliary - 1);
2726
2939
  }
2940
+ if (this.providerAdmissionClosed) break;
2941
+ // A surgery that took the store while this call was parked withdraws it.
2942
+ this.withdrawMaintenanceUnderSurgery();
2727
2943
  }
2728
- if (this.providerAdmissionClosed) {
2729
- throw new Error(`Provider admission closed while stopping (${agentName})`);
2730
- }
2944
+ this.withdrawMaintenanceWhenStopping(agentName);
2731
2945
  // A wait recorded while this call was parked binds it too.
2732
2946
  this.refuseHeldProviderModel(agentName, model);
2733
2947
  gate.auxiliaryInFlight++;
@@ -2749,6 +2963,28 @@ export class AgentFramework {
2749
2963
  if (gate.auxiliaryInFlight === 0) for (const resolve of gate.idleWaiters.splice(0)) resolve();
2750
2964
  }
2751
2965
  }
2966
+ /**
2967
+ * Maintenance makes no provider call while a surgery holds the store. The
2968
+ * surgery waits for the maintenance work that is running before it moves
2969
+ * the branch, so a call that parked at the gate would make it wait on
2970
+ * whatever reopens the gate: the end of a cooldown, or the retry turn a
2971
+ * cooldown queues, which the hold itself keeps from starting.
2972
+ */
2973
+ private withdrawMaintenanceUnderSurgery(): void {
2974
+ if (this.surgeryHold) {
2975
+ throw new MaintenanceWithdrawnError(`a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store`);
2976
+ }
2977
+ }
2978
+ /**
2979
+ * Nor once stop() has closed provider admission: a stop that catches a
2980
+ * tick is the framework stopping on purpose, so the call is withdrawn, as
2981
+ * under a surgery's hold, and no failure is reported for it.
2982
+ */
2983
+ private withdrawMaintenanceWhenStopping(agentName: string): void {
2984
+ if (this.providerAdmissionClosed) {
2985
+ throw new MaintenanceWithdrawnError(`provider admission closed while stopping (${agentName})`);
2986
+ }
2987
+ }
2752
2988
  private auxiliaryMembraneFor(agentName: string): Membrane {
2753
2989
  return this.membraneAdmitting((run, model) => this.withAuxiliaryAdmission(agentName, run, model));
2754
2990
  }
@@ -2760,10 +2996,16 @@ export class AgentFramework {
2760
2996
  * as a resident's are, so they bind its auxiliary calls too: a held model
2761
2997
  * is refused without a call, and an auxiliary call's own stated wait is
2762
2998
  * recorded for it. (An ephemeral run records nothing, its name being
2763
- * single-use, so its context manager keeps the plain membrane.)
2999
+ * single-use, so its context manager keeps the plain membrane.) Its
3000
+ * compression is the background work a resident's is, whether a
3001
+ * maintenance tick or a drain after a message starts it, so it is withdrawn
3002
+ * as a resident's is: a call made while a surgery holds the store, or once
3003
+ * stop() has closed provider admission, is withdrawn without being sent.
2764
3004
  */
2765
3005
  private conversationAuxiliaryMembraneFor(agentName: string): Membrane {
2766
3006
  return this.membraneAdmitting(async (run, model) => {
3007
+ this.withdrawMaintenanceUnderSurgery();
3008
+ this.withdrawMaintenanceWhenStopping(agentName);
2767
3009
  this.refuseHeldProviderModel(agentName, model);
2768
3010
  try { return await run(); }
2769
3011
  catch (error) {
@@ -2924,17 +3166,26 @@ export class AgentFramework {
2924
3166
  return undefined;
2925
3167
  }
2926
3168
  /** Why an ephemeral run ended instead of sleeping a provider's stated wait,
2927
- * for its caller. */
2928
- private statedWaitNotSlept(error: Error): string {
2929
- const stated = isMembraneError(error) ? error.retryAfterMs : undefined;
3169
+ * for its caller: the text, and the wait itself, which its rejection
3170
+ * carries (ProviderWaitNotSleptError). */
3171
+ private statedWaitNotSlept(error: Error): { error: string; statedWaitMs: number } {
3172
+ // Called only after nonOwningProviderWait answered 'fail', which takes a
3173
+ // numeric stated wait.
3174
+ const stated = isMembraneError(error) && typeof error.retryAfterMs === 'number' ? error.retryAfterMs : Number.NaN;
2930
3175
  const cap = `${PROVIDER_ACCELERATION_MAX_COOLDOWN_MS / 1000} s`;
2931
- if (typeof stated !== 'number' || Number.isNaN(stated)) {
2932
- return `${error.message} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
2933
- `stated wait of ${cap} or less, so it ends now)`;
3176
+ if (Number.isNaN(stated)) {
3177
+ return {
3178
+ error: `${error.message} (the provider's stated wait is not a number, and an ephemeral run sleeps only a ` +
3179
+ `stated wait of ${cap} or less, so it ends now)`,
3180
+ statedWaitMs: Number.NaN,
3181
+ };
2934
3182
  }
2935
3183
  const asked = Number.isFinite(stated) ? `${Math.ceil(stated / 1000)} s` : 'longer than can be represented';
2936
- return `${error.message} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
2937
- `longer than ${cap}, so it ends now)`;
3184
+ return {
3185
+ error: `${error.message} (the provider asked to wait ${asked}; an ephemeral run doesn't sleep a stated wait ` +
3186
+ `longer than ${cap}, so it ends now)`,
3187
+ statedWaitMs: stated,
3188
+ };
2938
3189
  }
2939
3190
  /** When a hold's timer next fires: at its deadline, stepped past what one
2940
3191
  * timer can hold. A hold with no deadline (a wait held until released, or
@@ -3236,7 +3487,32 @@ export class AgentFramework {
3236
3487
  });
3237
3488
  }
3238
3489
 
3490
+ /** resume()'s refusal while a surgery holds the store. */
3491
+ private refuseResumeUnderSurgery(): void {
3492
+ if (!this.surgeryHold) return;
3493
+ throw new OperatorActionError(
3494
+ 'agent-busy',
3495
+ `Cannot resume: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`,
3496
+ );
3497
+ }
3498
+
3499
+ /** Whether maintenance work is running: a pass, or an over-budget drain kick. */
3500
+ private maintenanceRunning(): boolean {
3501
+ return this.maintenancePass !== null || this.overBudgetDrains.size > 0;
3502
+ }
3503
+
3504
+ /** Wait for the maintenance work that is running, whatever its outcome. */
3505
+ private async settleMaintenancePass(): Promise<void> {
3506
+ await Promise.allSettled([...(this.maintenancePass ? [this.maintenancePass] : []), ...this.overBudgetDrains]);
3507
+ }
3508
+
3239
3509
  private async runQueuedMaintenance(): Promise<void> {
3510
+ // A surgery holds the store while it switches or forks a branch. No pass
3511
+ // starts while it's held (the timer's next pass after it runs as usual),
3512
+ // and a running pass starts no new tick. The surgery waits for that pass
3513
+ // to finish before it moves the branch, so no maintenance work overlaps
3514
+ // the move, and a failure a pass meets is never the surgery's doing.
3515
+ if (this.surgeryHold) return;
3240
3516
  // A guard whose agent has left the map gets no activation to retry its
3241
3517
  // queue; retry it here. It leaves busyGuards once nothing of it is left.
3242
3518
  const live = new Set([...this.agents.values()].map((agent) => agent.toolResultGuard));
@@ -3248,32 +3524,66 @@ export class AgentFramework {
3248
3524
  console.error(`[tool-result-guard] agent=${name} storage retry failed during maintenance:`, error);
3249
3525
  }
3250
3526
  }
3251
- const queued = [...this.agents.values()].flatMap((agent) => {
3527
+ for (const name of this.maintenanceFailureReported.keys()) {
3528
+ if (!this.agents.has(name)) this.maintenanceFailureReported.delete(name);
3529
+ }
3530
+ type Queued = { agent: Agent; record: ContextMaintenanceAgentRun }
3531
+ & ({ failed: true } | { failed: false; cm: ContextManager });
3532
+ const queued = [...this.agents.values()].flatMap((agent): Queued[] => {
3252
3533
  try {
3253
3534
  agent.toolResultGuard.flushUnrecorded();
3254
3535
  } catch (error) {
3255
3536
  console.error(`[tool-result-guard] agent=${agent.name} storage retry failed during maintenance:`, error);
3256
3537
  }
3257
- const cm = agent.getContextManager();
3258
- const tools = this.compressionToolsForAgent(agent.name);
3259
- cm.setToolDefinitions(tools);
3260
- if (this.providerGateBlocked(agent.name)) return [];
3261
- if (cm.isReady()) return [];
3262
- const pending = cm.getPendingWork()?.description;
3538
+ // Whatever throws while one agent's maintenance is prepared is that
3539
+ // agent's failure, reported for it alone, while the others run.
3540
+ const failedAgent = (error: unknown): Queued[] => {
3541
+ const record: ContextMaintenanceAgentRun = {
3542
+ agentName: agent.name,
3543
+ startedAt: Date.now(),
3544
+ ticks: 0,
3545
+ readyBefore: false,
3546
+ };
3547
+ record.finishedAt = record.startedAt;
3548
+ this.reportMaintenanceFailure(
3549
+ agent.name,
3550
+ record,
3551
+ error instanceof Error ? String(error.message) : failureDescription(error),
3552
+ );
3553
+ return [{ agent, record, failed: true }];
3554
+ };
3555
+ let cm: ContextManager;
3556
+ let state: ReturnType<AgentFramework['contextReadiness']>;
3557
+ try {
3558
+ cm = agent.getContextManager();
3559
+ const tools = this.compressionToolsForAgent(agent.name);
3560
+ cm.setToolDefinitions(tools);
3561
+ if (this.providerGateBlocked(agent.name)) return [];
3562
+ state = this.contextReadiness(cm);
3563
+ } catch (error) {
3564
+ return failedAgent(error);
3565
+ }
3566
+ if ('ready' in state && state.ready) {
3567
+ this.maintenanceFailureReported.delete(agent.name);
3568
+ return [];
3569
+ }
3263
3570
  const progress = this.contextProgress(cm);
3264
3571
  const record: ContextMaintenanceAgentRun = {
3265
3572
  agentName: agent.name,
3266
3573
  startedAt: Date.now(),
3267
3574
  ticks: 0,
3268
3575
  readyBefore: false,
3269
- ...(pending ? { pendingBefore: pending } : {}),
3576
+ ...('pending' in state && state.pending ? { pendingBefore: state.pending } : {}),
3270
3577
  ...(progress ? { progressBefore: progress } : {}),
3271
3578
  };
3272
- return [{
3273
- agent,
3274
- cm,
3275
- record,
3276
- }];
3579
+ if ('error' in state) {
3580
+ // Its readiness can't be read: that agent's failure, reported for it
3581
+ // alone, while the others run.
3582
+ record.finishedAt = Date.now();
3583
+ this.reportMaintenanceFailure(agent.name, record, state.error);
3584
+ return [{ agent, record, failed: true }];
3585
+ }
3586
+ return [{ agent, cm, record, failed: false }];
3277
3587
  });
3278
3588
  if (queued.length === 0) return;
3279
3589
 
@@ -3285,59 +3595,147 @@ export class AgentFramework {
3285
3595
  this.currentMaintenanceRun = run;
3286
3596
 
3287
3597
  try {
3288
- await Promise.all(queued.map(async ({ agent, cm, record }) => {
3598
+ const running = queued.filter((item): item is Extract<Queued, { failed: false }> => !item.failed);
3599
+ // Settled, not all: the pass ends only when every agent's work has.
3600
+ const outcomes = await Promise.allSettled(running.map(async ({ agent, cm, record }) => {
3601
+ // A tick whose provider call a surgery or stop() withdrew ends this
3602
+ // agent's work for the pass, as the hold or the stop would have before
3603
+ // its next tick: it is no failure, and what the failure reports
3604
+ // remember stays as it is.
3605
+ let withdrawn = false;
3289
3606
  try {
3290
3607
  for (
3291
3608
  let i = 0;
3292
- i < MAINTENANCE_TICKS_PER_PASS && this.running && !cm.isReady();
3609
+ i < MAINTENANCE_TICKS_PER_PASS && this.running && !this.surgeryHold && !cm.isReady();
3293
3610
  i++
3294
3611
  ) {
3295
3612
  await cm.tick();
3296
3613
  record.ticks++;
3297
3614
  }
3298
3615
  } catch (error) {
3299
- const wholeError = error instanceof Error ? String(error.message) : failureDescription(error);
3300
- record.error = boundFailureText(wholeError);
3301
- console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
3302
- this.opsAlert(
3303
- 'context-maintenance-failed',
3304
- agent.name,
3305
- wholeError,
3306
- {
3307
- data: {
3308
- scope: 'agent',
3309
- ...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
3310
- ...(record.progressBefore ? { progress: record.progressBefore } : {}),
3311
- },
3312
- },
3313
- );
3616
+ if (error instanceof MaintenanceWithdrawnError) {
3617
+ withdrawn = true;
3618
+ console.error(`[context-maintenance] agent=${agent.name} stopped: ${error.message}`);
3619
+ } else {
3620
+ this.reportMaintenanceFailure(
3621
+ agent.name,
3622
+ record,
3623
+ error instanceof Error ? String(error.message) : failureDescription(error),
3624
+ );
3625
+ }
3314
3626
  } finally {
3315
- const pending = cm.getPendingWork()?.description;
3316
3627
  record.finishedAt = Date.now();
3317
- record.readyAfter = cm.isReady();
3318
- if (pending) record.pendingAfter = pending;
3628
+ const state = this.contextReadiness(cm);
3629
+ if ('error' in state) {
3630
+ // A run records an agent's first failure: a readiness refusal
3631
+ // after a tick that failed is read again by the next pass.
3632
+ if (record.error === undefined && !withdrawn) this.reportMaintenanceFailure(agent.name, record, state.error);
3633
+ } else {
3634
+ record.readyAfter = state.ready;
3635
+ if (state.pending) record.pendingAfter = state.pending;
3636
+ if (record.error === undefined && !withdrawn) this.maintenanceFailureReported.delete(agent.name);
3637
+ }
3319
3638
  const progress = this.contextProgress(cm);
3320
3639
  if (progress) record.progressAfter = progress;
3321
3640
  }
3322
3641
  }));
3642
+ for (const outcome of outcomes) {
3643
+ if (outcome.status === 'rejected') {
3644
+ console.error('[context-maintenance] reporting an agent\'s failure failed:', describeFailureWithFrames(outcome.reason));
3645
+ }
3646
+ }
3323
3647
  } finally {
3324
3648
  run.finishedAt = Date.now();
3325
3649
  run.agents.sort((a, b) => a.agentName.localeCompare(b.agentName));
3326
3650
  this.currentMaintenanceRun = null;
3327
- this.maintenanceHistory.unshift(run);
3651
+ // A pass that only met failures already reported, and took no tick,
3652
+ // adds nothing the history doesn't hold, and a lasting failure would
3653
+ // otherwise fill it in minutes, pushing out the runs that did work. One
3654
+ // an operator asked for is kept: it's the pass their maintain reports.
3655
+ const requested = this.maintenancePassRequested;
3656
+ this.maintenancePassRequested = false;
3657
+ if (requested || !run.agents.every((record) => record.ticks === 0 && this.maintenanceRepeats.has(record))) this.maintenanceHistory.unshift(run);
3328
3658
  if (this.maintenanceHistory.length > 50) this.maintenanceHistory.length = 50;
3329
3659
  }
3330
3660
  }
3331
3661
 
3662
+ /**
3663
+ * One agent's maintenance failure: recorded on its run, bounded as every
3664
+ * failure sink is (`record.error`), and logged and alerted for that agent
3665
+ * with the whole text. A failure that lasts, such as a manager whose
3666
+ * strategy failed to initialize, meets every pass, a few seconds apart. It
3667
+ * is logged and alerted again only when its whole text changes, once
3668
+ * opsAlert's cooldown for it has passed since the webhook last posted it
3669
+ * (so the webhook, which posts a kind at most once per cooldown, hears a
3670
+ * failure that lasts), or after a pass in which that agent's maintenance
3671
+ * went without a failure (maintenanceFailureReported). A record whose
3672
+ * failure isn't reported again is marked as a repeat, and a pass whose
3673
+ * every record is a repeat that took no tick stays out of the history.
3674
+ */
3675
+ private reportMaintenanceFailure(agentName: string, record: ContextMaintenanceAgentRun, wholeError: string): void {
3676
+ record.error = boundFailureText(wholeError);
3677
+ const reported = this.maintenanceFailureReported.get(agentName);
3678
+ const now = Date.now();
3679
+ if (reported && reported.text === wholeError) {
3680
+ // The same failure again is alerted once the webhook's cooldown for it
3681
+ // has passed, counted from the webhook's last post of it: a report of
3682
+ // changed text that the webhook didn't post doesn't push the reminder
3683
+ // back. With no webhook, it's counted from this failure's last report.
3684
+ const since = this.opsAlertLastSent.get(`${agentName}:context-maintenance-failed`) ?? reported.at;
3685
+ if (now - since < this.opsAlertCooldownMs) {
3686
+ this.maintenanceRepeats.add(record);
3687
+ return;
3688
+ }
3689
+ }
3690
+ this.maintenanceFailureReported.set(agentName, { text: wholeError, at: now });
3691
+ console.error(`[context-maintenance] agent=${agentName} failed:`, record.error);
3692
+ this.opsAlert(
3693
+ 'context-maintenance-failed',
3694
+ agentName,
3695
+ wholeError,
3696
+ {
3697
+ data: {
3698
+ scope: 'agent',
3699
+ ...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
3700
+ ...(record.progressBefore ? { progress: record.progressBefore } : {}),
3701
+ },
3702
+ },
3703
+ );
3704
+ }
3705
+
3706
+ /**
3707
+ * An agent's context readiness, or the error that reading it threw. The
3708
+ * context manager refuses while its strategy's initialization is pending
3709
+ * or has failed (context-manager 0.14), and a strategy can refuse on its
3710
+ * own, as the autobiographical one does after a store-level branch switch
3711
+ * it hasn't reloaded for. Every such throw is that agent's failure.
3712
+ */
3713
+ private contextReadiness(
3714
+ cm: ContextManager,
3715
+ ): { ready: boolean; pending?: string } | { error: string } {
3716
+ try {
3717
+ const ready = cm.isReady();
3718
+ const pending = ready ? undefined : cm.getPendingWork()?.description;
3719
+ return pending ? { ready, pending } : { ready };
3720
+ } catch (error) {
3721
+ return { error: error instanceof Error ? String(error.message) : failureDescription(error) };
3722
+ }
3723
+ }
3724
+
3725
+ /** The strategy's progress snapshot, for diagnostics: one it can't give reads as none. */
3332
3726
  private contextProgress(cm: ContextManager): Record<string, unknown> | undefined {
3333
- const strategy = cm.getStrategy() as {
3334
- getProgressSnapshot?: () => unknown;
3335
- };
3336
- if (typeof strategy.getProgressSnapshot !== 'function') return undefined;
3337
- const progress = strategy.getProgressSnapshot();
3338
- return progress && typeof progress === 'object'
3339
- ? progress as Record<string, unknown>
3340
- : undefined;
3727
+ try {
3728
+ const strategy = cm.getStrategy() as {
3729
+ getProgressSnapshot?: () => unknown;
3730
+ };
3731
+ if (typeof strategy.getProgressSnapshot !== 'function') return undefined;
3732
+ const progress = strategy.getProgressSnapshot();
3733
+ return progress && typeof progress === 'object'
3734
+ ? progress as Record<string, unknown>
3735
+ : undefined;
3736
+ } catch {
3737
+ return undefined;
3738
+ }
3341
3739
  }
3342
3740
 
3343
3741
  /**
@@ -4084,7 +4482,8 @@ export class AgentFramework {
4084
4482
  `advance merges to go lower). Applying; the live compile budget is unchanged.`,
4085
4483
  );
4086
4484
  }
4087
- } else if (preview.reason) {
4485
+ } else if (preview.reason && agent.contextManagerAnswers()) {
4486
+ // A manager that refuses its queries refuses the update below too.
4088
4487
  console.warn(
4089
4488
  `[budget-preflight] preview unavailable for ${agentName} (${preview.reason}); ` +
4090
4489
  `applying without preflight`,
@@ -4093,7 +4492,7 @@ export class AgentFramework {
4093
4492
  }
4094
4493
  const result = agent.updateRuntimeSettings(patch);
4095
4494
  if (opts?.persist !== false) {
4096
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
4495
+ this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
4097
4496
  }
4098
4497
  this.recordOperatorAction({
4099
4498
  kind: 'settings-update',
@@ -4114,7 +4513,7 @@ export class AgentFramework {
4114
4513
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
4115
4514
  const result = agent.resetRuntimeSettings(keys);
4116
4515
  if (opts?.persist !== false) {
4117
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
4516
+ this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
4118
4517
  }
4119
4518
  this.recordOperatorAction({
4120
4519
  kind: 'settings-reset',
@@ -4133,7 +4532,7 @@ export class AgentFramework {
4133
4532
  const agent = this.agents.get(agentName);
4134
4533
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
4135
4534
  const result = agent.cancelRuntimeSettingsTransition();
4136
- this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
4535
+ this.persistAgentRuntimeSettings(agentName, this.persistedRuntimeSettingsOf(agent));
4137
4536
  this.recordOperatorAction({
4138
4537
  kind: 'settings-cancel-transition',
4139
4538
  agent: agentName,
@@ -4216,6 +4615,15 @@ export class AgentFramework {
4216
4615
  reason?: string;
4217
4616
  timeoutMs?: number;
4218
4617
  abandon?: boolean;
4618
+ }): Promise<HostModeStatus> {
4619
+ this.refuseOperatorAfterStop('quiesce');
4620
+ return this.trackOperatorOperation('host:quiesce', () => this.runQuiesce(opts));
4621
+ }
4622
+
4623
+ private async runQuiesce(opts?: {
4624
+ reason?: string;
4625
+ timeoutMs?: number;
4626
+ abandon?: boolean;
4219
4627
  }): Promise<HostModeStatus> {
4220
4628
  if (this.quiesced) {
4221
4629
  // Idempotent — EXCEPT abandon escalation: a first quiesce that timed
@@ -4271,6 +4679,7 @@ export class AgentFramework {
4271
4679
  // continuation's quiesce recheck requeues it — wait for that to happen).
4272
4680
  while (
4273
4681
  this.quiesced &&
4682
+ !this.queue?.isClosed &&
4274
4683
  (this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) &&
4275
4684
  Date.now() < deadline
4276
4685
  ) {
@@ -4285,6 +4694,13 @@ export class AgentFramework {
4285
4694
  console.error('[host-mode] quiesce superseded by a concurrent resume during drain');
4286
4695
  return this.getHostModeStatus();
4287
4696
  }
4697
+ // Stop cuts this drain short, including any requested abandon. It cancels
4698
+ // streams and waits for the event loop; a token with no stream settles
4699
+ // on its own. Return the status as it stands and keep the durable quiesce.
4700
+ if (this.queue?.isClosed) {
4701
+ console.error('[host-mode] quiesce stopped waiting for its drain: the framework is stopping');
4702
+ return this.getHostModeStatus();
4703
+ }
4288
4704
 
4289
4705
  let abandoned = false;
4290
4706
  let unabandonable: string[] = [];
@@ -4344,9 +4760,11 @@ export class AgentFramework {
4344
4760
  );
4345
4761
  }
4346
4762
  }
4347
- // Bounded grace for the cancelled streams' teardown to settle.
4763
+ // Bounded grace for cancelled streams. Stop cancels streams too; a turn
4764
+ // with no stream settles on its own. Report the abandon that already ran,
4765
+ // even if stop cuts its grace short.
4348
4766
  const grace = Date.now() + 10_000;
4349
- while (this.quiesced && this.activeTurnTokens.size > 0 && Date.now() < grace) {
4767
+ while (this.quiesced && !this.queue?.isClosed && this.activeTurnTokens.size > 0 && Date.now() < grace) {
4350
4768
  await new Promise((resolve) => setTimeout(resolve, 250));
4351
4769
  }
4352
4770
  // Only turns still alive after the grace are worth reporting.
@@ -4363,18 +4781,32 @@ export class AgentFramework {
4363
4781
  * strategy cannot preview would hold hosts hostage to a diagnostic.
4364
4782
  */
4365
4783
  async resume(opts?: { force?: boolean }): Promise<HostModeStatus> {
4784
+ this.refuseOperatorAfterStop('resume');
4785
+ return this.trackOperatorOperation('host:resume', () => this.runResume(opts));
4786
+ }
4787
+
4788
+ private async runResume(opts?: { force?: boolean }): Promise<HostModeStatus> {
4366
4789
  if (!this.quiesced) return this.getHostModeStatus();
4790
+ // A surgery switches or forks the branch while it holds the store, and a
4791
+ // manager whose initialization that left pending refuses its settings
4792
+ // read: resume once it's done, as the other operator calls do. Checked
4793
+ // again after the waits below, since a surgery can take the store while
4794
+ // resume waits for a pass or a preview.
4795
+ this.refuseResumeUnderSurgery();
4367
4796
 
4368
4797
  // The canonical operator flow is `maintain; resume`, and a timer-driven
4369
4798
  // pass may still be mid-flight: previewContext refuses to overlap one
4370
4799
  // ("already running"), which used to degrade the gate to "no opinion".
4371
4800
  // Wait for the pass first, then retry a few times for the case where a
4372
4801
  // fresh pass started in between.
4373
- if (this.maintenancePass) {
4374
- await this.maintenancePass.catch(() => {});
4375
- }
4802
+ if (this.maintenanceRunning()) await this.settleMaintenancePass();
4376
4803
  const failing: Array<{ agentName: string; preview: RuntimeSettingsPreview }> = [];
4377
4804
  for (const agentName of this.agents.keys()) {
4805
+ // A preview that isn't available gives no verdict, and resume goes on
4806
+ // for the others. That includes an agent whose context manager refuses
4807
+ // its queries, as when its strategy failed to initialize: that manager
4808
+ // refuses its compiles and writes until a switchBranch or setStrategy
4809
+ // succeeds, so the agent can't run over budget, and maintenance reports it.
4378
4810
  let preview = this.previewAgentRuntimeSettings(agentName);
4379
4811
  for (let attempt = 0; attempt < 8 && !preview.available && preview.reason === 'preview_in_flight'; attempt++) {
4380
4812
  await new Promise((resolve) => setTimeout(resolve, 250));
@@ -4390,6 +4822,13 @@ export class AgentFramework {
4390
4822
  );
4391
4823
  }
4392
4824
  }
4825
+ this.refuseResumeUnderSurgery();
4826
+ // stop() closes the queue first. If it has begun, before this call or
4827
+ // during the waits above, leave the host quiesced, as it will boot,
4828
+ // rather than reopen what stop() is closing.
4829
+ if (this.queue?.isClosed) {
4830
+ throw new Error('resume refused: the framework is stopping, so the host stays quiesced');
4831
+ }
4393
4832
  if (failing.length > 0 && !opts?.force) {
4394
4833
  const detail = failing.map(({ agentName, preview }) =>
4395
4834
  `${agentName}: folded floor ${preview.effective!.finalTokens} > hard budget ` +
@@ -4444,9 +4883,10 @@ export class AgentFramework {
4444
4883
  await barrier.promise;
4445
4884
  this.completeMcplDataPlaneGate(barrier);
4446
4885
  } catch (error) {
4447
- // The host IS resumed — don't rethrow. failMcplDataPlaneGate
4448
- // recycles the connections, and their reconnect flows re-run the
4449
- // funnel with quiesced=false, self-healing the data planes.
4886
+ // The host has resumed, so report the failed barrier without undoing
4887
+ // that. While serving, failMcplDataPlaneGate recycles the
4888
+ // connections and their reconnect flows reopen the data planes;
4889
+ // while the host stops, it releases the barrier instead.
4450
4890
  await this.failMcplDataPlaneGate(barrier, 'quiesce resume', error);
4451
4891
  }
4452
4892
  }
@@ -4460,7 +4900,9 @@ export class AgentFramework {
4460
4900
  // mid-quiesce boots quiesced but with these queues empty (sources like
4461
4901
  // heartbeats re-deliver on their own cadence; one-shot wakes are lost).
4462
4902
  // Deferred context WRITES are persisted while quiesced and survive.
4463
- console.error(`[host-mode] resumed — ${releasedRequests} parked wake(s) released`);
4903
+ console.error(this.queue?.isClosed
4904
+ ? `[host-mode] resumed — ${releasedRequests} parked wake(s) won't run because the framework is stopping`
4905
+ : `[host-mode] resumed — ${releasedRequests} parked wake(s) released`);
4464
4906
  }
4465
4907
  return this.getHostModeStatus();
4466
4908
  }
@@ -4757,14 +5199,27 @@ export class AgentFramework {
4757
5199
  * true in quiesce mode precisely so this machinery stays hot.
4758
5200
  */
4759
5201
  async maintenanceTick(): Promise<ContextMaintenanceSnapshot & { ran: boolean }> {
5202
+ // A pass already running is stop()'s to wait for; a new one isn't started.
5203
+ this.refuseOperatorAfterStop('maintain');
4760
5204
  if (!this.running) {
4761
- // startQueuedMaintenance no-ops on a stopped/never-started framework —
4762
- // a bare snapshot would masquerade as a completed pass, so say so.
5205
+ // startQueuedMaintenance no-ops on a framework that was never started
5206
+ // (a stopping one is refused above): a bare snapshot would masquerade
5207
+ // as a completed pass, so say so.
4763
5208
  console.warn('[host-mode] maintenanceTick: framework is not running — no pass executed');
4764
5209
  return { ...this.getContextMaintenanceSnapshot(), ran: false };
4765
5210
  }
5211
+ if (this.surgeryHold) {
5212
+ // No pass starts while a surgery holds the store (runQueuedMaintenance).
5213
+ console.warn(
5214
+ `[host-mode] maintenanceTick: a ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — no pass executed`,
5215
+ );
5216
+ return { ...this.getContextMaintenanceSnapshot(), ran: false };
5217
+ }
5218
+ this.maintenancePassRequested = true;
4766
5219
  this.startQueuedMaintenance();
4767
5220
  await this.maintenancePass;
5221
+ // A pass with nothing queued records no run, and the next pass wasn't asked for.
5222
+ this.maintenancePassRequested = false;
4768
5223
  return { ...this.getContextMaintenanceSnapshot(), ran: true };
4769
5224
  }
4770
5225
 
@@ -4772,12 +5227,13 @@ export class AgentFramework {
4772
5227
  getContextMaintenanceSnapshot(): ContextMaintenanceSnapshot {
4773
5228
  const agents = [...this.agents.values()].map((agent) => {
4774
5229
  const cm = agent.getContextManager();
4775
- const pending = cm.getPendingWork()?.description;
5230
+ const state = this.contextReadiness(cm);
4776
5231
  const progress = this.contextProgress(cm);
4777
5232
  return {
4778
5233
  agentName: agent.name,
4779
- ready: cm.isReady(),
4780
- ...(pending ? { pending } : {}),
5234
+ ...('error' in state
5235
+ ? { ready: false, error: boundFailureText(state.error) }
5236
+ : { ready: state.ready, ...(state.pending ? { pending: state.pending } : {}) }),
4781
5237
  ...(progress ? { progress } : {}),
4782
5238
  };
4783
5239
  });
@@ -4795,13 +5251,28 @@ export class AgentFramework {
4795
5251
  * were activated right now — WITHOUT running inference, opening a stream, or
4796
5252
  * mutating agent state. Intended for debug/preview tooling.
4797
5253
  *
4798
- * Transparency contract (default): the preview is side-effect-free and
4799
- * leaves no trace on the system. It does only read-only work —
4800
- * `ContextManager.compile` (which never triggers compression itself;
4801
- * compression runs in the background out-of-band), tool filtering, and
4802
- * system-prompt assembly — then delegates to `Agent.buildActivationRequest`.
4803
- * No tokens are spent, nothing is written to Chronicle, and no external
4804
- * MCPL server is contacted.
5254
+ * What a preview commits (default): nothing of its own. It compiles as a
5255
+ * dry run (`dryRun`), filters tools and assembles the system prompt, then
5256
+ * delegates to `Agent.buildActivationRequest`. No tokens are spent, no
5257
+ * external MCPL server is contacted, no fold resolution is persisted and
5258
+ * none of the compression its fold plan calls for is queued; the consumed
5259
+ * watermark and any prepared budget change are left alone; and once
5260
+ * context-manager's thinking binding (context-manager#160, not yet
5261
+ * released) ships, no stamps are committed and no compile awaits
5262
+ * acceptance. In context-manager 0.14 its dry run leaves nothing else
5263
+ * behind but the release of an expired compression hold, as any compile
5264
+ * does (see `Agent.compileWithInjections`). The preview keeps in-memory
5265
+ * bookkeeping of its own, such as handing the context manager the
5266
+ * compression tool definitions, as every activation request does.
5267
+ * Under the autobiographical strategy, a budget the context can't fit
5268
+ * still throws, as for a committing compile: a dry run skips only
5269
+ * context-manager's post-pick check, and an adaptive fold plan that
5270
+ * doesn't fit then overruns at the middle's emission, with a refusal that
5271
+ * names the tokens and the limit. Windowed passthrough's newest message
5272
+ * alone is the one window that comes back larger than the budget. For a
5273
+ * verdict instead of the refusal, `previewContextSettings` returns
5274
+ * context-manager's own at a budget, with the agent's `maxTokens` reserved
5275
+ * for the response.
4805
5276
  *
4806
5277
  * The trade-off is fidelity: the dynamically-gathered ContextInjection[]
4807
5278
  * (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
@@ -4832,13 +5303,15 @@ export class AgentFramework {
4832
5303
  };
4833
5304
 
4834
5305
  // An explicit budget compiles against a HYPOTHETICAL window instead of the
4835
- // agent's live one. That also suppresses transition-settling in
4836
- // compileWithInjections (which only settles when no budget is passed), so
4837
- // previewing a smaller window cannot advance a converging descent.
4838
- // Default: no dynamic injection gathering → fully transparent (no
4839
- // inference, no Chronicle writes, no external RPC). Opt in explicitly.
5306
+ // agent's live one. Every preview is a dry run, so none of them settles
5307
+ // a converging descent or commits what a sent compile commits (see the
5308
+ // doc comment above).
5309
+ // Default: no dynamic injection gathering → no inference, no Chronicle
5310
+ // writes of its own, no external RPC. Opt in explicitly.
4840
5311
  if (!opts?.injections) {
4841
- return capture(await agent.buildActivationRequest(tools, undefined, opts?.budget, presentation?.available ?? tools));
5312
+ return capture(await agent.buildActivationRequest(
5313
+ tools, undefined, opts?.budget, presentation?.available ?? tools, { dryRun: true },
5314
+ ));
4842
5315
  }
4843
5316
 
4844
5317
  // Full-fidelity path: mirrors startAgentStream's injection gathering.
@@ -4873,7 +5346,9 @@ export class AgentFramework {
4873
5346
  }
4874
5347
  }
4875
5348
 
4876
- return capture(await agent.buildActivationRequest(tools, injections, opts?.budget, presentation?.available ?? tools));
5349
+ return capture(await agent.buildActivationRequest(
5350
+ tools, injections, opts?.budget, presentation?.available ?? tools, { dryRun: true },
5351
+ ));
4877
5352
  }
4878
5353
 
4879
5354
  /**
@@ -4883,9 +5358,11 @@ export class AgentFramework {
4883
5358
  *
4884
5359
  * Unlike `previewActivation` (which renders a request), this returns the
4885
5360
  * picker's plan: resulting tokens, whether it fits, how many summaries would
4886
- * have to be produced first, and the per-chunk fold levels. Commits nothing:
4887
- * no resolution persistence, no compression enqueue, no transition
4888
- * bookkeeping — see `AutobiographicalStrategy.previewContext`.
5361
+ * have to be produced first, and the per-chunk fold levels. It commits
5362
+ * nothing: its select is a dry run, which in context-manager 0.14 leaves
5363
+ * what a select leaves behind as the last committing compile left it —
5364
+ * see `AutobiographicalStrategy.previewContext` and
5365
+ * `Agent.compileWithInjections`.
4889
5366
  *
4890
5367
  * Returns null when the agent's strategy has no fold plan (non-adaptive).
4891
5368
  */
@@ -4897,6 +5374,15 @@ export class AgentFramework {
4897
5374
  ): unknown {
4898
5375
  const agent = this.agents.get(agentName);
4899
5376
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
5377
+ return this.previewContextFor(agent, budgetTokens, overrides, opts);
5378
+ }
5379
+
5380
+ private previewContextFor(
5381
+ agent: Agent,
5382
+ budgetTokens: number,
5383
+ overrides?: Record<string, unknown>,
5384
+ opts?: { render?: boolean },
5385
+ ): unknown {
4900
5386
  const cm = agent.getContextManager() as unknown as {
4901
5387
  previewContext?: (
4902
5388
  budget: TokenBudget,
@@ -4930,9 +5416,14 @@ export class AgentFramework {
4930
5416
  * the compile budget and rejected safe-by-construction paced descents.
4931
5417
  *
4932
5418
  * Never throws for preview-layer reasons: strategy without previewContext,
4933
- * preview overlap, stale branch generation, and the hierarchical (non-
4934
- * adaptive) path all come back as `{available: false, reason}`. Unknown
4935
- * agent still throws, matching the sibling accessors.
5419
+ * preview overlap, stale branch generation, the hierarchical (non-
5420
+ * adaptive) path, and a context manager that refuses its queries (its
5421
+ * strategy's initialization pending or failed) all come back as
5422
+ * `{available: false, reason}`. Unknown agent still throws, matching the
5423
+ * sibling accessors, and so does a patch whose values the agent refuses,
5424
+ * whatever its context manager's state. Whether the strategy takes live
5425
+ * tail and pace settings is the manager's to say, so while it refuses, a
5426
+ * patch that sets them gets no verdict either.
4936
5427
  */
4937
5428
  previewAgentRuntimeSettings(
4938
5429
  agentName: string,
@@ -4940,8 +5431,30 @@ export class AgentFramework {
4940
5431
  ): RuntimeSettingsPreview {
4941
5432
  const agent = this.agents.get(agentName);
4942
5433
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
4943
- const plan = agent.planRuntimeSettings(patch);
4944
- const settings = agent.getRuntimeSettings();
5434
+ return this.previewRuntimeSettingsFor(agent, patch);
5435
+ }
5436
+
5437
+ /** previewAgentRuntimeSettings for an agent in hand: boot restore previews one not yet registered. */
5438
+ private previewRuntimeSettingsFor(
5439
+ agent: Agent,
5440
+ patch?: AgentRuntimeSettingsPatch,
5441
+ ): RuntimeSettingsPreview {
5442
+ // A patch whose values the agent refuses throws, whatever its context
5443
+ // manager's state.
5444
+ if (patch && Object.keys(patch).length > 0) agent.checkRuntimeSettingsPatch(patch);
5445
+ let plan: ReturnType<Agent['planRuntimeSettings']>;
5446
+ let settings: ReturnType<Agent['getRuntimeSettings']>;
5447
+ try {
5448
+ plan = agent.planRuntimeSettings(patch);
5449
+ settings = agent.getRuntimeSettings();
5450
+ } catch (error) {
5451
+ // A manager that refuses its queries gives no verdict, as a preview
5452
+ // that can't be computed gives none: the agent's own state still says
5453
+ // its budget and the patch's path, and whatever stopped the preview is
5454
+ // its reason. When the manager answers, anything else throws as before.
5455
+ if (agent.contextManagerAnswers()) throw error;
5456
+ return { available: false, reason: previewStoppedReason(error), ...agent.runtimeBudgetPath(patch) };
5457
+ }
4945
5458
  const base: RuntimeSettingsPreview = {
4946
5459
  available: false,
4947
5460
  path: plan.path,
@@ -4955,8 +5468,8 @@ export class AgentFramework {
4955
5468
  reason?: string;
4956
5469
  } => {
4957
5470
  try {
4958
- const result = this.previewContextSettings(
4959
- agentName,
5471
+ const result = this.previewContextFor(
5472
+ agent,
4960
5473
  budgetTokens,
4961
5474
  Object.keys(plan.overrides).length > 0 ? plan.overrides : undefined,
4962
5475
  );
@@ -4965,20 +5478,7 @@ export class AgentFramework {
4965
5478
  }
4966
5479
  return { result: result as Record<string, unknown> };
4967
5480
  } catch (error) {
4968
- const message = error instanceof Error ? error.message : String(error);
4969
- // Structured mapping of the strategy's known throw paths. Message
4970
- // matching is fragile by nature, so unknown messages surface verbatim
4971
- // as the reason rather than being swallowed or rethrown.
4972
- if (message.includes('requires reinitialization for the current branch generation')) {
4973
- return { reason: 'branch_generation_changed' };
4974
- }
4975
- if (message.includes('already running; previews must not overlap')) {
4976
- return { reason: 'preview_in_flight' };
4977
- }
4978
- if (message.includes('requires adaptiveResolution')) {
4979
- return { reason: 'no_adaptive_resolution' };
4980
- }
4981
- return { reason: message };
5481
+ return { reason: previewStoppedReason(error) };
4982
5482
  }
4983
5483
  };
4984
5484
 
@@ -5113,22 +5613,41 @@ export class AgentFramework {
5113
5613
  }
5114
5614
  }
5115
5615
 
5616
+ /** An agent's persisted record, or null when it has none; throws when the store can't be read. */
5116
5617
  private readAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsOverrides | null {
5117
- try {
5118
- const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
5119
- agentRuntimeSettings?: Record<string, AgentRuntimeSettingsOverrides>;
5120
- } | null;
5121
- const stored = data?.agentRuntimeSettings?.[agentName];
5122
- return stored ? { ...stored } : null;
5123
- } catch {
5124
- return null;
5125
- }
5618
+ const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
5619
+ agentRuntimeSettings?: Record<string, AgentRuntimeSettingsOverrides>;
5620
+ } | null;
5621
+ const stored = data?.agentRuntimeSettings?.[agentName];
5622
+ return stored ? { ...stored } : null;
5126
5623
  }
5127
5624
 
5128
5625
  private validatePersistedAgentRuntimeSettings(
5129
5626
  agentName: string,
5130
5627
  overrides: AgentRuntimeSettingsOverrides,
5131
5628
  ): AgentRuntimeSettingsOverrides {
5629
+ // A malformed number is dropped, with a line that names the value's kind,
5630
+ // never its content. It could never apply: a budget, tail or pace kept
5631
+ // unapplied would be reported and written back as it is, and a marker
5632
+ // that can't be read isn't trusted, so the budget restores paced.
5633
+ const numbers = [
5634
+ ['contextBudgetReachedTokens', 1],
5635
+ ['contextBudgetTokens', 1],
5636
+ ['tailTokens', 0],
5637
+ ['transitionPaceTokens', 1],
5638
+ ] as const;
5639
+ for (const [key, least] of numbers) {
5640
+ const value = overrides[key] as unknown;
5641
+ if (value === undefined || (Number.isSafeInteger(value) && (value as number) >= least)) continue;
5642
+ const kind = typeof value === 'number' ? `a number that isn't a ${least > 0 ? 'positive' : 'non-negative'} safe integer`
5643
+ : value === null ? 'null'
5644
+ : Array.isArray(value) ? 'an array'
5645
+ : typeof value === 'object' ? 'an object'
5646
+ : `a ${typeof value}`;
5647
+ console.error(`[agent-settings] ignoring an invalid persisted ${key} for agent "${agentName}": ${kind}`);
5648
+ const { [key]: _ignored, ...rest } = overrides;
5649
+ overrides = rest;
5650
+ }
5132
5651
  if (
5133
5652
  overrides.sameRoundThinkTextPolicy !== undefined &&
5134
5653
  overrides.sameRoundThinkTextPolicy !== 'public' &&
@@ -5179,6 +5698,138 @@ export class AgentFramework {
5179
5698
  this.store.setStateJson(FRAMEWORK_STATE_ID, state);
5180
5699
  }
5181
5700
 
5701
+ /**
5702
+ * Apply an agent's persisted settings at boot, each on its own, the budget
5703
+ * last. A persisted setting never stops the agent booting, except a
5704
+ * think-text policy, which fails closed: an invalid one is refused before
5705
+ * this (validatePersistedAgentRuntimeSettings), and a valid one always
5706
+ * applies. A tail, pace or budget that can't be applied now (a tail or pace
5707
+ * on a strategy without live settings, a budget no longer above maxTokens,
5708
+ * a lower one restored paced on a strategy that can't prepare a smaller
5709
+ * window) is kept set but unapplied (Agent.keepRuntimeSettings), so a later
5710
+ * persisting write keeps it in the record.
5711
+ *
5712
+ * A budget below the recipe's restores as a paced descent from it, unless
5713
+ * the record carries its marker (`contextBudgetReachedTokens`, written when
5714
+ * a compile fit at it) and the preflight says it fits now: then it is
5715
+ * applied at once. The preflight plans the stored context as the context
5716
+ * manager will, with the restored tail and pace, so it sees what a restart
5717
+ * can change there: a new maxTokens (the response reserve) or strategy
5718
+ * configuration. It also sees a context that grew while the agent ran at
5719
+ * another budget after the record was written, through a persist: false
5720
+ * change. The system prompt, tools and injections are outside the context
5721
+ * manager's budget, so they don't enter it (context-manager #162). If a
5722
+ * compile at a budget applied at once this way is refused, the agent
5723
+ * restores it paced then (armed by Agent.restoreRuntimeSettings, run in
5724
+ * Agent.compileAtLiveBudgetNoting). A budget at or above the recipe's is
5725
+ * applied at once with or without the marker, as an increase always is.
5726
+ *
5727
+ * When a marked budget isn't applied at once or a setting can't be
5728
+ * applied, one line says why, what couldn't be applied, and the budget set
5729
+ * beside the one compiles plan at.
5730
+ */
5731
+ private restorePersistedRuntimeSettings(
5732
+ agentName: string,
5733
+ agent: Agent,
5734
+ overrides: AgentRuntimeSettingsOverrides,
5735
+ ): void {
5736
+ const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error));
5737
+ const { contextBudgetReachedTokens: reached, sameRoundThinkTextPolicy, ...settings } = overrides;
5738
+ if (sameRoundThinkTextPolicy !== undefined) agent.restoreRuntimeSettings({ sameRoundThinkTextPolicy });
5739
+ const unapplied: string[] = [];
5740
+ /** Restore one setting, or keep it set but unapplied; true if it applied. */
5741
+ const restore = (part: Pick<AgentRuntimeSettingsPatch, 'contextBudgetTokens' | 'tailTokens' | 'transitionPaceTokens'>) => {
5742
+ try {
5743
+ agent.restoreRuntimeSettings(part);
5744
+ return true;
5745
+ } catch (error) {
5746
+ agent.keepRuntimeSettings(part);
5747
+ unapplied.push(Object.entries(part).map(([key, value]) => `${key}=${value}`).join(', ') + ` (${messageOf(error)})`);
5748
+ return false;
5749
+ }
5750
+ };
5751
+ if (settings.tailTokens !== undefined) restore({ tailTokens: settings.tailTokens });
5752
+ if (settings.transitionPaceTokens !== undefined) restore({ transitionPaceTokens: settings.transitionPaceTokens });
5753
+ const budget = settings.contextBudgetTokens;
5754
+ let atOnce: string | undefined;
5755
+ let appliedAtOnce = false;
5756
+ if (budget !== undefined && reached === budget) {
5757
+ const patch: AgentRuntimeSettingsPatch = { contextBudgetTokens: budget, immediate: true };
5758
+ let why: string | undefined;
5759
+ try {
5760
+ const preview = this.previewRuntimeSettingsFor(agent, patch);
5761
+ if (!preview.available) why = `whether it fits now can't be checked (${preview.reason ?? 'no preview'})`;
5762
+ else if (preview.effective?.fits !== true) why = `the folded floor renders ${preview.effective?.finalTokens} tokens against it now`;
5763
+ } catch (error) {
5764
+ why = `whether it fits now can't be checked (${messageOf(error)})`;
5765
+ }
5766
+ if (why === undefined) {
5767
+ try {
5768
+ agent.restoreRuntimeSettings(patch, { provenBudget: budget });
5769
+ appliedAtOnce = true;
5770
+ } catch (error) {
5771
+ why = `applying it at once failed (${messageOf(error)}), though the preflight says it fits now`;
5772
+ }
5773
+ }
5774
+ if (why !== undefined) atOnce = `its record says a compile fit at contextBudgetTokens=${budget}, but ${why}`;
5775
+ }
5776
+ const budgetKept = budget !== undefined && !appliedAtOnce && !restore({ contextBudgetTokens: budget });
5777
+ if (atOnce === undefined && unapplied.length === 0) return;
5778
+ const now = agent.getRuntimeSettings();
5779
+ const said: string[] = [];
5780
+ if (atOnce !== undefined) {
5781
+ said.push(budgetKept ? atOnce : `${atOnce}; restored it ${now.transition === 'stable' ? 'without that proof' : 'paced instead'}`);
5782
+ }
5783
+ if (unapplied.length > 0) {
5784
+ said.push(`${unapplied.join(', ')} can't be applied now, so ${unapplied.length === 1 ? 'it stays' : 'they stay'} set but unapplied`);
5785
+ }
5786
+ console.error(
5787
+ `[agent-settings] ${agentName}: ${said.join('; ')}: contextBudgetTokens is ${now.contextBudgetTokens}, ` +
5788
+ `and compiles plan at ${now.effectiveContextBudgetTokens}${now.transition === 'stable' ? '' : ` (${now.transition})`}`,
5789
+ );
5790
+ }
5791
+
5792
+ /**
5793
+ * What persists for an agent: its overrides, and `contextBudgetReachedTokens`
5794
+ * when the agent's proof stands for the overridden budget: a compile fit at
5795
+ * it, or the boot applied it at once on the record's proof, and the live
5796
+ * budget hasn't changed since (context-manager #162). A
5797
+ * change of budget, immediate or not, carries none until a compile at it
5798
+ * fits; arming or cancelling a descent leaves the proof as it was.
5799
+ */
5800
+ private persistedRuntimeSettingsOf(agent: Agent): AgentRuntimeSettingsOverrides {
5801
+ const overrides = agent.getRuntimeSettingsOverrides();
5802
+ const proven = agent.getProvenContextBudgetTokens();
5803
+ return overrides.contextBudgetTokens !== undefined && proven === overrides.contextBudgetTokens
5804
+ ? { ...overrides, contextBudgetReachedTokens: proven }
5805
+ : overrides;
5806
+ }
5807
+
5808
+ /**
5809
+ * A compile at the agent's live budget came out differently from the last
5810
+ * (Agent.onContextBudgetCompiled). Keep the persisted record's
5811
+ * `contextBudgetReachedTokens` true to it: written when a compile fits at
5812
+ * the record's own budget, withdrawn when the context manager refuses that
5813
+ * budget. Nothing else in the record changes, and a budget other than the
5814
+ * record's (one applied with `persist: false`, or a descent's starting
5815
+ * budget) writes nothing. A record that can't be read, or written, is
5816
+ * logged, and the next compile at that budget tells it again.
5817
+ */
5818
+ private recordCompiledContextBudget(agentName: string, budget: number, fit: boolean): boolean {
5819
+ try {
5820
+ const persisted = this.readAgentRuntimeSettings(agentName);
5821
+ if (!persisted || persisted.contextBudgetTokens !== budget) return true;
5822
+ if ((persisted.contextBudgetReachedTokens === budget) === fit) return true;
5823
+ const { contextBudgetReachedTokens: _previous, ...settings } = persisted;
5824
+ this.persistAgentRuntimeSettings(agentName, fit ? { ...settings, contextBudgetReachedTokens: budget } : settings);
5825
+ return true;
5826
+ } catch (error) {
5827
+ // The next compile at this budget tells it again.
5828
+ console.error(`[agent-settings] couldn't record ${agentName}'s compile at budget ${budget}:`, error);
5829
+ return false;
5830
+ }
5831
+ }
5832
+
5182
5833
  private persistAgentRuntimeSettings(
5183
5834
  agentName: string,
5184
5835
  overrides: AgentRuntimeSettingsOverrides,
@@ -5354,6 +6005,20 @@ export class AgentFramework {
5354
6005
  run.lastActivity = Date.now();
5355
6006
  }
5356
6007
 
6008
+ /**
6009
+ * A start dropped before its stream sends anything (its agent was replaced,
6010
+ * or stop() closed admission or ended the lifecycle the start began in)
6011
+ * ends the logical turn it was running. stop() ends every open turn
6012
+ * (openLogical), but a fresh start that awaited its batches' assembly
6013
+ * across a stop() reaches its routing after it and opens its turn. That
6014
+ * turn would then stay open, and the agent's first start after a restart
6015
+ * would continue the dropped turn's routing. The identity check leaves any
6016
+ * other turn open.
6017
+ */
6018
+ private endDroppedTurn(agentName: string, routing: TurnRouting): void {
6019
+ if (this.openLogical.get(agentName) === routing) this.openLogical.delete(agentName);
6020
+ }
6021
+
5357
6022
  /** A logical turn's end, for every agent: it closes the agent's open
5358
6023
  * logical turn when it is the settling stream's own (openLogical), and
5359
6024
  * settles an active ephemeral run. The run's tool-call count comes from
@@ -5361,21 +6026,31 @@ export class AgentFramework {
5361
6026
  * per-stream local survives budget restarts. */
5362
6027
  private settleAgent(
5363
6028
  agentName: string,
5364
- outcome: { stopReason: AgentSettleResult['stopReason']; speech: string; error?: string },
5365
- /** The settling stream's logical turn, when that stream still owns the
5366
- * agent: it ends here, so the agent's next fresh start opens a new one.
5367
- * A settle from a stream a newer one replaced closes nothing. */
5368
- closes: LogicalTurn | undefined,
6029
+ outcome: {
6030
+ stopReason: AgentSettleResult['stopReason'];
6031
+ speech: string;
6032
+ error?: string;
6033
+ /** An ephemeral run that ended rather than sleep the provider's stated
6034
+ * wait: that wait (statedWaitNotSlept). */
6035
+ statedWaitMs?: number;
6036
+ },
6037
+ /** The settling stream's logical turn (TurnRouting), when that stream
6038
+ * still owns the agent: it ends here, so the agent's next fresh start
6039
+ * opens a new one. A stream that no longer owns the agent passes none. */
6040
+ closes: TurnRouting | undefined,
5369
6041
  ): void {
5370
- // This identity check is the only guard against a stale settle closing
5371
- // a newer turn (no test reaches one); keep it when editing call sites.
6042
+ // The identity check keeps a settle that carries an earlier turn's
6043
+ // object from closing a newer turn; keep it when editing call sites.
5372
6044
  if (closes && this.openLogical.get(agentName) === closes) this.openLogical.delete(agentName);
5373
6045
  const run = this.ephemeralRuns.get(agentName);
5374
6046
  if (!run) return;
5375
6047
  run.lastActivity = Date.now();
5376
6048
  if (outcome.stopReason === 'exhausted') {
5377
6049
  // The caller may log or relay the message, which can echo the whole request.
5378
- run.settle.reject(new Error(boundFailureText(outcome.error ?? 'Unknown error')));
6050
+ const message = boundFailureText(outcome.error ?? 'Unknown error');
6051
+ run.settle.reject(outcome.statedWaitMs === undefined
6052
+ ? new Error(message)
6053
+ : new ProviderWaitNotSleptError(message, outcome.statedWaitMs));
5379
6054
  return;
5380
6055
  }
5381
6056
  run.settle.resolve({
@@ -5391,6 +6066,9 @@ export class AgentFramework {
5391
6066
  * The agent is temporarily registered, inference is triggered, and the
5392
6067
  * framework drives the stream (emitting traces, logging, dispatching tools).
5393
6068
  * Returns the agent's speech output when it finishes (no more tool calls).
6069
+ * Rejects with ProviderWaitNotSleptError when the run ends rather than sleep
6070
+ * a retryable failure's stated wait, and with a plain Error for any other
6071
+ * ending.
5394
6072
  *
5395
6073
  * The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
5396
6074
  * The task message should already be in the context manager.
@@ -5956,7 +6634,7 @@ export class AgentFramework {
5956
6634
  const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
5957
6635
  if (!server) {
5958
6636
  console.error(
5959
- `[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`,
6637
+ `[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`,
5960
6638
  );
5961
6639
  return;
5962
6640
  }
@@ -6009,27 +6687,41 @@ export class AgentFramework {
6009
6687
  idx = i; break;
6010
6688
  }
6011
6689
  if (idx < 0) return null;
6690
+ // The newest message can be the last shard of a body (a document stored
6691
+ // in shards). context-manager removes a shard only with the rest of its
6692
+ // body, so the rewind takes the whole body.
6693
+ const first = bodyRangeIn(all, idx).from;
6694
+ const members = all.slice(first, idx + 1);
6012
6695
  const msg = all[idx];
6013
6696
  const md = (msg.metadata ?? {}) as {
6014
6697
  messageId?: unknown; channelId?: unknown; system?: unknown;
6015
6698
  };
6016
6699
 
6017
- const content = Array.isArray(msg.content) ? msg.content : [];
6700
+ const content = members.flatMap((m) => (Array.isArray(m.content) ? m.content : []));
6018
6701
  const toolBlocks = content.filter((b) => typeOf(b) === 'tool_result');
6019
6702
  const images = content.filter((b) => typeOf(b) === 'image').length;
6020
6703
  const textLen = content
6021
6704
  .filter((b) => typeOf(b) === 'text')
6022
6705
  .reduce((n, b) => n + String((b as { text?: string }).text ?? '').length, 0);
6023
6706
 
6024
- // Determine the complete exchange to remove.
6025
- const removedIds: MessageId[] = [msg.id];
6707
+ // Determine the complete exchange to remove, newest first.
6708
+ const removedIds: MessageId[] = members.map((m) => m.id).reverse();
6709
+ // Where the exchange starts: this body, or the tool_use turn paired with it.
6710
+ let from = first;
6026
6711
  let kind: RewindRecord['kind'];
6027
6712
  let descriptor: string;
6028
6713
  if (toolBlocks.length > 0) {
6029
6714
  kind = 'tool';
6030
- // Pair the tool_result with the tool_use assistant turn right before it.
6031
- if (idx - 1 >= 0 && hasBlock(all[idx - 1], 'tool_use')) {
6032
- removedIds.push(all[idx - 1].id);
6715
+ // Pair the tool_result with the tool_use assistant turn right before
6716
+ // it, the whole of it: a turn stored as a body carries its tool_use on
6717
+ // its first shard, not its last.
6718
+ if (first - 1 >= 0) {
6719
+ const turn = bodyRangeIn(all, first - 1);
6720
+ const turnMembers = all.slice(turn.from, turn.to + 1);
6721
+ if (turnMembers.some((m) => hasBlock(m, 'tool_use'))) {
6722
+ from = turn.from;
6723
+ removedIds.push(...turnMembers.map((m) => m.id).reverse());
6724
+ }
6033
6725
  }
6034
6726
  const sz = toolBlocks.reduce(
6035
6727
  (n, b) => n + String((b as { content?: unknown }).content ?? '').length, 0);
@@ -6048,7 +6740,8 @@ export class AgentFramework {
6048
6740
  ? { channelId: String(md.channelId), messageId: String(md.messageId) }
6049
6741
  : undefined;
6050
6742
 
6051
- for (const id of removedIds) cm.removeMessage(id);
6743
+ // One removal for the whole exchange, so it is never half applied.
6744
+ removeStoredMessages(cm, all[from], msg);
6052
6745
  return { kind, descriptor, removedIds, discordRef };
6053
6746
  }
6054
6747
 
@@ -6243,7 +6936,15 @@ export class AgentFramework {
6243
6936
  * stated retry-after, kept across restarts) for one `model` or all, as an
6244
6937
  * explicit, recorded operator override.
6245
6938
  */
6246
- private async handleHostCommand(
6939
+ private handleHostCommand(
6940
+ serverId: string,
6941
+ params: HostCommandParams,
6942
+ ): ReturnType<AgentFramework['runHostCommand']> {
6943
+ return this.trackOperatorOperation(`host/command:${String(params.command)}`, () => this.runHostCommand(serverId, params));
6944
+ }
6945
+
6946
+ /** handleHostCommand's work, run where stop() can wait for it. */
6947
+ private async runHostCommand(
6247
6948
  serverId: string,
6248
6949
  params: HostCommandParams,
6249
6950
  ): Promise<{
@@ -6415,28 +7116,75 @@ export class AgentFramework {
6415
7116
  if (agent.state.status !== 'idle') {
6416
7117
  return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
6417
7118
  }
7119
+ // A surgery switches or forks the branch while it holds the store, so
7120
+ // a removal now would land on the branch it's leaving, or meet a
7121
+ // manager mid-switch: refused as busy, as the other operator calls are.
7122
+ if (this.surgeryHold) {
7123
+ return {
7124
+ ok: false,
7125
+ error: `Cannot hide: a live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} holds the store — retry when it completes`,
7126
+ };
7127
+ }
6418
7128
  if (!params.fromMessageId) {
6419
7129
  return { ok: false, error: 'hide: fromMessageId is required' };
6420
7130
  }
6421
7131
  const cm = agent.getContextManager();
6422
7132
  const all = cm.getAllMessages();
6423
- const byDiscordId = (did: string) =>
6424
- all.findIndex((m) => String((m.metadata as { messageId?: unknown } | undefined)?.messageId) === String(did));
7133
+ // Only a message that carries a Discord id matches: a missing one
7134
+ // would otherwise read as the text "undefined".
7135
+ const discordIdOf = (m: StoredMessage) => {
7136
+ const id = (m.metadata as { messageId?: unknown } | undefined)?.messageId;
7137
+ return id === undefined || id === null || id === '' ? undefined : String(id);
7138
+ };
7139
+ const byDiscordId = (did: string) => all.findIndex((m) => discordIdOf(m) === String(did));
7140
+ /** Runs in stored order, those that overlap or touch made one. */
7141
+ const merged = (runs: Array<{ from: number; to: number }>) => {
7142
+ const out: Array<{ from: number; to: number }> = [];
7143
+ for (const { from, to } of [...runs].sort((a, b) => a.from - b.from)) {
7144
+ const last = out[out.length - 1];
7145
+ if (last && from <= last.to + 1) last.to = Math.max(last.to, to);
7146
+ else out.push({ from, to });
7147
+ }
7148
+ return out;
7149
+ };
7150
+ // A message stored as a body (a long one, in shards) goes whole, and so
7151
+ // does every copy of it: a write cut short leaves a partial body before
7152
+ // its retry, both carrying the Discord id.
7153
+ const copiesOf = (did: string) => {
7154
+ const runs: Array<{ from: number; to: number }> = [];
7155
+ all.forEach((m, i) => { if (discordIdOf(m) === String(did)) runs.push(bodyRangeIn(all, i)); });
7156
+ return runs;
7157
+ };
6425
7158
 
6426
- // Collect Discord refs of every message in [lo, hi] that carries one,
7159
+ // Collect Discord refs of every message in the runs that carries one,
6427
7160
  // so the surface can mark them. channelId here is whatever the ingest
6428
7161
  // path stored (raw id or "discord:guild:channel"); the surface
6429
7162
  // normalizes it.
6430
- const refsIn = (lo: number, hi: number) => {
7163
+ // Each Discord message once, though every shard of a body carries it.
7164
+ const refsIn = (runs: Array<{ from: number; to: number }>) => {
6431
7165
  const refs: Array<{ channelId: string; messageId: string }> = [];
6432
- for (let i = lo; i <= hi; i++) {
6433
- const md = all[i].metadata as { messageId?: unknown; channelId?: unknown } | undefined;
6434
- if (md?.messageId && md?.channelId) {
7166
+ const seen = new Set<string>();
7167
+ for (const { from, to } of runs) {
7168
+ for (let i = from; i <= to; i++) {
7169
+ const md = all[i].metadata as { messageId?: unknown; channelId?: unknown } | undefined;
7170
+ if (!md?.messageId || !md?.channelId) continue;
7171
+ const key = `${String(md.channelId)}\u0000${String(md.messageId)}`;
7172
+ if (seen.has(key)) continue;
7173
+ seen.add(key);
6435
7174
  refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
6436
7175
  }
6437
7176
  }
6438
7177
  return refs;
6439
7178
  };
7179
+ // How many stored messages the runs hold, a body (a long message, in
7180
+ // shards) counting once.
7181
+ const messagesIn = (runs: Array<{ from: number; to: number }>) => {
7182
+ let count = 0;
7183
+ for (const { from, to } of runs) {
7184
+ for (let i = from; i <= to; i = bodyRangeIn(all, i).to + 1) count++;
7185
+ }
7186
+ return count;
7187
+ };
6440
7188
 
6441
7189
  const fromIdx = byDiscordId(params.fromMessageId);
6442
7190
  if (fromIdx < 0) {
@@ -6452,12 +7200,24 @@ export class AgentFramework {
6452
7200
  if (toIdx < 0) {
6453
7201
  return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
6454
7202
  }
6455
- const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
6456
- const refs = refsIn(lo, hi);
6457
- const rangeIds = all.slice(lo, hi + 1).map((m) => String(m.id));
6458
- cm.removeMessages(all[lo].id, all[hi].id);
7203
+ // What's between the two messages, after the earlier one began and
7204
+ // before the later one began: from the earlier one's first stored
7205
+ // copy through the later one's first, each whole. Each end is every
7206
+ // copy of it besides, as the single form takes, so a copy stored
7207
+ // outside that span (a write cut short and retried later) is taken
7208
+ // on its own, and nothing else stored after the later one began is
7209
+ // taken. Offline recovery's range is the same.
7210
+ const span = {
7211
+ from: bodyRangeIn(all, Math.min(fromIdx, toIdx)).from,
7212
+ to: bodyRangeIn(all, Math.max(fromIdx, toIdx)).to,
7213
+ };
7214
+ const runs = merged([span, ...copiesOf(params.fromMessageId), ...copiesOf(params.toMessageId)]);
7215
+ const refs = refsIn(runs);
7216
+ const hidden = messagesIn(runs);
7217
+ const rangeIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
7218
+ for (const { from, to } of runs) removeStoredMessages(cm, all[from], all[to]);
6459
7219
  console.error(
6460
- `[host-command] hide agent=${agentName} range removed=${hi - lo + 1} ` +
7220
+ `[host-command] hide agent=${agentName} range removed=${hidden} ` +
6461
7221
  `(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
6462
7222
  );
6463
7223
  const loggedRange = capIds(rangeIds);
@@ -6468,35 +7228,45 @@ export class AgentFramework {
6468
7228
  params: { fromMessageId: params.fromMessageId, toMessageId: params.toMessageId },
6469
7229
  result: {
6470
7230
  branch: this.store.currentBranch().name,
6471
- hidden: hi - lo + 1,
7231
+ hidden,
6472
7232
  removedIds: loggedRange.ids,
6473
7233
  ...(loggedRange.truncated ? { removedIdsTruncated: true } : {}),
6474
7234
  },
6475
7235
  });
6476
7236
  return {
6477
7237
  ok: true,
6478
- hidden: hi - lo + 1,
7238
+ hidden,
6479
7239
  hiddenRefs: refs,
6480
7240
  lastVisible: await this.lastVisiblePreview(agentName),
6481
7241
  };
6482
7242
  }
6483
- const refs = refsIn(fromIdx, fromIdx);
6484
- const hiddenId = String(all[fromIdx].id);
6485
- cm.removeMessage(all[fromIdx].id);
7243
+ const runs = merged(copiesOf(params.fromMessageId));
7244
+ const refs = refsIn(runs);
7245
+ const hidden = messagesIn(runs);
7246
+ const hiddenIds = runs.flatMap(({ from, to }) => all.slice(from, to + 1).map((m) => String(m.id)));
7247
+ for (const { from, to } of runs) removeStoredMessages(cm, all[from], all[to]);
6486
7248
  console.error(
6487
- `[host-command] hide agent=${agentName} removed=1 (${params.fromMessageId}) ` +
7249
+ `[host-command] hide agent=${agentName} removed=${hidden} (${params.fromMessageId}) ` +
6488
7250
  `by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
6489
7251
  );
7252
+ // Every shard of every copy goes, so the ids are bounded as the
7253
+ // range form's are.
7254
+ const loggedIds = capIds(hiddenIds);
6490
7255
  this.recordOperatorAction({
6491
7256
  kind: 'hide',
6492
7257
  agent: agentName,
6493
7258
  requester: hostCommandRequester(serverId, params),
6494
7259
  params: { fromMessageId: params.fromMessageId },
6495
- result: { branch: this.store.currentBranch().name, hidden: 1, removedIds: [hiddenId] },
7260
+ result: {
7261
+ branch: this.store.currentBranch().name,
7262
+ hidden,
7263
+ removedIds: loggedIds.ids,
7264
+ ...(loggedIds.truncated ? { removedIdsTruncated: true } : {}),
7265
+ },
6496
7266
  });
6497
7267
  return {
6498
7268
  ok: true,
6499
- hidden: 1,
7269
+ hidden,
6500
7270
  hiddenRefs: refs,
6501
7271
  lastVisible: await this.lastVisiblePreview(agentName),
6502
7272
  };
@@ -6550,7 +7320,7 @@ export class AgentFramework {
6550
7320
  let undone = 0;
6551
7321
  try {
6552
7322
  for (let i = 0; i < requested; i++) {
6553
- const r = this.undoLastTurn(agentName, hostCommandRequester(serverId, params));
7323
+ const r = this.runUndoLastTurn(agentName, hostCommandRequester(serverId, params));
6554
7324
  if (!r.undone) break;
6555
7325
  undone++;
6556
7326
  }
@@ -6558,7 +7328,7 @@ export class AgentFramework {
6558
7328
  // e.g. "Cannot undo while agent is streaming" — report what happened,
6559
7329
  // including any turns already undone before the failure.
6560
7330
  const msg = error instanceof Error ? error.message : String(error);
6561
- if (undone === 0) return { ok: false, error: msg };
7331
+ if (undone === 0) return { ok: false, error: msg, ...(error instanceof OperatorActionError ? { code: error.code } : {}) };
6562
7332
  console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
6563
7333
  }
6564
7334
 
@@ -6877,9 +7647,45 @@ export class AgentFramework {
6877
7647
  }
6878
7648
 
6879
7649
  // ==========================================================================
6880
- // Undo / Redo
7650
+ // Branch switch, Undo / Redo
6881
7651
  // ==========================================================================
6882
7652
 
7653
+ /**
7654
+ * Switch the store to an existing branch, start the delivery of the
7655
+ * Discord awareness marks the move changes (startDiscordAwarenessDelivery),
7656
+ * and re-materialize the config mount from it. Resolves once the mount has
7657
+ * run; if it fails, it rejects with the store already moved. The marks'
7658
+ * delivery isn't awaited, and its failure doesn't fail the switch. The
7659
+ * store has moved by the time the call returns, before its promise
7660
+ * settles, and a refusal moves nothing. An operator's operation: refused
7661
+ * once stop() has begun, and waited for by stop() while it runs.
7662
+ *
7663
+ * The branch moves under every agent sharing the store, so the switch
7664
+ * reserves the store as a surgery does: every agent must be idle, or it is
7665
+ * refused (OperatorActionError 'agent-busy'), and no turn starts until the
7666
+ * switch is done.
7667
+ */
7668
+ async switchBranch(name: string): Promise<void> {
7669
+ this.refuseOperatorAfterStop('switch branch');
7670
+ const release = this.reserveStoreForSurgery('switch branch', 'every agent');
7671
+ try {
7672
+ // A maintenance tick already running finishes before the switch.
7673
+ if (this.maintenanceRunning()) await this.settleMaintenancePass();
7674
+ this.store.switchBranch(name);
7675
+ // Reversible Discord awareness markers are part of the branch
7676
+ // projection. Their delivery starts behind the data-plane gate, which
7677
+ // goes up before the store is released, and the switch doesn't wait
7678
+ // for Discord.
7679
+ this.startDiscordAwarenessDelivery('branch switch');
7680
+ const ws = this.moduleRegistry.getModule('workspace');
7681
+ if (ws && 'materializeMount' in ws) {
7682
+ await (ws as { materializeMount: (m: string) => Promise<void> }).materializeMount('_config');
7683
+ }
7684
+ } finally {
7685
+ release();
7686
+ }
7687
+ }
7688
+
6883
7689
  /**
6884
7690
  * Undo the last inference turn for an agent.
6885
7691
  *
@@ -6895,6 +7701,18 @@ export class AgentFramework {
6895
7701
  turnIndex?: number;
6896
7702
  fromBranch?: string;
6897
7703
  toBranch?: string;
7704
+ } {
7705
+ this.refuseOperatorAfterStop('undo');
7706
+ return this.runUndoLastTurn(agentName, requester);
7707
+ }
7708
+
7709
+ /** undoLastTurn's work, for a host/command undo already admitted: a stop()
7710
+ * begun by a trace of one of its turns doesn't refuse the next. */
7711
+ private runUndoLastTurn(agentName: string, requester?: OperatorRequester): {
7712
+ undone: boolean;
7713
+ turnIndex?: number;
7714
+ fromBranch?: string;
7715
+ toBranch?: string;
6898
7716
  } {
6899
7717
  const agent = this.agents.get(agentName);
6900
7718
  if (!agent) {
@@ -6903,6 +7721,10 @@ export class AgentFramework {
6903
7721
  if (agent.state.status !== 'idle') {
6904
7722
  throw new Error(`Cannot undo while agent is ${agent.state.status}`);
6905
7723
  }
7724
+ // A surgery or a branch switch holds the store while it awaits its steps.
7725
+ if (this.surgeryHold) {
7726
+ throw new OperatorActionError('agent-busy', `Cannot undo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
7727
+ }
6906
7728
 
6907
7729
  const checkpoints = this.getTurnCheckpoints(agentName);
6908
7730
  if (checkpoints.length === 0) {
@@ -6918,11 +7740,7 @@ export class AgentFramework {
6918
7740
  this.store.createBranchAt(undoBranchName, currentBranch.name, checkpoint.sequenceBefore);
6919
7741
  this.store.switchBranch(undoBranchName);
6920
7742
 
6921
- // Materialize config files from the new branch (fire-and-forget; gate picks up via mtime)
6922
- const wsUndo = this.moduleRegistry.getModule('workspace');
6923
- if (wsUndo && 'materializeMount' in wsUndo) {
6924
- (wsUndo as any).materializeMount('_config').catch(() => {});
6925
- }
7743
+ this.materializeConfigMountAfterBranchSwitch();
6926
7744
 
6927
7745
  // Push onto redo stack
6928
7746
  let redoStack = this.redoStacks.get(agentName);
@@ -6966,6 +7784,7 @@ export class AgentFramework {
6966
7784
  fromBranch?: string;
6967
7785
  toBranch?: string;
6968
7786
  } {
7787
+ this.refuseOperatorAfterStop('redo');
6969
7788
  const agent = this.agents.get(agentName);
6970
7789
  if (!agent) {
6971
7790
  throw new Error(`Unknown agent: ${agentName}`);
@@ -6973,6 +7792,10 @@ export class AgentFramework {
6973
7792
  if (agent.state.status !== 'idle') {
6974
7793
  throw new Error(`Cannot redo while agent is ${agent.state.status}`);
6975
7794
  }
7795
+ // A surgery or a branch switch holds the store while it awaits its steps.
7796
+ if (this.surgeryHold) {
7797
+ throw new OperatorActionError('agent-busy', `Cannot redo: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName}`);
7798
+ }
6976
7799
 
6977
7800
  const redoStack = this.redoStacks.get(agentName);
6978
7801
  if (!redoStack || redoStack.length === 0) {
@@ -6984,11 +7807,7 @@ export class AgentFramework {
6984
7807
 
6985
7808
  this.store.switchBranch(branchName);
6986
7809
 
6987
- // Materialize config files from the restored branch (fire-and-forget; gate picks up via mtime)
6988
- const wsRedo = this.moduleRegistry.getModule('workspace');
6989
- if (wsRedo && 'materializeMount' in wsRedo) {
6990
- (wsRedo as any).materializeMount('_config').catch(() => {});
6991
- }
7810
+ this.materializeConfigMountAfterBranchSwitch();
6992
7811
 
6993
7812
  // Restore the checkpoint
6994
7813
  const checkpoints = this.getTurnCheckpoints(agentName);
@@ -7110,10 +7929,19 @@ export class AgentFramework {
7110
7929
  // arrivals during the awaited switch (see surgeryHold).
7111
7930
  const hold = { verb, agentName, since: Date.now() };
7112
7931
  this.surgeryHold = hold;
7932
+ // Maintenance calls parked at a provider gate wake, find the hold and are
7933
+ // withdrawn, so the wait for running maintenance can't wait on them.
7934
+ for (const gate of this.providerGates.values()) for (const resolve of gate.auxiliaryWaiters.splice(0)) resolve();
7935
+ // stop() waits for the release (see stopOwned): a surgery admitted before
7936
+ // stop() began finishes, flush included, before the store closes.
7937
+ let settle!: () => void;
7938
+ void this.trackOperatorOperation(`surgery:${verb}`, () => new Promise<void>((resolve) => { settle = resolve; }));
7113
7939
  let released = false;
7114
7940
  return () => {
7115
7941
  if (released) return;
7116
7942
  released = true;
7943
+ // The release runs to its end before stop() can go on.
7944
+ settle();
7117
7945
  if (this.surgeryHold === hold) this.surgeryHold = null;
7118
7946
  for (const [name, token] of reserved) {
7119
7947
  if (this.activeTurnTokens.get(name) === token) this.activeTurnTokens.delete(name);
@@ -7200,6 +8028,7 @@ export class AgentFramework {
7200
8028
  markers: SurgeryMarkerReceipt;
7201
8029
  lastVisible: { participant?: string; role?: string; preview?: string } | null;
7202
8030
  }> {
8031
+ this.refuseOperatorAfterStop('roll back');
7203
8032
  const logBase: OperatorLogInput = {
7204
8033
  kind: 'rollback',
7205
8034
  agent: agentName,
@@ -7242,6 +8071,8 @@ export class AgentFramework {
7242
8071
  let branchName: string;
7243
8072
  let markers: SurgeryMarkerReceipt;
7244
8073
  try {
8074
+ // A maintenance tick already running finishes before the switch.
8075
+ if (this.maintenanceRunning()) await this.settleMaintenancePass();
7245
8076
  // Prepare the external side effect before switching Chronicle. If the
7246
8077
  // process dies after the switch but before activate(), startup
7247
8078
  // promotes this batch by matching targetBranch to the active branch.
@@ -7352,6 +8183,7 @@ export class AgentFramework {
7352
8183
  markers: SurgeryMarkerReceipt;
7353
8184
  lastVisible: { participant?: string; role?: string; preview?: string } | null;
7354
8185
  }> {
8186
+ this.refuseOperatorAfterStop('suppress');
7355
8187
  const requestedIds = [...new Set(opts.messageIds.map(String))];
7356
8188
  const logBase: OperatorLogInput = {
7357
8189
  kind: 'suppress',
@@ -7409,6 +8241,8 @@ export class AgentFramework {
7409
8241
  let createdBranch: string;
7410
8242
  let markers: SurgeryMarkerReceipt;
7411
8243
  try {
8244
+ // A maintenance tick already running finishes before the fork.
8245
+ if (this.maintenanceRunning()) await this.settleMaintenancePass();
7412
8246
  const markerBatch = this.discordAwarenessOutbox?.prepare({
7413
8247
  agentName,
7414
8248
  sourceBranch,
@@ -7577,9 +8411,9 @@ export class AgentFramework {
7577
8411
  * undo or redo) without the caller awaiting it. No operator response waits
7578
8412
  * for Discord to accept marks: the MCPL data-plane gate alone holds channel
7579
8413
  * traffic that arrives after it until they are attempted.
7580
- * `syncDiscordAwarenessMarkers` installs that gate synchronously, before
7581
- * its first await, so calling this while a surgery still holds the store
7582
- * puts the gate up before the store is released. With no drain running, the
8414
+ * The sync's body installs that gate synchronously, before its first
8415
+ * await, so calling this while a surgery still holds the store puts the
8416
+ * gate up before the store is released. With no drain running, the
7583
8417
  * drain starts in the same synchronous run (the first reaction request can
7584
8418
  * be written before this returns); with one running, this generation's pass
7585
8419
  * follows it (drainForGeneration). Either way delivery continues
@@ -7590,10 +8424,20 @@ export class AgentFramework {
7590
8424
  * (queued when a surgery began, or arriving during its switch or fork): the
7591
8425
  * surgery's store reservation, which used to hold such a turn through the
7592
8426
  * drain, now ends once the gate is up. A failure is accounted by the gate
7593
- * itself: the current generation's failure recycles the connections, and a
7594
- * superseded one leaves that to the newer generation, whose own pass meets
7595
- * the same ledger. Here it is logged and raised as an ops alert, and the
7596
- * next sync, reconnect or start delivers what is still pending.
8427
+ * itself: the current generation's failure recycles the connections (or,
8428
+ * once the host is stopping, releases the gate instead), and a superseded
8429
+ * one leaves that to the newer generation, whose own pass meets the same
8430
+ * ledger. Here it is logged and raised as an ops alert, and the next sync,
8431
+ * reconnect or start delivers what is still pending.
8432
+ *
8433
+ * Like a server's drain, the delivery runs apart from the operation that
8434
+ * started it: it isn't an operator's operation, so stop() doesn't wait for
8435
+ * it. A drain already running when stop() begins runs on while the
8436
+ * connections close, and what it can't deliver stays pending in the
8437
+ * durable outbox. Once stop() has begun, none is started, since there's
8438
+ * no point opening a drain that the teardown is about to cut off: the
8439
+ * marks stay pending, and the next start's barrier reconciles the outbox
8440
+ * with the branch and delivers them.
7597
8441
  *
7598
8442
  * @param after What moved the branch, for the log and the alert
7599
8443
  * ("rollback", "branch switch", "undo" …).
@@ -7602,7 +8446,14 @@ export class AgentFramework {
7602
8446
  */
7603
8447
  startDiscordAwarenessDelivery(after: string, agentName = 'framework'): void {
7604
8448
  if (!this.discordAwarenessOutbox) return;
7605
- this.syncDiscordAwarenessMarkers().catch((error) => {
8449
+ if (this.stopHasBegun()) {
8450
+ console.error(
8451
+ `[discord-awareness] delivery after ${after} agent=${agentName} not started: ` +
8452
+ 'the framework is stopping; the next start delivers what is pending',
8453
+ );
8454
+ return;
8455
+ }
8456
+ this.runDiscordAwarenessSync().catch((error) => {
7606
8457
  const detail = error instanceof Error ? error.message : String(error);
7607
8458
  console.error(`[discord-awareness] delivery after ${after} agent=${agentName} failed: ${detail}`);
7608
8459
  this.opsAlert(
@@ -7613,14 +8464,19 @@ export class AgentFramework {
7613
8464
  });
7614
8465
  }
7615
8466
 
7616
- /** Re-materialize config files from the (new) active branch — fire and
7617
- * forget; the gate picks changes up via mtime. Mirrors undoLastTurn. */
8467
+ /** Start config file IO without holding the caller or a surgery's store
8468
+ * reservation. The gate picks it up by mtime; stop() still waits for it. */
7618
8469
  private materializeConfigMountAfterBranchSwitch(): void {
7619
8470
  const ws = this.moduleRegistry.getModule('workspace');
7620
8471
  if (ws && 'materializeMount' in ws) {
7621
- (ws as { materializeMount: (m: string) => Promise<void> })
7622
- .materializeMount('_config')
7623
- .catch(() => {});
8472
+ void this.trackOperatorOperation('config:materialize',
8473
+ () => (ws as { materializeMount: (m: string) => Promise<void> }).materializeMount('_config'),
8474
+ ).catch((error) => {
8475
+ console.error(
8476
+ '[workspace] re-materializing the config mount after a branch switch failed:',
8477
+ error instanceof Error ? error.message : error,
8478
+ );
8479
+ });
7624
8480
  }
7625
8481
  }
7626
8482
 
@@ -7804,12 +8660,23 @@ export class AgentFramework {
7804
8660
  this.adoptToolResultGuard(agent);
7805
8661
  agent.markContextConsumed(); // RFC-006: nothing stored before now is replaceable
7806
8662
  this.sharedSlotAgents.add(agent);
7807
- const restoredSettings = this.readAgentRuntimeSettings(config.name);
8663
+ let restoredSettings: AgentRuntimeSettingsOverrides | null = null;
8664
+ try {
8665
+ restoredSettings = this.readAgentRuntimeSettings(config.name);
8666
+ } catch (error) {
8667
+ console.error(
8668
+ `[agent-settings] ${config.name}: its persisted settings can't be read ` +
8669
+ `(${error instanceof Error ? error.message : String(error)}); it boots at the recipe's`,
8670
+ );
8671
+ }
7808
8672
  if (restoredSettings) {
7809
- agent.restoreRuntimeSettings(
8673
+ this.restorePersistedRuntimeSettings(
8674
+ config.name,
8675
+ agent,
7810
8676
  this.validatePersistedAgentRuntimeSettings(config.name, restoredSettings),
7811
8677
  );
7812
8678
  }
8679
+ agent.onContextBudgetCompiled((budget, fit) => this.recordCompiledContextBudget(config.name, budget, fit));
7813
8680
  this.agents.set(config.name, agent);
7814
8681
  this.agentConfigs.set(config.name, config);
7815
8682
 
@@ -8077,11 +8944,17 @@ export class AgentFramework {
8077
8944
  // queued, they flush at the target's own next boundary — with
8078
8945
  // injection — or in driveStream's finally when its turn ends.
8079
8946
  const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
8947
+ // The injected messages' ids, for a boundary that leaves them to the
8948
+ // turn's next start as arrivals.
8949
+ const injectedIds = new Map<object, MessageId>();
8080
8950
  {
8081
8951
  const deferred = this.drainDeferredFor(agent.name);
8082
8952
  if (deferred.length > 0) {
8083
8953
  for (const msg of deferred) {
8084
- agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
8954
+ const id = agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id));
8955
+ // Every other resident reads what this boundary stores; for its
8956
+ // own agent, the boundary weighs what it injects below.
8957
+ this.noteForOtherResidents(agent, msg.metadata, id);
8085
8958
  // Injection guards: tool blocks would corrupt the tool-cycle
8086
8959
  // structure the membrane enforces, and a message named as the
8087
8960
  // agent itself would render as an ASSISTANT turn on the wire
@@ -8091,11 +8964,13 @@ export class AgentFramework {
8091
8964
  (b) => b.type === 'tool_use' || b.type === 'tool_result'
8092
8965
  );
8093
8966
  if (!hasToolBlocks && msg.participant !== agent.name) {
8094
- midTurnInjections.push({
8967
+ const injection = {
8095
8968
  participant: msg.participant,
8096
8969
  content: msg.content,
8097
8970
  ...(msg.metadata ? { metadata: msg.metadata } : {}),
8098
- });
8971
+ };
8972
+ midTurnInjections.push(injection);
8973
+ injectedIds.set(injection, id);
8099
8974
  }
8100
8975
  }
8101
8976
  }
@@ -8138,13 +9013,18 @@ export class AgentFramework {
8138
9013
  // under budget instead of dispatching a doomed request.
8139
9014
  // Projection terms: prior round's real input + prior round's
8140
9015
  // OUTPUT (the just-generated thinking/text/tool_use joins the next
8141
- // round's input — omitting it is a systematic underestimate) +
8142
- // blocks about to be appended + reserve for the next response.
9016
+ // round's input — omitting it is a systematic underestimate) + the
9017
+ // growth since that round, when calls after it reported no prompt
9018
+ // (lastStreamUnreportedTokens) + blocks about to be appended +
9019
+ // reserve for the next response. It runs once a call on this
9020
+ // stream has reported a prompt: before then there is no figure to
9021
+ // project from.
8143
9022
  const projectedRealTokens = currentState.stream
8144
9023
  && agent.physicalWindowTokens !== undefined
8145
9024
  && agent.lastStreamRealInputTokens > 0
8146
9025
  ? agent.lastStreamRealInputTokens
8147
9026
  + agent.lastStreamOutputTokens
9027
+ + agent.lastStreamUnreportedTokens
8148
9028
  + estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections)
8149
9029
  + agent.maxTokens
8150
9030
  : 0;
@@ -8174,15 +9054,18 @@ export class AgentFramework {
8174
9054
  // classifier-safe D-class from the 2026-07-24 ablation). It also
8175
9055
  // updates lastAnnouncedLocus so the next turn's announce-on-change
8176
9056
  // diffs against what the agent was actually last told.
8177
- if (
8178
- (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') &&
8179
- !shouldEndTurn && !overBudget && currentState.stream
8180
- ) {
9057
+ // The turn's own mode: a silent turn routes no prose, whatever the
9058
+ // agent's mode, so its boundary neither re-pins nor notes. The
9059
+ // trigger is this stream's: its start holds the agent's turn token
9060
+ // while the agent waits on this round's tools.
9061
+ const routesLocus = (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid')
9062
+ && !this.activeTurnTriggers.get(agent.name)?.suppressProse;
9063
+ if (routesLocus && !shouldEndTurn && !overBudget && currentState.stream) {
8181
9064
  // Two signals qualify an injection to move the pin (n=6 + n=7):
8182
9065
  // - chat:addressed — someone explicitly spoke TO the agent
8183
9066
  // (mention / reply / DM);
8184
9067
  // - a conversational follow-up in a channel the agent itself
8185
- // explicitly sent into THIS turn (turnEngagedChannels) —
9068
+ // explicitly sent into THIS turn (TurnRouting.engaged) —
8186
9069
  // conversation the agent just engaged continues without
8187
9070
  // re-mentioning it (2026-07-31: q's #portables reply, no @,
8188
9071
  // answered in prose that followed the stale pin into
@@ -8193,44 +9076,23 @@ export class AgentFramework {
8193
9076
  // Reactions and system markers are already excluded by
8194
9077
  // isConversationalInjection, and the engaged-this-turn scope
8195
9078
  // keeps unrelated channels from moving the pin.
8196
- const engaged = this.turnEngagedChannels.get(agent.name);
8197
- const lastQualifying = [...midTurnInjections].reverse().find((inj) => {
8198
- const m = inj.metadata as Record<string, unknown> | undefined;
8199
- if (!isConversationalInjection(inj.metadata)) return false;
8200
- if (typeof m?.channelId !== 'string') return false;
8201
- const tags = m.tags as string[] | undefined;
8202
- if (isAddressedMessage(tags, m)) return true;
8203
- return engaged?.has(m.channelId as string) === true;
8204
- });
8205
- const newLocus = (lastQualifying?.metadata as Record<string, unknown> | undefined)
8206
- ?.channelId as string | undefined;
8207
- if (newLocus && this.turnLocusPins.get(agent.name) !== newLocus) {
8208
- const prevPin = this.turnLocusPins.get(agent.name) ?? null;
8209
- this.turnLocusPins.set(agent.name, newLocus);
8210
- this.lastAnnouncedLocus.set(agent.name, newLocus);
8211
- const label = this.channelRegistry?.getDescriptor(newLocus)?.label;
8212
- const shown = label && label !== newLocus
8213
- ? `${label.startsWith('#') ? label : `#${label}`} (${newLocus})`
8214
- : newLocus;
8215
- const noticeContent: ContentBlock[] = [{
8216
- type: 'text',
8217
- text:
8218
- `[routing] The conversation moved to ${shown} — your plain ` +
8219
- 'speech now lands there for the rest of this turn. Other ' +
8220
- 'channels need an explicit send tool.',
8221
- }];
8222
- const noticeMeta = { system: true, kind: 'routing-notice' } as MessageMetadata;
8223
- try {
8224
- const id = agent.getContextManager().addMessage('user', noticeContent, noticeMeta);
8225
- this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
8226
- midTurnInjections.push({ participant: 'user', content: noticeContent, metadata: noticeMeta });
8227
- console.error(
8228
- `[routing] ${agent.name}: mid-turn addressed re-pin ${prevPin ?? '(none)'} -> ${newLocus}`,
8229
- );
8230
- } catch (err) {
8231
- console.error('mid-turn locus re-pin: failed to record routing notice:', err);
8232
- }
9079
+ // The last injection that qualifies wins (repinChannel).
9080
+ const routing = this.routingOf(agent.name);
9081
+ let newLocus: string | undefined;
9082
+ for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus;
9083
+ if (newLocus) {
9084
+ // What this boundary just stored is newer than any arrival the
9085
+ // turn holds, so the arrivals can't move the locus back.
9086
+ routing.arrivals = [];
9087
+ const notice = this.repinTurnLocus(agent, routing, newLocus, 'mid-turn addressed');
9088
+ if (notice) midTurnInjections.push(notice);
8233
9089
  }
9090
+ } else if (routesLocus && !shouldEndTurn) {
9091
+ // A boundary that can't re-pin now (over budget, or no live
9092
+ // stream to tell) leaves its injections to the turn's next start,
9093
+ // as arrivals: a budget restart, or a re-inference on these tool
9094
+ // results.
9095
+ for (const inj of midTurnInjections) this.noteArrival(agent.name, inj.metadata, injectedIds.get(inj)!);
8234
9096
  }
8235
9097
 
8236
9098
  if (shouldEndTurn) {
@@ -8248,7 +9110,7 @@ export class AgentFramework {
8248
9110
  // agent must be wakeable the moment it goes idle. (Idempotent;
8249
9111
  // driveStream's finally is the backstop.)
8250
9112
  this.eventGate?.onInferenceEnded(agent.name);
8251
- this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name)?.logical);
9113
+ this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' }, this.turnRouting.get(agent.name));
8252
9114
  this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
8253
9115
  } else if (overBudget || overPhysical) {
8254
9116
  // Context budget exceeded: break the stream, let compile() compress.
@@ -8292,6 +9154,12 @@ export class AgentFramework {
8292
9154
  silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
8293
9155
  });
8294
9156
  } else if (currentState.stream) {
9157
+ // What this round appends is in the next call's prompt. If that
9158
+ // call reports no prompt, the next boundary projects it from the
9159
+ // growth since the last call that did.
9160
+ if (agent.physicalWindowTokens !== undefined) {
9161
+ agent.lastStreamUnreportedTokens += estimateAppendedRoundTokens(currentState.toolResults, spilled, midTurnInjections);
9162
+ }
8295
9163
  // Streaming path: convert results and resume the stream.
8296
9164
  // Mid-turn messages collected above ride along as injected user
8297
9165
  // messages (membrane ≥0.5.72) — appended after the tool_result
@@ -8483,7 +9351,7 @@ export class AgentFramework {
8483
9351
  // Same backstop as the push lane: no row, no wake, no fork spawned for a
8484
9352
  // message with nothing visible in it.
8485
9353
  if (isVisiblyEmptyContent(event.content)) {
8486
- console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
9354
+ console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${logValue(event.channelId)} messageId=${logValue(event.messageId)} reason=empty-content`);
8487
9355
  this.emitTrace({
8488
9356
  type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
8489
9357
  channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
@@ -8669,7 +9537,7 @@ export class AgentFramework {
8669
9537
  });
8670
9538
  } catch (error) {
8671
9539
  const err = error instanceof Error ? error : new Error(String(error));
8672
- console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
9540
+ console.error(`Failed to spawn conversation agent for ${logValue(event.channelId)}:`, err);
8673
9541
  this.emitTrace({
8674
9542
  type: 'mcpl:conversation-spawn-failed',
8675
9543
  channelId: event.channelId,
@@ -8722,9 +9590,10 @@ export class AgentFramework {
8722
9590
  /**
8723
9591
  * Spawn a persistent per-channel conversation agent ("fork") from the
8724
9592
  * template agent: own ContextManager under `conversations/{name}`, seeded
8725
- * with a copy of the template's compiled context (the SubagentModule
8726
- * wholesale-copy pattern), registered in the event loop like any agent —
8727
- * but never primary.
9593
+ * with the template's compiled context without its thinking or its unpaired
9594
+ * tool blocks (the SubagentModule wholesale-copy pattern; see the seeding
9595
+ * comment below), registered in the event loop like any agent — but never
9596
+ * primary.
8728
9597
  */
8729
9598
  private async createConversationAgent(name: string, channelId: string): Promise<Agent> {
8730
9599
  const router = this.conversationRouter!;
@@ -8758,14 +9627,54 @@ export class AgentFramework {
8758
9627
  // persisted precisely so names aren't reused, but if this namespace has
8759
9628
  // history anyway (counter state lost, crash between spawn and persist),
8760
9629
  // seeding again would stack another template copy on top of it.
8761
- const { messages: existing } = await contextManager.compile();
9630
+ //
9631
+ // Both compiles are dry runs: neither is sent, and the template's runs
9632
+ // while the template may be mid-stream, where a committing compile
9633
+ // would commit fold resolutions and queue the compression its fold plan
9634
+ // calls for (and, once context-manager's thinking binding ships, fence
9635
+ // its stream's replies). In context-manager 0.14 a dry run leaves
9636
+ // nothing else behind but the release of an expired compression hold
9637
+ // (see Agent.compileWithInjections), so the template's request in
9638
+ // flight keeps its calibration estimate.
9639
+ // The template's compile runs at context-manager's default budget (none
9640
+ // is passed), and a dry run still refuses a budget it can't fit: it
9641
+ // skips only the post-pick check, and an adaptive fold plan that
9642
+ // doesn't fit then overruns at the middle's emission. So a template
9643
+ // whose fold plan doesn't fit that budget fails the spawn with
9644
+ // context-manager's refusal, which names the tokens and the limit, as
9645
+ // a committing compile failed it. Only a windowed-passthrough template
9646
+ // whose newest message alone exceeds it seeds that message, larger than
9647
+ // the budget. The fork's own compiles meet its window by its strategy's
9648
+ // rule: passthrough, the fork's default, keeps the newest messages that
9649
+ // fit; an adaptive strategy folds to fit or throws over_budget for the
9650
+ // drain breaker.
9651
+ // The seed leaves out the template's thinking (withoutThinking): a
9652
+ // signed block verifies only while everything before it is what it was
9653
+ // minted under, and a fork's system, tools and scoped injections don't
9654
+ // promise that, while its own namespace holds no stamps that could
9655
+ // vouch for the copies. On models that don't bind thinking to its
9656
+ // prefix a copy would have been kept, so there the fork loses the
9657
+ // template's reasoning.
9658
+ // Then context-manager's stripUnpairedToolBlocks drops each call whose
9659
+ // result isn't in the seed and each result whose call isn't, keeping
9660
+ // the rest of their messages (a message left with nothing becomes its
9661
+ // "[tool call omitted]" placeholder). A fork spawned while the template
9662
+ // is between a round's call and its results (the coalescer can spawn
9663
+ // one outside the event loop) would otherwise give the fork's first
9664
+ // request an unanswered call: the passthrough strategies have no
9665
+ // pairing pass, and an adaptive template's compile has been through its
9666
+ // own.
9667
+ const { messages: existing } = await contextManager.compile(undefined, undefined, { dryRun: true });
8762
9668
  if (existing.length === 0) {
8763
9669
  // The copy is the fork's own, and the template's annotation edits only
8764
9670
  // the template's message: seed only once each of the template's
8765
9671
  // annotations has finished.
8766
9672
  const { messages: compiled } = await template.toolResultGuard.afterAnnotations(
8767
- () => template.getContextManager().compile());
8768
- for (const msg of compiled) {
9673
+ () => template.getContextManager().compile(undefined, undefined, { dryRun: true }));
9674
+ const withoutReasoning = withoutThinking(compiled);
9675
+ const seed = stripUnpairedToolBlocks(withoutReasoning);
9676
+ logSeedOmissions(name, template.name, compiled, withoutReasoning, seed);
9677
+ for (const msg of seed) {
8769
9678
  const participant = msg.participant === template.name ? name : msg.participant;
8770
9679
  contextManager.addMessage(participant, msg.content);
8771
9680
  }
@@ -9044,7 +9953,21 @@ export class AgentFramework {
9044
9953
  if (!server?.isConnected) throw new Error('server disconnected');
9045
9954
  return server.sendPushRender(params);
9046
9955
  },
9047
- audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
9956
+ audit: (record) => {
9957
+ // A render result the host refused falls back to the occurrence's own
9958
+ // content; say so on the host, with the server, the subject and the
9959
+ // failing field, not only in the trace. The error is a field like the
9960
+ // rest (logValue, spaces escaped), not a host-message tail: a server
9961
+ // that answers push/render with an error chooses its message.
9962
+ if (record.kind === 'render-failed') {
9963
+ console.error(
9964
+ `[push-render-failed] server=${logValue(record.serverId ?? '')} subject=${logValue(record.subject ?? '')} ` +
9965
+ `eventId=${logValue(record.eventId ?? '')}${record.field !== undefined ? ` field=${logValue(record.field)}` : ''}: ` +
9966
+ `${logValue(record.error ?? '')} (the occurrence's own content is delivered instead)`,
9967
+ );
9968
+ }
9969
+ this.emitTrace({ type: 'mcpl:coalescing', ...record });
9970
+ },
9048
9971
  save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
9049
9972
  recordReceipt: (record) => {
9050
9973
  // Written now, not at the throttled flush: an acknowledged acceptance
@@ -9151,7 +10074,7 @@ export class AgentFramework {
9151
10074
  throw new CoalesceError('eventId', 'eventId is required with coalesce');
9152
10075
  }
9153
10076
  if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
9154
- validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true });
10077
+ validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true });
9155
10078
  const c = message.coalesce;
9156
10079
  const result = await this.pushCoalescer.accept({
9157
10080
  serverId,
@@ -9178,7 +10101,7 @@ export class AgentFramework {
9178
10101
  validateCoalesceMember(params.coalesce, 'push');
9179
10102
  if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
9180
10103
  if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
9181
- validateCoalescedContent(params.payload?.content, undefined, {
10104
+ validateCoalescedContent(params.payload?.content, {
9182
10105
  allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
9183
10106
  serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
9184
10107
  }),
@@ -9267,7 +10190,7 @@ export class AgentFramework {
9267
10190
  this.persistConversationRouterState();
9268
10191
  return [decision.agentName];
9269
10192
  } catch (err) {
9270
- console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
10193
+ console.error(`[coalescing] could not spawn a conversation agent for ${logValue(occ.scope.id)}:`, err);
9271
10194
  return [];
9272
10195
  }
9273
10196
  }
@@ -9456,7 +10379,7 @@ export class AgentFramework {
9456
10379
  const agent = this.agents.get(p.agent);
9457
10380
  if (!agent) return false;
9458
10381
  try {
9459
- agent.getContextManager().removeMessage(p.messageId);
10382
+ removeStoredMessage(agent.getContextManager(), p.messageId as MessageId);
9460
10383
  this.emitTrace({ type: 'message:removed', messageId: p.messageId, source: 'mcpl:coalescing' });
9461
10384
  return true;
9462
10385
  } catch (err) {
@@ -9475,7 +10398,7 @@ export class AgentFramework {
9475
10398
  // model would wake to `[Continue]` or an older message re-presented as
9476
10399
  // the newest, a wake with no visible cause.
9477
10400
  if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
9478
- console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
10401
+ console.error(`[push-event-dropped] server=${event.serverId} eventId=${logValue(event.eventId ?? '')} reason=empty-content`);
9479
10402
  this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
9480
10403
  return undefined;
9481
10404
  }
@@ -9540,7 +10463,7 @@ export class AgentFramework {
9540
10463
  const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
9541
10464
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
9542
10465
  } else {
9543
- console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
10466
+ console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
9544
10467
  }
9545
10468
 
9546
10469
  if (event.triggerInference) {
@@ -9944,7 +10867,7 @@ export class AgentFramework {
9944
10867
  /** Record plain-prose segments this turn suppressed. */
9945
10868
  private recordProseSuppression(routing: TurnRouting, count: number): void {
9946
10869
  if (count <= 0) return;
9947
- routing.logical.suppressed += count;
10870
+ routing.suppressed += count;
9948
10871
  }
9949
10872
 
9950
10873
  /** Record a successful plain-prose delivery for this turn's receipt. */
@@ -9953,7 +10876,7 @@ export class AgentFramework {
9953
10876
  outcome: { delivered: boolean; channelId: string } | null | undefined,
9954
10877
  ): void {
9955
10878
  if (!outcome?.delivered) return;
9956
- routing.logical.deliveries.push(outcome.channelId);
10879
+ routing.deliveries.push(outcome.channelId);
9957
10880
  }
9958
10881
 
9959
10882
  /**
@@ -9970,11 +10893,11 @@ export class AgentFramework {
9970
10893
  * ([discord-send-failed]); this is the success half.
9971
10894
  */
9972
10895
  private appendProseDeliveryReceipt(agent: Agent, routing: TurnRouting): void {
9973
- const list = routing.logical.deliveries;
9974
- const suppressed = routing.logical.suppressed;
10896
+ const list = routing.deliveries;
10897
+ const suppressed = routing.suppressed;
9975
10898
  if (list.length === 0 && suppressed === 0) return;
9976
- routing.logical.deliveries = [];
9977
- routing.logical.suppressed = 0;
10899
+ routing.deliveries = [];
10900
+ routing.suppressed = 0;
9978
10901
  const seen = new Set<string>();
9979
10902
  const shown: string[] = [];
9980
10903
  for (const id of list ?? []) {
@@ -10026,7 +10949,6 @@ export class AgentFramework {
10026
10949
  if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus);
10027
10950
  return;
10028
10951
  }
10029
- this.lastAnnouncedLocus.set(agentName, locus);
10030
10952
 
10031
10953
  const agent = this.agents.get(agentName);
10032
10954
  if (!agent) return;
@@ -10053,6 +10975,9 @@ export class AgentFramework {
10053
10975
  [{ type: 'text', text }],
10054
10976
  { system: true, kind: 'routing-notice' },
10055
10977
  );
10978
+ // The baseline is what the agent was told: it moves once the notice is
10979
+ // stored, so a notice that fails is announced again at the next turn.
10980
+ this.lastAnnouncedLocus.set(agentName, locus);
10056
10981
  this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
10057
10982
  console.error(
10058
10983
  `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`,
@@ -10062,6 +10987,127 @@ export class AgentFramework {
10062
10987
  }
10063
10988
  }
10064
10989
 
10990
+ /**
10991
+ * The channel a message moves its turn's locus to, by the addressed
10992
+ * re-pin rule (2026-07-31, n=6 and n=7): a conversational message (not a
10993
+ * reaction or a system marker) that addressed the agent, or that follows
10994
+ * up in a channel the turn sent into (TurnRouting.engaged). Undefined for
10995
+ * any other message. A tool boundary weighs the messages it injects by it,
10996
+ * and noteArrival the ones no boundary of the turn weighs.
10997
+ */
10998
+ private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined {
10999
+ if (!isConversationalInjection(metadata)) return undefined;
11000
+ const m = metadata as Record<string, unknown> | undefined;
11001
+ if (typeof m?.channelId !== 'string') return undefined;
11002
+ if (isAddressedMessage(m.tags as string[] | undefined, m)) return m.channelId;
11003
+ return routing.engaged.has(m.channelId) ? m.channelId : undefined;
11004
+ }
11005
+
11006
+ /**
11007
+ * Weigh a message that no tool boundary of the agent's open logical turn
11008
+ * will weigh: one that would re-pin the locus becomes one of the turn's
11009
+ * arrivals (TurnRouting.arrivals). A message tune-out diverted
11010
+ * (metadata.tuneOut) never enters a resident's compiled view, so the agent
11011
+ * won't see it, and it moves nothing.
11012
+ */
11013
+ private noteArrival(agentName: string, metadata: MessageMetadata | undefined, messageId: MessageId): void {
11014
+ const open = this.openLogical.get(agentName);
11015
+ if (!open) return;
11016
+ if ((metadata as { tuneOut?: unknown } | undefined)?.tuneOut) return;
11017
+ const channelId = this.repinChannel(metadata, open);
11018
+ if (channelId) open.arrivals.push({ messageId, channelId });
11019
+ }
11020
+
11021
+ /**
11022
+ * Whether `agent`'s store is the residents' one message slot, which every
11023
+ * ordinary resident reads (sharedSlotAgents). The subconscious reads that
11024
+ * slot through a merged view, but its own store is isolated, as a fork's
11025
+ * or an ephemeral agent's is.
11026
+ */
11027
+ private isOrdinaryResident(agent: Agent): boolean {
11028
+ return this.sharedSlotAgents.has(agent) && agent.name !== this.subconsciousAgentName;
11029
+ }
11030
+
11031
+ /**
11032
+ * Weigh a message just stored in an ordinary resident's store for every
11033
+ * other ordinary resident's open turn, running or not: residents read one
11034
+ * message slot, and a tool boundary weighs only the messages drained for
11035
+ * its own agent, so no boundary of another resident's turn weighs it.
11036
+ */
11037
+ private noteForOtherResidents(agent: Agent, metadata: MessageMetadata | undefined, messageId: MessageId): void {
11038
+ if (!this.isOrdinaryResident(agent)) return;
11039
+ for (const reader of this.sharedSlotAgents) {
11040
+ if (reader !== agent && this.agents.get(reader.name) === reader && this.isOrdinaryResident(reader)) {
11041
+ this.noteArrival(reader.name, metadata, messageId);
11042
+ }
11043
+ }
11044
+ }
11045
+
11046
+ /**
11047
+ * The channel of the turn's newest arrival whose message is still in the
11048
+ * agent's store (TurnRouting.arrivals). A message removed since it was
11049
+ * stored, such as an unread coalesced message its server retracted, moves
11050
+ * nothing, and an earlier one that remains still does. Compression keeps a
11051
+ * message in the store; the last compile can't be the test, since an
11052
+ * arrival between the turn's streams isn't in it yet.
11053
+ */
11054
+ private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined {
11055
+ const cm = agent.getContextManager();
11056
+ for (let i = routing.arrivals.length - 1; i >= 0; i--) {
11057
+ const { messageId, channelId } = routing.arrivals[i]!;
11058
+ // The store answers null for a message that isn't there. A store that
11059
+ // can't be read throws here, as anywhere else in the start, rather
11060
+ // than have its candidates read as withdrawn.
11061
+ if (cm.getMessage(messageId)) return channelId;
11062
+ }
11063
+ return undefined;
11064
+ }
11065
+
11066
+ /**
11067
+ * Move a turn's locus to the channel its conversation moved to: an
11068
+ * addressed message, or a follow-up in a channel the turn engaged (the
11069
+ * addressed re-pin, repinChannel). Ambient chatter never moves it. Leaves
11070
+ * a durable `[routing]` notice in the window and updates
11071
+ * lastAnnouncedLocus once it's stored, so the next turn's announce-on-change
11072
+ * diffs against what the agent was last told. Returns the notice as stored, for a live
11073
+ * stream to inject with its boundary's other messages. Returns undefined
11074
+ * when the locus is already there, and when the notice couldn't be
11075
+ * stored. The locus has moved then too, but the announce baseline hasn't,
11076
+ * so the agent's next turn announces where its speech lands.
11077
+ */
11078
+ private repinTurnLocus(
11079
+ agent: Agent,
11080
+ routing: TurnRouting,
11081
+ channelId: string,
11082
+ cause: string,
11083
+ ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined {
11084
+ if (routing.locus === channelId) return undefined;
11085
+ const prevPin = routing.locus ?? null;
11086
+ routing.locus = channelId;
11087
+ const label = this.channelRegistry?.getDescriptor(channelId)?.label;
11088
+ const shown = label && label !== channelId
11089
+ ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})`
11090
+ : channelId;
11091
+ const content: ContentBlock[] = [{
11092
+ type: 'text',
11093
+ text:
11094
+ `[routing] The conversation moved to ${shown} — your plain ` +
11095
+ 'speech now lands there for the rest of this turn. Other ' +
11096
+ 'channels need an explicit send tool.',
11097
+ }];
11098
+ const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata;
11099
+ try {
11100
+ const id = agent.getContextManager().addMessage('user', content, metadata);
11101
+ this.lastAnnouncedLocus.set(agent.name, channelId);
11102
+ this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' });
11103
+ console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`);
11104
+ return { participant: 'user', content, metadata };
11105
+ } catch (err) {
11106
+ console.error('turn locus re-pin: failed to record routing notice:', err);
11107
+ return undefined;
11108
+ }
11109
+ }
11110
+
10065
11111
  /**
10066
11112
  * Durable window notice for a channel the agent did not ask to open —
10067
11113
  * subscription-policy admission, or an open forced by the agent's own
@@ -10113,10 +11159,10 @@ export class AgentFramework {
10113
11159
  */
10114
11160
  private requestProseContinuation(agent: Agent, routing: TurnRouting): void {
10115
11161
  // The flag may be shared with an earlier stream of the same logical
10116
- // turn (LogicalTurn); its deliveries are on the agent's chain ahead of
11162
+ // turn (TurnRouting); its deliveries are on the agent's chain ahead of
10117
11163
  // this stream's, so by the time this check runs, they have run.
10118
- if (!routing.logical.continuation) return;
10119
- routing.logical.continuation = false;
11164
+ if (!routing.continuation) return;
11165
+ routing.continuation = false;
10120
11166
  // An explicit turn's check can run after its stream has returned. A
10121
11167
  // stopped framework, or an agent no longer registered under its name (a
10122
11168
  // disposed fork or ephemeral run), takes no new turn from it.
@@ -10231,7 +11277,7 @@ export class AgentFramework {
10231
11277
  const normalized = envelope.replace(/^\s+(?=>>>)/, '');
10232
11278
  const attempted = normalized.startsWith('>>>');
10233
11279
  const parsed = parseHybridProsePrefix(normalized);
10234
- if (parsed.continueTurn) routing.logical.continuation = true;
11280
+ if (parsed.continueTurn) routing.continuation = true;
10235
11281
  if (parsed.kind === 'private') {
10236
11282
  delete routing.pin;
10237
11283
  routing.hybridHold = true;
@@ -10309,7 +11355,7 @@ export class AgentFramework {
10309
11355
  const parsed = parseProsePrefix(rawText);
10310
11356
  // A private round (one that called skip_reply) asks for no publication
10311
11357
  // or continuation, even when delivery runs after that turn has ended.
10312
- if (parsed.continueTurn && !round.keepPrivate) routing.logical.continuation = true;
11358
+ if (parsed.continueTurn && !round.keepPrivate) routing.continuation = true;
10313
11359
 
10314
11360
  if (parsed.kind === 'private') {
10315
11361
  // The text already lives in the assistant message (window/chronicle);
@@ -10593,15 +11639,75 @@ export class AgentFramework {
10593
11639
  let tokenHandedOff = false;
10594
11640
  try {
10595
11641
  tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
11642
+ } catch (error) {
11643
+ // A throw outside the stream's own failure handling (the checkpoint,
11644
+ // locus resolution, the typing indicator) drops this start's trigger,
11645
+ // and nothing retries or holds it. The agent's open logical turn is the
11646
+ // one this start was opening, continuing or resuming, so it ends here,
11647
+ // as a terminal failure in beginAgentTurn's own catch ends its turn:
11648
+ // the next start opens a turn of its own rather than continuing this
11649
+ // one's routing, and an ephemeral run fails with the error rather than
11650
+ // waiting out its watchdog. A silent start has no part in an open turn.
11651
+ if (!trigger?.suppressProse) {
11652
+ this.settleAgent(agent.name, {
11653
+ stopReason: 'exhausted', speech: '', error: error instanceof Error ? error.message : String(error),
11654
+ }, this.openLogical.get(agent.name));
11655
+ }
11656
+ throw error;
10596
11657
  } finally {
10597
11658
  if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
10598
11659
  this.activeTurnTokens.delete(agent.name);
10599
11660
  this.activeTurnTriggers.delete(agent.name);
10600
11661
  }
10601
11662
  if (!tokenHandedOff && ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
11663
+ // A start that ends before its stream exists lands what was deferred
11664
+ // during it, as a stream's teardown does. Left queued, those messages
11665
+ // would land only at the agent's next start, after any stored meanwhile.
11666
+ // Per message, as the turn-start flush does: one store failure must
11667
+ // neither drop the messages behind it nor replace the start's own error.
11668
+ if (!tokenHandedOff && !this.activeTurnTokens.has(agent.name)
11669
+ && this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
11670
+ for (const msg of this.drainDeferredFor(agent.name)) {
11671
+ try {
11672
+ this.addMessage(msg.participant, msg.content, msg.metadata, {
11673
+ deferredWriteId: msg.id,
11674
+ ...(msg.forAgent ? { forAgent: msg.forAgent } : {}),
11675
+ });
11676
+ } catch (err) {
11677
+ console.error(
11678
+ `[deferred-flush] failed-start flush failed to store a message for ${agent.name} ` +
11679
+ `(participant=${msg.participant}, id=${msg.id}):`,
11680
+ err,
11681
+ );
11682
+ }
11683
+ }
11684
+ this.ackDeferredWrites();
11685
+ }
10602
11686
  }
10603
11687
  }
10604
11688
 
11689
+ /**
11690
+ * End a turn that beginAgentTurn drops before it sends anything. The turn
11691
+ * emitted inference:started, and listeners that bracket a turn, such as a
11692
+ * typing loop or an activity indicator, end it on a terminal event, so it
11693
+ * gets inference:aborted with `reason`, and lastInference records its end
11694
+ * as driveStream's would, when the record is still this turn's. Nothing of
11695
+ * the kind when another turn of this agent's name holds the turn token (one
11696
+ * begun after a stop and start, or by a replacement agent object): the
11697
+ * event and the record carry only the name, so this frame's would end that
11698
+ * newer turn instead.
11699
+ */
11700
+ private abortDroppedTurn(agentName: string, turnToken: number, reason: string, startedAt: number): void {
11701
+ const owner = this.activeTurnTokens.get(agentName);
11702
+ if (owner !== undefined && owner !== turnToken) return;
11703
+ const now = Date.now();
11704
+ // Only this turn's own record ends: a name whose run was disposed meanwhile
11705
+ // (runEphemeralToCompletion deletes its record) gets none back.
11706
+ const last = this.lastInferenceAt.get(agentName);
11707
+ if (last?.startedAt === startedAt) this.lastInferenceAt.set(agentName, { ...last, endedAt: now });
11708
+ this.emitTrace({ type: 'inference:aborted', agentName, reason, durationMs: now - startedAt });
11709
+ }
11710
+
10605
11711
  /**
10606
11712
  * Body of startAgentStream, split out so the caller's try/finally owns the
10607
11713
  * turn token unconditionally. Returns true iff the token was handed off to
@@ -10615,7 +11721,7 @@ export class AgentFramework {
10615
11721
  ownsProviderGate: boolean,
10616
11722
  ): Promise<boolean> {
10617
11723
  const lifecycle = this.lifecycleGeneration;
10618
- const continuingTurn = resumesLogicalTurn(trigger?.reason);
11724
+ const resumesTurn = resumesLogicalTurn(trigger?.reason);
10619
11725
  const turnProseRouting = trigger?.suppressProse ? 'disabled' : agent.proseRouting;
10620
11726
  // Flush messages deferred during the PREVIOUS turn — before the
10621
11727
  // checkpoint, the locus announcement, and the compile — so a turn started
@@ -10633,7 +11739,7 @@ export class AgentFramework {
10633
11739
  // products — undoing the turn must not destroy them.
10634
11740
  if (
10635
11741
  attempt === 0 &&
10636
- !continuingTurn &&
11742
+ !resumesTurn &&
10637
11743
  this.deferredMessages.length > 0
10638
11744
  ) {
10639
11745
  {
@@ -10646,6 +11752,8 @@ export class AgentFramework {
10646
11752
  const id = agent.getContextManager().addMessage(
10647
11753
  msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id),
10648
11754
  );
11755
+ this.noteArrival(agent.name, msg.metadata, id);
11756
+ this.noteForOtherResidents(agent, msg.metadata, id);
10649
11757
  this.emitTrace({ type: 'message:added', messageId: id, source: 'deferred-flush:turn-start' });
10650
11758
  } catch (err) {
10651
11759
  console.error(
@@ -10664,7 +11772,7 @@ export class AgentFramework {
10664
11772
  // not yet run) and before the checkpoint (they are the turn's inputs).
10665
11773
  // Not on a context-budget restart: that continues the same logical turn.
10666
11774
  let assembly: AssemblyResult | undefined;
10667
- if (attempt === 0 && !continuingTurn && this.pushCoalescer?.pendingBatches()) {
11775
+ if (attempt === 0 && !resumesTurn && this.pushCoalescer?.pendingBatches()) {
10668
11776
  try {
10669
11777
  assembly = await this.pushCoalescer.assemble(agent.name);
10670
11778
  } catch (err) {
@@ -10711,45 +11819,57 @@ export class AgentFramework {
10711
11819
  this.redoStacks.delete(agent.name); // new work invalidates redo
10712
11820
  }
10713
11821
 
10714
- // Establish this turn's outbound routing locus (item-3 redux). Set it to the
10715
- // triggering channel for a channel/DM-triggered turn; clear it otherwise so
10716
- // a heartbeat / no-trigger turn doesn't inherit a previous turn's channel and
10717
- // instead falls back to the global default. A given agent runs one turn at a
10718
- // time, so a set here is only read during THIS turn's routeSpeech /
10719
- // buildChannelContext; retries re-run with the same trigger, re-setting it.
10720
- // A context-budget restart continues the SAME logical turn in a fresh
10721
- // stream — its trigger carries no channelId, and deleting the trigger
10722
- // channel here mid-logical-turn is exactly the hole that sent Sol's DM
10723
- // reply to a stale guild channel (2026-07-22): any live locus resolution
10724
- // after the restart fell through to the hours-old defaultPublishChannel.
10725
- // Keep the turn's trigger channel across restarts; only real new turns
10726
- // reset it.
10727
- if (!continuingTurn) {
10728
- if (trigger?.channelId) {
10729
- this.activeTriggerChannels.set(agent.name, trigger.channelId);
11822
+ // This start's routing: its logical turn's (TurnRouting), which holds
11823
+ // everything the turn has decided about where its speech goes.
11824
+ // - A resume (a budget restart, a tool-result-guard retry) carries its
11825
+ // turn's request on and keeps the turn's object as it is, and the
11826
+ // turn stays, or after a stop() becomes again, the agent's open turn.
11827
+ // - A silent start routes no prose. It gets an object of its own, with
11828
+ // no locus, so it neither takes nor disturbs an open turn's. Its
11829
+ // object is the agent's live routing while it runs, and the open
11830
+ // turn's next start makes the turn's object live again. (No silent
11831
+ // start runs while a prose turn is open in production: a capacity
11832
+ // hold holds silent wakes with the turn's, and a re-inference's wake
11833
+ // is queued before its stream ends, so it batches with them.)
11834
+ // - Any other start continues the agent's open logical turn, if one is
11835
+ // open (openLogical): a framework retry, the start that consumes tool
11836
+ // results the non-streaming fallback left (whichever wake the
11837
+ // scheduler starts it with), the later compile a provider-capacity
11838
+ // hold defers a turn to. It takes the turn's object: the sticky
11839
+ // destination, the destination and the hold, the ` !` and receipt
11840
+ // tallies, the trigger channel, the locus with any re-pin, and the
11841
+ // engaged channels. A delivery still queued from an earlier stream
11842
+ // runs ahead of this one's on the agent's chain, so what it sets
11843
+ // applies to this stream's prose, in the order the turn wrote them.
11844
+ // With no turn open, the start opens one.
11845
+ // A new object doesn't clear the previous turn's: prose that turn left
11846
+ // queued still reads and writes its own.
11847
+ let routing: TurnRouting;
11848
+ let opensTurn = false;
11849
+ if (resumesTurn) {
11850
+ routing = this.routingOf(agent.name);
11851
+ // The turn a resume continues is open, unless a stop() ended every
11852
+ // open turn since: it opens again, so its later starts continue it.
11853
+ if (!trigger?.suppressProse && !this.openLogical.has(agent.name)) this.openLogical.set(agent.name, routing);
11854
+ } else if (trigger?.suppressProse) {
11855
+ routing = freshTurnRouting();
11856
+ opensTurn = true;
11857
+ } else {
11858
+ const open = this.openLogical.get(agent.name);
11859
+ if (open) {
11860
+ routing = open;
10730
11861
  } else {
10731
- this.activeTriggerChannels.delete(agent.name);
10732
- }
10733
- }
10734
-
10735
- // FREEZE this turn's outbound locus now (turn-frozen routing). Resolved
10736
- // once — home channel for forks, else the triggering channel, else the
10737
- // global default — and never moved for the rest of the turn: mid-turn
10738
- // injections must not redirect the agent's plain prose (ambient chatter /
10739
- // reactions hijacked it before, 2026-07-21). A context-budget restart
10740
- // continues the same logical turn, so it keeps the existing pin. The
10741
- // eager snapshot also protects heartbeat turns from a moving
10742
- // defaultPublishChannel mid-turn. If the effective locus differs from the
10743
- // last one announced, drop a durable `[routing]` notice into the window
10744
- // BEFORE this turn compiles, so the agent always knows where its voice
10745
- // goes (announce-on-change only — no per-turn chatter, append-only for
10746
- // KV stability).
10747
- if (continuingTurn) {
10748
- const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
10749
- this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
11862
+ routing = freshTurnRouting();
11863
+ this.openLogical.set(agent.name, routing);
11864
+ opensTurn = true;
11865
+ }
10750
11866
  }
11867
+ this.turnRouting.set(agent.name, routing);
10751
11868
 
10752
- if (!continuingTurn) {
11869
+ if (resumesTurn) {
11870
+ const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
11871
+ this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
11872
+ } else {
10753
11873
  if (attempt === 0) this.maybePrimeProseMode(agent);
10754
11874
  const previousLogicalToolState = this.logicalTurnToolCalls.get(agent);
10755
11875
  if (attempt === 0) {
@@ -10759,72 +11879,63 @@ export class AgentFramework {
10759
11879
  // A framework retry is a new physical stream in the same logical turn.
10760
11880
  this.logicalTurnToolCalls.set(agent, { turnToken, count: previousLogicalToolState?.count ?? 0 });
10761
11881
  }
10762
- // Fresh turn: forget the previous turn's explicit-send engagements.
10763
- // A restart continues the same logical turn and keeps them.
10764
- this.turnEngagedChannels.delete(agent.name);
10765
- // A new routing state for the new turn, in every mode: the sticky
10766
- // destination, the turn's destination, hybrid's hold and the receipt
10767
- // tallies start empty. The previous turn's object isn't cleared: prose
10768
- // it left queued still reads and writes its own.
10769
- const fresh = freshTurnRouting();
10770
- // A start while the agent's logical turn is open continues it: it
10771
- // shares the earlier stream's ` !` and receipt tallies, which a
10772
- // delivery still queued there can write after this stream begins
10773
- // (openLogical). Otherwise this start opens one. A silent start does
10774
- // neither: it routes no prose, so it keeps a logical turn of its own,
10775
- // and an open turn waits for the next start that routes prose.
10776
- if (!trigger?.suppressProse) {
10777
- const open = this.openLogical.get(agent.name);
10778
- if (open) fresh.logical = open;
10779
- else this.openLogical.set(agent.name, fresh.logical);
10780
- }
10781
- this.turnRouting.set(agent.name, fresh);
10782
- if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') {
10783
- // Explicit and disabled prose routing have no inferred locus.
10784
- // Explicit mode uses a turn-scoped `>>` target; disabled mode has no
10785
- // prose target at all. Neither mode freezes or announces a locus.
10786
- this.midTurnInputSignals.delete(agent.name);
10787
- this.turnLocusPins.delete(agent.name);
10788
- } else {
11882
+ this.midTurnInputSignals.delete(agent.name);
11883
+ }
11884
+
11885
+ if (opensTurn) {
11886
+ // The turn's trigger channel (item-3 redux): the channel of a
11887
+ // channel/DM-triggered turn, and none for a heartbeat / no-trigger
11888
+ // turn, which falls back to the global default rather than inheriting
11889
+ // a previous turn's channel.
11890
+ routing.triggerChannel = trigger?.channelId;
11891
+ // FREEZE the turn's outbound locus now (turn-frozen routing): the home
11892
+ // channel for forks, else the trigger channel, else the global default.
11893
+ // Mid-turn injections must not redirect the agent's plain prose
11894
+ // (ambient chatter and reactions hijacked it before, 2026-07-21), and
11895
+ // the eager snapshot protects heartbeat turns from a moving
11896
+ // defaultPublishChannel. Explicit mode uses a turn-scoped `>>` target
11897
+ // and disabled mode has no prose target, so neither freezes or
11898
+ // announces a locus. If the effective locus differs from the last one
11899
+ // announced, a durable `[routing]` notice goes into the window BEFORE
11900
+ // this turn compiles, so the agent always knows where its voice goes
11901
+ // (announce-on-change only — no per-turn chatter, append-only for KV
11902
+ // stability).
11903
+ if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') {
10789
11904
  const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null;
10790
- if (locus !== null) this.turnLocusPins.set(agent.name, locus);
10791
- else this.turnLocusPins.delete(agent.name);
10792
- this.midTurnInputSignals.delete(agent.name);
11905
+ if (locus !== null) routing.locus = locus;
10793
11906
  if (attempt === 0) this.announceLocusIfChanged(agent.name, locus);
10794
11907
  }
11908
+ } else {
11909
+ // Every other start of the turn keeps its locus: a resume, or a start
11910
+ // that continues the open turn. A message no tool boundary of the turn
11911
+ // weighed can still move it, as it would have at a boundary
11912
+ // (TurnRouting.arrivals).
11913
+ // Only a turn that has a locus reads its arrivals' messages.
11914
+ const arrival = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'
11915
+ ? this.remainingArrival(agent, routing)
11916
+ : undefined;
11917
+ routing.arrivals = [];
11918
+ if (arrival) this.repinTurnLocus(agent, routing, arrival, 'arrival');
10795
11919
  }
10796
-
10797
- // A context-budget restart normally preserves the turn locus, but a
10798
- // silent control-plane wake must remain locusless even if an explicit tool
10799
- // opened/pinned a channel mid-turn.
10800
- if (trigger?.suppressProse) {
10801
- this.turnLocusPins.delete(agent.name);
10802
- // Silent: no routing state. A continuing silent start keeps its turn's
10803
- // logical turn; a fresh one keeps the one of its own it was given.
10804
- const silent = freshTurnRouting();
10805
- const logical = this.turnRouting.get(agent.name)?.logical;
10806
- if (logical) silent.logical = logical;
10807
- this.turnRouting.set(agent.name, silent);
10808
- }
10809
- // This start's own logical turn: a failure before its stream exists
10810
- // settles it, and no other start's.
10811
- const startLogical = this.turnRouting.get(agent.name)?.logical;
11920
+ // A silent start, or a disabled agent's, has no locus to show, whatever a
11921
+ // channel_open in a silent turn left on its object.
11922
+ const turnLocus = turnProseRouting === 'disabled' ? undefined : routing.locus;
10812
11923
 
10813
11924
  this.touchEphemeralRun(agent.name, true);
11925
+ const turnStartedAt = Date.now();
10814
11926
  this.emitTrace({
10815
11927
  type: 'inference:started',
10816
11928
  agentName: agent.name,
10817
- // The turn-frozen locus was pinned just above (kept across a
10818
- // context-budget restart, which skips the re-pin but re-emits this).
10819
- channelId: this.turnLocusPins.get(agent.name),
11929
+ // The turn's locus, kept across a start that continues the turn.
11930
+ channelId: turnLocus,
10820
11931
  ...(trigger?.suppressProse ? { silent: true } : {}),
10821
11932
  });
10822
11933
  // A budget restart continues the same logical inference window. The
10823
11934
  // predecessor keeps EventGate liveness until its successor terminates.
10824
- if (!continuingTurn) {
11935
+ if (!resumesTurn) {
10825
11936
  this.eventGate?.onInferenceStarted(agent.name);
10826
11937
  }
10827
- this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
11938
+ this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: turnStartedAt });
10828
11939
 
10829
11940
  // Typing indicator, started at TURN START. It says "attending", which is
10830
11941
  // true from the moment the turn is accepted — but it used to start inside
@@ -10838,7 +11949,7 @@ export class AgentFramework {
10838
11949
  const earlyTypingChannel =
10839
11950
  turnProseRouting === 'explicit'
10840
11951
  ? trigger?.channelId ?? null
10841
- : this.turnLocusPins.get(agent.name) ?? null;
11952
+ : turnLocus ?? null;
10842
11953
  if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
10843
11954
 
10844
11955
  try {
@@ -10899,8 +12010,13 @@ export class AgentFramework {
10899
12010
  // and stop() may close provider admission. Do not create a provider
10900
12011
  // stream after its generation lost ownership, or once admission closed.
10901
12012
  if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed) {
12013
+ const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed' : 'the framework was stopping';
12014
+ const reason = `a turn assembled while ${why} is dropped before it sends anything`;
12015
+ console.error(`[turn] ${agent.name}: ${reason}`);
12016
+ this.endDroppedTurn(agent.name, routing);
10902
12017
  this.channelRegistry?.stopTyping();
10903
12018
  this.eventGate?.onInferenceEnded(agent.name);
12019
+ this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
10904
12020
  return false;
10905
12021
  }
10906
12022
 
@@ -10929,10 +12045,11 @@ export class AgentFramework {
10929
12045
  // Membrane's yielding stream starts its request when it is first
10930
12046
  // iterated, so cancelling it here, before driveStream, sends nothing.
10931
12047
  if (this.agents.get(agent.name) !== agent || this.providerAdmissionClosed || lifecycle !== this.lifecycleGeneration) {
10932
- console.error(`[turn] ${agent.name}: a turn compiled while ${
10933
- this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
10934
- : this.providerAdmissionClosed ? 'the framework was stopping'
10935
- : 'the framework was stopped and started again'} is dropped before it sends anything`);
12048
+ const why = this.agents.get(agent.name) !== agent ? 'the agent was replaced or removed'
12049
+ : this.providerAdmissionClosed ? 'the framework was stopping'
12050
+ : 'the framework was stopped and started again';
12051
+ const reason = `a turn compiled while ${why} is dropped before it sends anything`;
12052
+ console.error(`[turn] ${agent.name}: ${reason}`);
10936
12053
  stream.cancel();
10937
12054
  agent.cancelStream();
10938
12055
  if (this.agents.get(agent.name) === agent) {
@@ -10942,8 +12059,10 @@ export class AgentFramework {
10942
12059
  this.activeStreams.delete(agent.name);
10943
12060
  this.pendingAssistantBlocks.delete(agent.name);
10944
12061
  }
12062
+ this.endDroppedTurn(agent.name, routing);
10945
12063
  this.channelRegistry?.stopTyping();
10946
12064
  this.eventGate?.onInferenceEnded(agent.name);
12065
+ this.abortDroppedTurn(agent.name, turnToken, reason, turnStartedAt);
10947
12066
  return false;
10948
12067
  }
10949
12068
  // A conversation agent takes no provider gate, so its own compression
@@ -11040,11 +12159,13 @@ export class AgentFramework {
11040
12159
  this.activeTurnTokens.delete(agent.name);
11041
12160
  this.activeTurnTriggers.delete(agent.name);
11042
12161
  }
12162
+ // This start's own logical turn: a failure before its stream exists
12163
+ // settles it, and no other start's.
11043
12164
  this.settleAgent(agent.name, {
11044
12165
  stopReason: 'exhausted',
11045
12166
  speech: '',
11046
- error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message,
11047
- }, startLogical);
12167
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
12168
+ }, routing);
11048
12169
  this.emitTrace({
11049
12170
  type: 'inference:exhausted',
11050
12171
  agentName: agent.name,
@@ -11092,8 +12213,9 @@ export class AgentFramework {
11092
12213
  const myStreamId = agent.streamId;
11093
12214
  // Membrane usage events are cumulative across the native/XML tool loop.
11094
12215
  // Keep the previous cumulative sample so consumers that operate at the
11095
- // physical provider-call boundary (estimator calibration and kv receipt
11096
- // acceptance) see this call only, not an ever-growing turn total.
12216
+ // physical provider-call boundary (estimator calibration and the
12217
+ // physical-window projection) see this call only, not an ever-growing
12218
+ // turn total.
11097
12219
  let previousUsage = {
11098
12220
  inputTokens: 0,
11099
12221
  outputTokens: 0,
@@ -11117,15 +12239,14 @@ export class AgentFramework {
11117
12239
  let generationLost = false;
11118
12240
 
11119
12241
  // ---- Present-while-acting turn state ---------------------------------
11120
- // Output locus for the WHOLE logical turn: frozen in startAgentStream
11121
- // (home → addressed trigger → global default) before this stream began,
11122
- // and never moved until the next turn. Mid-turn injections do not touch
11123
- // it — an ambient message or a reaction arriving at a tool boundary must
11124
- // not redirect the agent's task narration (2026-07-21 Cairn misroute).
11125
- // The pin lives in `turnLocusPins` so a context-budget restart (same
11126
- // logical turn, fresh driveStream) keeps it.
11127
- const resolveTurnLocus = (): string | null =>
11128
- this.turnLocusPins.get(agent.name) ?? null;
12242
+ // Output locus for the WHOLE logical turn: frozen when the turn opened
12243
+ // (home → addressed trigger → global default), and moved only by a
12244
+ // re-pin (an addressed message, or a follow-up in a channel the turn
12245
+ // engaged) or the agent's own channel_open. Ambient messages and
12246
+ // reactions arriving at a tool boundary must not redirect the agent's
12247
+ // task narration (2026-07-21 Cairn misroute). The locus lives in the
12248
+ // turn's routing state, so every stream of the turn shares it.
12249
+ const resolveTurnLocus = (): string | null => streamRouting.locus ?? null;
11129
12250
 
11130
12251
  // Ordered delivery chain for live-routed prose. Links are enqueued
11131
12252
  // WITHOUT awaiting in the stream-event loop — an awaited network post
@@ -11137,11 +12258,13 @@ export class AgentFramework {
11137
12258
  //
11138
12259
  // Every stream queues its deliveries behind the agent's latest link, so
11139
12260
  // the agent's posts go out in the order they were written, across a
11140
- // turn's streams (a budget restart, a tool-result-guard retry) and across
11141
- // turns. A turn that a tool ends (skip_reply) settles before its queued
11142
- // prose has posted; that prose keeps its own turn's routing state
11143
- // (turnRouting), and the next turn's posts wait behind it.
11144
- const resumesTurn = resumesLogicalTurn(trigger?.reason);
12261
+ // turn's streams and across turns. So an earlier stream's deliveries,
12262
+ // and the sticky destination or hold they set, come before this stream's
12263
+ // own. A stream queues only while it is the agent's current stream: the
12264
+ // event loop stops at the first event after a replacement. A turn that a
12265
+ // tool ends (skip_reply) settles before its queued prose has posted; that
12266
+ // prose keeps its own turn's routing state (turnRouting), and the next
12267
+ // turn's posts wait behind it.
11145
12268
  let turnSpeechChain: Promise<void> = this.proseSpeechChains.get(agent.name) ?? Promise.resolve();
11146
12269
  this.proseSpeechChains.set(agent.name, turnSpeechChain);
11147
12270
  const chainSpeech = (link: () => Promise<void>, failure: string): void => {
@@ -11233,14 +12356,14 @@ export class AgentFramework {
11233
12356
  ? new ProseStreamRouter({
11234
12357
  mode: turnProseRouting === 'explicit' ? 'explicit' : turnProseRouting === 'hybrid' ? 'hybrid' : 'locus',
11235
12358
  // Explicit text has no destination until a `>>` prefix names one,
11236
- // so a fresh turn's stream starts suppressed. One that resumes the
11237
- // turn (a tool-result-guard retry, a budget restart) starts at the
11238
- // destination the turn's delivered rounds have set: a private
11239
- // round's prefixes don't count, and the gateway's queued
11240
- // deliveries needn't have run. The trigger channel is only where
11241
- // explicit mode's typing indicator shows.
12359
+ // so a fresh turn's stream starts suppressed. A later stream of the
12360
+ // turn (a resume, a retry, a re-inference, a capacity-deferred
12361
+ // compile) starts at the destination the turn's delivered rounds
12362
+ // have set: a private round's prefixes don't count, and the
12363
+ // gateway's queued deliveries needn't have run. The trigger channel
12364
+ // is only where explicit mode's typing indicator shows.
11242
12365
  initialTarget: turnProseRouting === 'explicit'
11243
- ? (resumesTurn ? streamRouting.destination : null)
12366
+ ? streamRouting.destination
11244
12367
  : typingChannel,
11245
12368
  resolve: (spec) => {
11246
12369
  const r = this.channelRegistry!.resolveProseTarget(spec);
@@ -11820,7 +12943,7 @@ export class AgentFramework {
11820
12943
  .filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
11821
12944
  .map((block) => block.text)
11822
12945
  .join('\n'),
11823
- }, streamRouting.logical);
12946
+ }, streamRouting);
11824
12947
 
11825
12948
  this.emitTrace({
11826
12949
  type: 'inference:completed',
@@ -12121,13 +13244,11 @@ export class AgentFramework {
12121
13244
  this.recordProseSuppression(streamRouting, segments.length);
12122
13245
  }
12123
13246
  } else {
12124
- // Reuse the locus pinned at the turn's first live-routed
12125
- // segment (or resolve it now for a turn whose only prose is
12126
- // trailing). This dispatch runs AFTER the agent is idle (see
12127
- // PR #32 note below), so a queued inbound from another channel
12128
- // could otherwise overwrite the per-agent triggering channel
12129
- // between segments — and a turn that narrated live into one
12130
- // channel must not land its postscript in another.
13247
+ // The turn's own locus, read from this stream's routing
13248
+ // state. This dispatch runs AFTER the agent is idle (see PR
13249
+ // #32 note below), so a next turn may already have started
13250
+ // with routing of its own, and a turn that narrated live into
13251
+ // one channel must not land its postscript in another.
12131
13252
  const locus = resolveTurnLocus();
12132
13253
  console.error(
12133
13254
  `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`,
@@ -12214,7 +13335,7 @@ export class AgentFramework {
12214
13335
  stopReason: 'exhausted',
12215
13336
  speech: '',
12216
13337
  error: 'Turn abandoned by operator quiesce',
12217
- }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
13338
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
12218
13339
  this.emitTrace({
12219
13340
  type: 'inference:aborted',
12220
13341
  agentName: agent.name,
@@ -12251,8 +13372,8 @@ export class AgentFramework {
12251
13372
  this.settleAgent(agent.name, {
12252
13373
  stopReason: 'exhausted',
12253
13374
  speech: '',
12254
- error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message,
12255
- }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting.logical : undefined);
13375
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
13376
+ }, this.agents.get(agent.name) === agent && agent.streamId === myStreamId ? streamRouting : undefined);
12256
13377
  this.emitTrace({
12257
13378
  type: 'inference:exhausted',
12258
13379
  agentName: agent.name,
@@ -12309,7 +13430,7 @@ export class AgentFramework {
12309
13430
  stopReason: 'exhausted',
12310
13431
  speech: '',
12311
13432
  error: 'Turn abandoned by operator quiesce',
12312
- }, streamRouting.logical);
13433
+ }, streamRouting);
12313
13434
  }
12314
13435
  this.emitTrace({
12315
13436
  type: 'inference:aborted',
@@ -12353,7 +13474,7 @@ export class AgentFramework {
12353
13474
  stopReason: 'exhausted',
12354
13475
  speech: '',
12355
13476
  error: `Stream aborted: ${reason}`,
12356
- }, streamRouting.logical);
13477
+ }, streamRouting);
12357
13478
  this.emitTrace({
12358
13479
  type: 'inference:exhausted',
12359
13480
  agentName: agent.name,
@@ -12380,12 +13501,9 @@ export class AgentFramework {
12380
13501
  }
12381
13502
 
12382
13503
  case 'usage': {
13504
+ // The stream's accumulated fresh input, which is what the
13505
+ // maxStreamTokens budget checks.
12383
13506
  agent.lastStreamInputTokens = event.usage.inputTokens;
12384
- agent.lastStreamRealInputTokens =
12385
- (event.usage.inputTokens ?? 0) +
12386
- (event.usage.cacheCreationTokens ?? 0) +
12387
- (event.usage.cacheReadTokens ?? 0);
12388
- agent.lastStreamOutputTokens = event.usage.outputTokens ?? 0;
12389
13507
 
12390
13508
  // Closed-loop estimator calibration (2026-07-12). Sample the REAL
12391
13509
  // prefix size of THIS API call (fresh + cache write + cache read)
@@ -12416,6 +13534,34 @@ export class AgentFramework {
12416
13534
  };
12417
13535
  previousUsage = cumulativeUsage;
12418
13536
  outputTokensSinceStamp += perCallUsage.outputTokens;
13537
+ const realTotal =
13538
+ perCallUsage.inputTokens +
13539
+ perCallUsage.cacheCreationTokens +
13540
+ perCallUsage.cacheReadTokens;
13541
+ // The physical-window projection adds the next two fields as the
13542
+ // prior round's real input and output, so they take this call's
13543
+ // share, never the event's own figures: the event is the stream's
13544
+ // running total, which after k calls holds k prompts and would
13545
+ // restart a stream whose next request still fits. Every real call
13546
+ // has a prompt, and a call that ends at a tool boundary wrote
13547
+ // output (its tool_use), so a 0 in either is a count the adapter
13548
+ // left at 0, not an empty call. A call that reported no prompt is
13549
+ // unreported: the fields stay the last call that reported one,
13550
+ // which is what the provider billed, and its own output joins the
13551
+ // growth since that call (lastStreamUnreportedTokens). An output
13552
+ // of 0 counts as maxTokens, the bound on it, in either case, so
13553
+ // the projection errs toward a restart. That holds once a call on
13554
+ // the stream has reported a prompt: the projection runs only then.
13555
+ const callOutputTokens = perCallUsage.outputTokens > 0
13556
+ ? perCallUsage.outputTokens
13557
+ : agent.maxTokens;
13558
+ if (realTotal > 0) {
13559
+ agent.lastStreamRealInputTokens = realTotal;
13560
+ agent.lastStreamOutputTokens = callOutputTokens;
13561
+ agent.lastStreamUnreportedTokens = 0;
13562
+ } else {
13563
+ agent.lastStreamUnreportedTokens += callOutputTokens;
13564
+ }
12419
13565
  const strat = (agent as unknown as {
12420
13566
  getContextManager?: () => { getStrategy?: () => unknown };
12421
13567
  }).getContextManager?.()?.getStrategy?.() as
@@ -12430,10 +13576,6 @@ export class AgentFramework {
12430
13576
  }
12431
13577
  | undefined;
12432
13578
  try {
12433
- const realTotal =
12434
- perCallUsage.inputTokens +
12435
- perCallUsage.cacheCreationTokens +
12436
- perCallUsage.cacheReadTokens;
12437
13579
  strat?.reportRealInputTokens?.(realTotal);
12438
13580
  } catch { /* calibration is best-effort */ }
12439
13581
 
@@ -12512,8 +13654,8 @@ export class AgentFramework {
12512
13654
  this.settleAgent(agent.name, {
12513
13655
  stopReason: 'exhausted',
12514
13656
  speech: '',
12515
- error: statedWait === 'fail' ? this.statedWaitNotSlept(err) : err.message,
12516
- }, streamRouting.logical);
13657
+ ...(statedWait === 'fail' ? this.statedWaitNotSlept(err) : { error: err.message }),
13658
+ }, streamRouting);
12517
13659
  this.emitTrace({
12518
13660
  type: 'inference:exhausted',
12519
13661
  agentName: agent.name,
@@ -12727,6 +13869,16 @@ export class AgentFramework {
12727
13869
  agentName: string,
12728
13870
  toolName: string,
12729
13871
  input: Record<string, unknown>,
13872
+ ): Promise<{ toolUseId: string; result: ToolResult }> {
13873
+ this.refuseOperatorAfterStop('puppet');
13874
+ return this.trackOperatorOperation('puppet:tool-call', () => this.runPuppetToolCall(agentName, toolName, input));
13875
+ }
13876
+
13877
+ /** puppetToolCall's work, run where stop() can wait for it. */
13878
+ private async runPuppetToolCall(
13879
+ agentName: string,
13880
+ toolName: string,
13881
+ input: Record<string, unknown>,
12730
13882
  ): Promise<{ toolUseId: string; result: ToolResult }> {
12731
13883
  const agent = this.agents.get(agentName);
12732
13884
  if (!agent) throw new Error(`Unknown agent: ${agentName}`);
@@ -14427,6 +15579,12 @@ export class AgentFramework {
14427
15579
  opts?.deferredWriteId ? withDeferredWriteId(metadata, opts.deferredWriteId) : metadata,
14428
15580
  );
14429
15581
  if (opts?.placement) { opts.placement.agent = agent.name; opts.placement.messageId = stored; }
15582
+ // Stored while none of the agent's turns is alive, or assembled for the
15583
+ // start now beginning: no tool boundary will weigh it.
15584
+ if (!this.activeTurnTokens.has(agent.name) || opts?.bypassDeferralFor === agent.name) {
15585
+ this.noteArrival(agent.name, metadata, stored);
15586
+ }
15587
+ this.noteForOtherResidents(agent, metadata, stored);
14430
15588
  return stored;
14431
15589
  }
14432
15590
 
@@ -14478,7 +15636,7 @@ export class AgentFramework {
14478
15636
  if (!agent) {
14479
15637
  throw new Error('No agents configured');
14480
15638
  }
14481
- agent.getContextManager().removeMessage(id);
15639
+ removeStoredMessage(agent.getContextManager(), id);
14482
15640
  }
14483
15641
 
14484
15642
  private getMessage(id: MessageId): StoredMessage | null {
@@ -14952,34 +16110,58 @@ export class AgentFramework {
14952
16110
  // this turn — surface it on the ops-alert pipeline (ops:alert trace +
14953
16111
  // webhook → fleet-watch) immediately, not only at the hard-down streak.
14954
16112
  // The per-(agent,kind) cooldown throttles repeats; failures.log already
14955
- // has the per-exhaustion record from logFailure above.
14956
- if (overBudget) {
14957
- this.opsAlert(
14958
- 'context-refusal',
14959
- agentName,
14960
- `compile refused (${errorType ?? 'unclassified'}): ${boundFailureText(wholeReason, FAILURE_SUMMARY_MAX_CHARS)} — ` +
14961
- `drain breaker ${agent ? 'kicking' : 'unavailable (no agent handle)'}; ` +
14962
- `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`,
14963
- { skipLog: true },
14964
- );
14965
- }
14966
- if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
16113
+ // has the per-exhaustion record from logFailure above. It says whether
16114
+ // the drain breaker kicks, decided where the kick is. What ends drains is
16115
+ // stop(), not a framework that was never started: one run with
16116
+ // runUntilIdle() has no maintenance timer, so a drain is its recovery.
16117
+ const drainState = !overBudget ? undefined
16118
+ : !agent ? 'unavailable (no agent handle)'
16119
+ : this.providerAdmissionClosed ? 'not kicked: the framework is stopping'
16120
+ : this.surgeryHold ? 'not kicked: a surgery holds the store'
16121
+ : this.overBudgetDrainInFlight.has(agentName) ? 'already draining'
16122
+ : 'kicking';
16123
+ if (agent && drainState === 'kicking') {
14967
16124
  this.overBudgetDrainInFlight.add(agentName);
14968
- void (async () => {
16125
+ // Like a maintenance pass: none starts once stop() has begun, it starts
16126
+ // no tick while a surgery holds the store, and a surgery, a
16127
+ // switchBranch or stop() waits for the tick in flight. It joins the
16128
+ // drains they wait for before the alert is raised and before its body
16129
+ // runs, so the alert's listeners find it: one that begins stop() or a
16130
+ // surgery waits for it, and its body then ticks none and says why. So
16131
+ // does a stop() made from inside its first tick, before that tick's
16132
+ // first await.
16133
+ const drain = Promise.resolve().then(async () => {
14969
16134
  let ticks = 0;
14970
16135
  try {
14971
16136
  const cm = agent.getContextManager();
14972
- while (ticks < 8) {
16137
+ while (ticks < 8 && !this.providerAdmissionClosed && !this.surgeryHold) {
14973
16138
  await cm.tick();
14974
16139
  ticks++;
14975
16140
  }
14976
- console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks)`);
16141
+ const stopped = ticks >= 8 ? '' : this.providerAdmissionClosed ? ', stopped as the framework stops' : ', stopped while a surgery holds the store';
16142
+ console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks${stopped})`);
14977
16143
  } catch (err) {
14978
- console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
16144
+ if (err instanceof MaintenanceWithdrawnError) {
16145
+ console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks, stopped: ${err.message})`);
16146
+ } else {
16147
+ console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks: ${describeFailureWithFrames(err)}`);
16148
+ }
14979
16149
  } finally {
14980
16150
  this.overBudgetDrainInFlight.delete(agentName);
14981
16151
  }
14982
- })();
16152
+ });
16153
+ this.overBudgetDrains.add(drain);
16154
+ void drain.finally(() => { this.overBudgetDrains.delete(drain); });
16155
+ }
16156
+ if (overBudget) {
16157
+ this.opsAlert(
16158
+ 'context-refusal',
16159
+ agentName,
16160
+ `compile refused (${errorType ?? 'unclassified'}): ${boundFailureText(wholeReason, FAILURE_SUMMARY_MAX_CHARS)} — ` +
16161
+ `drain breaker ${drainState}; ` +
16162
+ `if this persists, raise contextBudgetTokens or overBudgetGraceRatio.`,
16163
+ { skipLog: true },
16164
+ );
14983
16165
  }
14984
16166
 
14985
16167
  // (3) Hard-down escalation on repeated identical failure.
@@ -15163,13 +16345,22 @@ export class AgentFramework {
15163
16345
  homeChannelResolver: (agentName) =>
15164
16346
  this.conversationAgentHomes.get(agentName)
15165
16347
  ?? this.conversationRouter?.channelForAgent(agentName),
15166
- // Route a single TRUNK agent's plain-text speech to the channel that
15167
- // triggered its CURRENT turn (item-3 redux). connectome-host runs every
15168
- // agent as a trunk (it never exposes conversation forks), so without this
15169
- // a reply falls back to the process-global most-recent-inbound locus and
15170
- // a concurrent message in another channel hijacks it. Empty for
15171
- // heartbeat / no-trigger turns → correct global fallback.
15172
- activeChannelResolver: (agentName) => this.activeTriggerChannels.get(agentName),
16348
+ // A single TRUNK agent's channel for its CURRENT turn (item-3 redux):
16349
+ // its locus once the turn has one, a re-pin's or a channel_open's
16350
+ // included, else the channel that triggered the turn. connectome-host
16351
+ // runs every agent as a trunk (it never exposes conversation forks), so
16352
+ // without this a reply falls back to the process-global
16353
+ // most-recent-inbound locus and a concurrent message in another
16354
+ // channel hijacks it. An opening start resolves the turn's locus from
16355
+ // it (the trigger channel then), and the channel context names it, so
16356
+ // a later start of the turn is told where its unprefixed speech lands
16357
+ // unless a hybrid `>>>` destination takes it. Empty for a heartbeat /
16358
+ // no-trigger turn until its locus is resolved, so that resolution
16359
+ // takes the global fallback; the turn then keeps that channel.
16360
+ activeChannelResolver: (agentName) => {
16361
+ const routing = this.turnRouting.get(agentName);
16362
+ return routing?.locus ?? routing?.triggerChannel;
16363
+ },
15173
16364
  // A text-only turn whose speech couldn't be delivered must not vanish
15174
16365
  // silently: record a `[discord-send-failed]` marker in chronicle so the
15175
16366
  // agent sees, on her next turn, that her reply never reached the human.
@@ -15265,15 +16456,17 @@ export class AgentFramework {
15265
16456
  }
15266
16457
  // Fail-open: log and continue with remaining servers
15267
16458
  const err = error instanceof Error ? error : new Error(String(error));
15268
- console.error(`Failed to connect MCPL server "${config.id}":`, err.message);
16459
+ console.error(`Failed to connect MCPL server "${config.id}": ${err.message}${withStderr(err)}`);
15269
16460
  // Reaching this catch means no stub was created, so nothing will
15270
16461
  // retry — except in the edge case where addServer succeeded (with a
15271
16462
  // reconnecting stub) and a later setup step threw; config.reconnect
15272
16463
  // is the best cheap approximation of that.
16464
+ const stderrTail = stderrTailOf(err);
15273
16465
  this.emitTrace({
15274
16466
  type: 'mcpl:server-connect-failed',
15275
16467
  serverId: config.id,
15276
16468
  error: err.message,
16469
+ ...(stderrTail === undefined ? {} : { stderrTail }),
15277
16470
  attempt: 0,
15278
16471
  willRetry: config.reconnect === true,
15279
16472
  });
@@ -15300,9 +16493,20 @@ export class AgentFramework {
15300
16493
  /**
15301
16494
  * Reconcile the durable ledger with Chronicle, then deliver every server's
15302
16495
  * work. A drain still running is waited for and then followed by a fresh
15303
- * pass (drainForGeneration).
16496
+ * pass (drainForGeneration). Like the other operator entry points, it's
16497
+ * refused once stop() has begun, and stop() waits for one already running.
16498
+ * The framework's own branch moves start their delivery without awaiting
16499
+ * it (startDiscordAwarenessDelivery).
15304
16500
  */
15305
- async syncDiscordAwarenessMarkers(_onlyServerId?: string): Promise<void> {
16501
+ async syncDiscordAwarenessMarkers(onlyServerId?: string): Promise<void> {
16502
+ this.refuseOperatorAfterStop('awareness sync');
16503
+ return this.trackOperatorOperation('awareness:sync', () => this.runDiscordAwarenessSync(onlyServerId));
16504
+ }
16505
+
16506
+ /** The sync's work. It installs the MCPL data-plane gate before its first
16507
+ * await, so a caller that starts it while holding the store puts the gate
16508
+ * up before releasing the store. */
16509
+ private async runDiscordAwarenessSync(_onlyServerId?: string): Promise<void> {
15306
16510
  if (!this.discordAwarenessOutbox) return;
15307
16511
  // A targeted retry may discover pending work for other Discord servers
15308
16512
  // during reconciliation. Safety is global, so one generation accounts for
@@ -15342,8 +16546,7 @@ export class AgentFramework {
15342
16546
  `${interval.fromId}..${interval.toId} is only partially present`,
15343
16547
  );
15344
16548
  }
15345
- if (interval.fromId === interval.toId) cm.removeMessage(from.id);
15346
- else cm.removeMessages(from.id, to.id);
16549
+ removeStoredMessages(cm, from, to);
15347
16550
  }
15348
16551
  this.discordAwarenessOutbox.activate(batch.id);
15349
16552
  console.error(`[discord-awareness] resumed suppression batch ${batch.id}`);
@@ -15658,6 +16861,15 @@ export class AgentFramework {
15658
16861
  error: `Discord awareness accounting unhealthy: ${err.message}`,
15659
16862
  });
15660
16863
  }
16864
+ // A stopping host closes these connections itself, so recycling them now
16865
+ // would start reconnects only for stop() to close them again. Release the
16866
+ // barrier instead, as a completed one is released: nothing waits on a
16867
+ // rejected barrier, and unless the host is quiesced, what the pause held
16868
+ // back reaches the listeners' stopping refusal.
16869
+ if (this.queue?.isClosed) {
16870
+ this.completeMcplDataPlaneGate(barrier);
16871
+ return;
16872
+ }
15661
16873
  await Promise.all(connections.map(async (connection) => {
15662
16874
  await connection.reconnectAfterFailure().catch((closeError) => {
15663
16875
  console.error(
@@ -16191,6 +17403,13 @@ export class AgentFramework {
16191
17403
  */
16192
17404
  async connectMcplServer(
16193
17405
  config: import('./mcpl/types.js').McplServerConfig,
17406
+ ): Promise<void> {
17407
+ this.refuseOperatorAfterStop('connect MCPL server');
17408
+ return this.trackOperatorOperation('mcpl:connect', () => this.runConnectMcplServer(config));
17409
+ }
17410
+
17411
+ private async runConnectMcplServer(
17412
+ config: import('./mcpl/types.js').McplServerConfig,
16194
17413
  ): Promise<void> {
16195
17414
  // Lazily bring up the MCPL subsystem — a framework that started with zero
16196
17415
  // configured servers can still deploy its first one at runtime.
@@ -16227,6 +17446,11 @@ export class AgentFramework {
16227
17446
  * No-op-ish if the server is not connected (still clears routing state).
16228
17447
  */
16229
17448
  async disconnectMcplServer(id: string): Promise<void> {
17449
+ this.refuseOperatorAfterStop('disconnect MCPL server');
17450
+ return this.trackOperatorOperation('mcpl:disconnect', () => this.runDisconnectMcplServer(id));
17451
+ }
17452
+
17453
+ private async runDisconnectMcplServer(id: string): Promise<void> {
16230
17454
  if (!this.mcplServerRegistry) {
16231
17455
  throw new Error('MCPL subsystem is not initialized');
16232
17456
  }
@@ -16260,6 +17484,14 @@ export class AgentFramework {
16260
17484
  async restartMcplServer(
16261
17485
  id: string,
16262
17486
  newConfig?: import('./mcpl/types.js').McplServerConfig,
17487
+ ): Promise<void> {
17488
+ this.refuseOperatorAfterStop('restart MCPL server');
17489
+ return this.trackOperatorOperation('mcpl:restart', () => this.runRestartMcplServer(id, newConfig));
17490
+ }
17491
+
17492
+ private async runRestartMcplServer(
17493
+ id: string,
17494
+ newConfig?: import('./mcpl/types.js').McplServerConfig,
16263
17495
  ): Promise<void> {
16264
17496
  const config = newConfig ?? this.mcplServerConfigs.get(id);
16265
17497
  if (!config) {
@@ -16271,8 +17503,8 @@ export class AgentFramework {
16271
17503
  // refuses the restart and leaves the running child up, whatever the
16272
17504
  // server's baseline or transport. (No outbox configured: nothing to read.)
16273
17505
  this.getPlacedReactionBaseline();
16274
- await this.disconnectMcplServer(id);
16275
- await this.connectMcplServer(config);
17506
+ await this.runDisconnectMcplServer(id);
17507
+ await this.runConnectMcplServer(config);
16276
17508
  }
16277
17509
 
16278
17510
  /**
@@ -16569,8 +17801,8 @@ export class AgentFramework {
16569
17801
  try {
16570
17802
  if (this.discordAwarenessBarrier) await this.awaitDiscordAwarenessBarriers();
16571
17803
  // A stopping host runs no host command: refused like the requests
16572
- // above, before its handler runs.
16573
- if (this.queue?.isClosed) {
17804
+ // above, before its handler runs, from the moment stop() has begun.
17805
+ if (this.stopHasBegun()) {
16574
17806
  this.refuseServerItem('host/command', connection.id, answer, { kind: 'refused' });
16575
17807
  return;
16576
17808
  }
@@ -16656,20 +17888,22 @@ export class AgentFramework {
16656
17888
  // Surface connect/reconnect failures. Before these traces existed the
16657
17889
  // only receipt was a console.error on the host's own stderr — invisible
16658
17890
  // unless someone ssh'd in and read the process log.
16659
- connection.on('connect-failed', (params: { error: string; attempt: number }) => {
17891
+ connection.on('connect-failed', (params: { error: string; stderrTail?: string; attempt: number }) => {
16660
17892
  this.emitTrace({
16661
17893
  type: 'mcpl:server-connect-failed',
16662
17894
  serverId: connection.id,
16663
17895
  error: params.error,
17896
+ ...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
16664
17897
  attempt: params.attempt,
16665
17898
  willRetry: connection.willReconnect,
16666
17899
  });
16667
17900
  });
16668
- connection.on('reconnect-failed', (params: { error: string; attempt: number }) => {
17901
+ connection.on('reconnect-failed', (params: { error: string; stderrTail?: string; attempt: number }) => {
16669
17902
  this.emitTrace({
16670
17903
  type: 'mcpl:server-connect-failed',
16671
17904
  serverId: connection.id,
16672
17905
  error: params.error,
17906
+ ...(params.stderrTail === undefined ? {} : { stderrTail: params.stderrTail }),
16673
17907
  attempt: params.attempt,
16674
17908
  willRetry: connection.willReconnect,
16675
17909
  });
@@ -16681,6 +17915,10 @@ export class AgentFramework {
16681
17915
  this.opsAlert(
16682
17916
  'mcpl-down',
16683
17917
  connection.id,
17918
+ // Not the server's stderr (params.stderrTail): the webhook posts
17919
+ // outside the process, and a server's stderr can hold a credential
17920
+ // its configuration gives it, such as a token, which nothing here
17921
+ // redacts.
16684
17922
  `MCPL server unreachable (attempt ${params.attempt}): ${params.error}`,
16685
17923
  );
16686
17924
  }
@@ -16726,7 +17964,7 @@ export class AgentFramework {
16726
17964
  // on it would erase every checkpoint tree on an ordinary host restart while
16727
17965
  // a SIGKILL preserves them. Permanent removal is owned solely by
16728
17966
  // disconnectMcplServer, which deletes the trees explicitly.
16729
- connection.on('close', (code?: number | null, signal?: string | null) => {
17967
+ connection.on('close', (code?: number | null, signal?: string | null, info?: McplConnectionCloseInfo) => {
16730
17968
  this.featureSetManager?.removeServer(connection.id);
16731
17969
  // RFC-008: a closed provider's class hints are stale — on reconnect it
16732
17970
  // may class its tools differently. Until the next tools/list its tools
@@ -16737,6 +17975,9 @@ export class AgentFramework {
16737
17975
  serverId: connection.id,
16738
17976
  code: code ?? null,
16739
17977
  signal: signal ?? null,
17978
+ // The transport's own words only: a WebSocket server's close text
17979
+ // stays off the trace.
17980
+ reason: info?.reason !== undefined && !info.reasonFromPeer ? boundFailureText(info.reason) : null,
16740
17981
  willReconnect: connection.willReconnect,
16741
17982
  });
16742
17983
  this.emitTrace({ type: 'module:removed', moduleName: `mcpl:${connection.id}` });
@@ -16989,6 +18230,9 @@ export class AgentFramework {
16989
18230
  this.emitTrace({ type: 'tool:started', module: `mcpl:${serverId}`, tool: toolName, callId: call.id, input: call.input });
16990
18231
  const startTime = Date.now();
16991
18232
  const args = (call.input && typeof call.input === 'object') ? call.input as Record<string, unknown> : {};
18233
+ // The turn that sends: a send's engaged channel is recorded on it, even
18234
+ // when the send's result, or the open that follows, comes after the turn.
18235
+ const sendingTurn = SEND_ENGAGEMENT_TOOLS.has(toolName) ? this.turnRouting.get(agentName) : undefined;
16992
18236
 
16993
18237
  // Build state params for stateful tools (Step 8).
16994
18238
  // A tools/list attribution selects that exact stateful set. Untagged tools
@@ -17088,12 +18332,7 @@ export class AgentFramework {
17088
18332
  // the microtask queue, i.e. before the tool-result event
17089
18333
  // that evaluates injections.
17090
18334
  if (status !== 'unknown-channel' && status !== 'ambiguous') {
17091
- let engaged = this.turnEngagedChannels.get(agentName);
17092
- if (!engaged) {
17093
- engaged = new Set();
17094
- this.turnEngagedChannels.set(agentName, engaged);
17095
- }
17096
- engaged.add(channelId ?? target);
18335
+ sendingTurn?.engaged.add(channelId ?? target);
17097
18336
  }
17098
18337
  if (status === 'opened') {
17099
18338
  console.error(
@@ -17211,6 +18450,15 @@ export class AgentFramework {
17211
18450
 
17212
18451
  this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
17213
18452
  const startTime = Date.now();
18453
+ // The turn that called: a channel_open's result records its trigger
18454
+ // channel there, however late it arrives, and moves its locus while the
18455
+ // turn is still open, even once another start's routing is the agent's
18456
+ // live one. A silent turn's open moves no locus: no silent start or
18457
+ // resume registers its routing state as the open turn (openLogical), and
18458
+ // a non-silent resume that does, a budget restart batched with an
18459
+ // ordinary wake, starts only once its over-budget boundary has every
18460
+ // result of the round.
18461
+ const callingTurn = this.routingOf(agentName);
17214
18462
 
17215
18463
  this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName })
17216
18464
  .then((result) => {
@@ -17219,10 +18467,10 @@ export class AgentFramework {
17219
18467
  // A successful channel_open plants the agent's feet in the opened
17220
18468
  // channel. The agent's own deliberate open is the strongest "my next
17221
18469
  // words go here" signal in the system — stronger than any injection —
17222
- // so it MOVES the current turn's prose pin (turnLocusPins), not just
17223
- // the next turn's trigger state. (The original 2026-07-21 Aria fix
17224
- // set only activeTriggerChannels, which resolveLocus reads at the
17225
- // NEXT turn's freeze — the turn-frozen refactor had silently
18470
+ // so it MOVES the current turn's locus (TurnRouting.locus), not just
18471
+ // its trigger channel. (The original 2026-07-21 Aria fix set only
18472
+ // the trigger channel, which resolveLocus reads at the NEXT turn's
18473
+ // freeze — the turn-frozen refactor had silently
17226
18474
  // regressed the "reply right after opening" case; found while
17227
18475
  // mapping the routing logic 2026-07-31, antra-ratified fix.) The
17228
18476
  // announcement rides THIS TOOL RESULT — model-requested content,
@@ -17234,10 +18482,19 @@ export class AgentFramework {
17234
18482
  (result.data as { channelId?: string } | undefined)?.channelId
17235
18483
  ?? (call.input as { channelId?: string } | undefined)?.channelId;
17236
18484
  if (opened) {
17237
- this.activeTriggerChannels.set(agentName, opened);
18485
+ callingTurn.triggerChannel = opened;
17238
18486
  const openerAgent = this.agents.get(agentName);
17239
- if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) {
17240
- this.turnLocusPins.set(agentName, opened);
18487
+ // The move is one this result tells the agent, so it needs the
18488
+ // turn that called still open. A result that lands after the turn
18489
+ // settled is dropped, so the agent never reads it, whether or not
18490
+ // a later start has replaced the turn's routing state yet: the
18491
+ // turn's locus stays where the agent was last told, for a resume
18492
+ // that opens the turn again, and so does the announce baseline,
18493
+ // which is the agent's across turns.
18494
+ if (openerAgent
18495
+ && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')
18496
+ && this.openLogical.get(agentName) === callingTurn) {
18497
+ callingTurn.locus = opened;
17241
18498
  this.lastAnnouncedLocus.set(agentName, opened);
17242
18499
  result = {
17243
18500
  ...result,
@@ -17520,7 +18777,7 @@ export class AgentFramework {
17520
18777
  console.error(`[sleep] ${agentName}: no prose target this turn — sleep announcement not posted (explicit mode never guesses)`);
17521
18778
  }
17522
18779
  } else {
17523
- const locus = this.turnLocusPins.get(agentName) ?? null;
18780
+ const locus = this.turnRouting.get(agentName)?.locus ?? null;
17524
18781
  this.channelRegistry.routeSpeech(agentName, text, locus).catch((err) => {
17525
18782
  console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
17526
18783
  });
@@ -18429,9 +19686,10 @@ export class AgentFramework {
18429
19686
  // Conversation identity = the agent (a trunk agent IS its own conversation;
18430
19687
  // forks get their own agent). The turn's channel LOCUS — the "proper
18431
19688
  // conversation tracking" this once flagged as a TODO — is now tracked
18432
- // per-agent in `activeTriggerChannels` and surfaced to the agent via
18433
- // buildChannelContext (channels.defaultOutgoing) below, so the agent is
18434
- // told the same channel its speech will route to (item-3 redux).
19689
+ // on the turn's routing state (TurnRouting.locus, from its trigger
19690
+ // channel) and surfaced to the agent via buildChannelContext
19691
+ // (channels.defaultOutgoing) below, so the agent is told the same
19692
+ // channel its speech will route to (item-3 redux).
18435
19693
  conversationId: agent.name,
18436
19694
  turnIndex: 0, // Simplified; needs per-conversation counter TODO
18437
19695
  userMessage: null, // Could extract from trigger context